Merge branch 'master' of https://github.com/payloadcms/payload into fix/select-field-clear-value

This commit is contained in:
PatrikKozak
2022-12-14 14:41:52 -05:00
49 changed files with 3732 additions and 2859 deletions

View File

@@ -22,22 +22,6 @@
&--has-tooltip {
position: relative;
}
.btn__tooltip {
opacity: 0;
visibility: hidden;
transform: translate(-50%, -10px);
}
.btn__content {
&:hover {
.btn__tooltip {
opacity: 1;
visibility: visible;
}
}
}
&--icon-style-without-border {

View File

@@ -1,4 +1,4 @@
import React, { isValidElement } from 'react';
import React, { Fragment, isValidElement } from 'react';
import { Link } from 'react-router-dom';
import { Props } from './types';
@@ -21,30 +21,31 @@ const icons = {
const baseClass = 'btn';
const ButtonContents = ({ children, icon, tooltip }) => {
const ButtonContents = ({ children, icon, tooltip, showTooltip }) => {
const BuiltInIcon = icons[icon];
return (
<span
className={`${baseClass}__content`}
>
{tooltip && (
<Tooltip className={`${baseClass}__tooltip`}>
{tooltip}
</Tooltip>
)}
{children && (
<span className={`${baseClass}__label`}>
{children}
</span>
)}
{icon && (
<span className={`${baseClass}__icon`}>
{isValidElement(icon) && icon}
{BuiltInIcon && <BuiltInIcon />}
</span>
)}
</span>
<Fragment>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{tooltip}
</Tooltip>
<span className={`${baseClass}__content`}>
{children && (
<span className={`${baseClass}__label`}>
{children}
</span>
)}
{icon && (
<span className={`${baseClass}__icon`}>
{isValidElement(icon) && icon}
{BuiltInIcon && <BuiltInIcon />}
</span>
)}
</span>
</Fragment>
);
};
@@ -53,7 +54,7 @@ const Button: React.FC<Props> = (props) => {
className,
id,
type = 'button',
el,
el = 'button',
to,
url,
children,
@@ -69,6 +70,8 @@ const Button: React.FC<Props> = (props) => {
tooltip,
} = props;
const [showTooltip, setShowTooltip] = React.useState(false);
const classes = [
baseClass,
className && className,
@@ -84,6 +87,7 @@ const Button: React.FC<Props> = (props) => {
].filter(Boolean).join(' ');
function handleClick(event) {
setShowTooltip(false);
if (type !== 'submit' && onClick) event.preventDefault();
if (onClick) onClick(event);
}
@@ -93,6 +97,8 @@ const Button: React.FC<Props> = (props) => {
type,
className: classes,
disabled,
onMouseEnter: tooltip ? () => setShowTooltip(true) : undefined,
onMouseLeave: tooltip ? () => setShowTooltip(false) : undefined,
onClick: !disabled ? handleClick : undefined,
rel: newTab ? 'noopener noreferrer' : undefined,
target: newTab ? '_blank' : undefined,
@@ -108,6 +114,7 @@ const Button: React.FC<Props> = (props) => {
<ButtonContents
icon={icon}
tooltip={tooltip}
showTooltip={showTooltip}
>
{children}
</ButtonContents>
@@ -123,6 +130,7 @@ const Button: React.FC<Props> = (props) => {
<ButtonContents
icon={icon}
tooltip={tooltip}
showTooltip={showTooltip}
>
{children}
</ButtonContents>
@@ -130,18 +138,21 @@ const Button: React.FC<Props> = (props) => {
);
default:
const Tag = el; // eslint-disable-line no-case-declarations
return (
<button
<Tag
type="submit"
{...buttonProps}
>
<ButtonContents
icon={icon}
tooltip={tooltip}
showTooltip={showTooltip}
>
{children}
</ButtonContents>
</button>
</Tag>
);
}
};

View File

@@ -1,10 +1,10 @@
import React, { MouseEvent } from 'react';
import React, { ElementType, MouseEvent } from 'react';
export type Props = {
className?: string,
id?: string,
type?: 'submit' | 'button',
el?: 'link' | 'anchor' | undefined,
el?: 'link' | 'anchor' | ElementType,
to?: string,
url?: string,
children?: React.ReactNode,

View File

@@ -14,22 +14,8 @@
width: 0px;
}
.tooltip {
pointer-events: none;
opacity: 0;
visibility: hidden;
}
&:focus,
&:active {
outline: none;
}
&:hover {
.tooltip {
opacity: 1;
visibility: visible;
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Copy from '../../icons/Copy';
import Tooltip from '../Tooltip';
@@ -18,14 +18,6 @@ const CopyToClipboard: React.FC<Props> = ({
const [hovered, setHovered] = useState(false);
const { t } = useTranslation('general');
useEffect(() => {
if (copied && !hovered) {
setTimeout(() => {
setCopied(false);
}, 1500);
}
}, [copied, hovered]);
if (value) {
return (
<button
@@ -44,13 +36,15 @@ const CopyToClipboard: React.FC<Props> = ({
ref.current.select();
ref.current.setSelectionRange(0, value.length + 1);
document.execCommand('copy');
setCopied(true);
}
}}
>
<Copy />
<Tooltip>
<Tooltip
show={hovered || copied}
delay={copied ? 0 : undefined}
>
{copied && (successMessage ?? t('copied'))}
{!copied && (defaultMessage ?? t('copy'))}
</Tooltip>

View File

@@ -102,7 +102,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
<p>
<Trans
i18nKey="aboutToDelete"
values={{ label: singular, title: titleToRender }}
values={{ label: getTranslation(singular, i18n), title: titleToRender }}
t={t}
>
aboutToDelete

View File

@@ -0,0 +1,36 @@
@import '../../../scss/styles.scss';
.doc-drawer {
&__header {
margin-bottom: base(1);
}
&__header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: base(2.5);
}
&__header-close {
svg {
width: base(2.5);
height: base(2.5);
position: relative;
top: base(-.5);
right: base(-.75);
.stroke {
stroke-width: .5px;
}
}
}
@include mid-break {
&__header-close {
svg {
top: base(-.75);
}
}
}
}

View File

@@ -0,0 +1,239 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types';
import DefaultEdit from '../../views/collections/Edit/Default';
import X from '../../icons/X';
import { Fields } from '../../forms/Form/types';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import { getTranslation } from '../../../../utilities/getTranslation';
import { Drawer, DrawerToggler } from '../Drawer';
import Button from '../Button';
import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../../views/collections/Edit/formatFields';
import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections';
import IDLabel from '../IDLabel';
import { useEditDepth } from '../../utilities/EditDepth';
import './index.scss';
const baseClass = 'doc-drawer';
const formatDocumentDrawerSlug = ({
collectionSlug,
id,
depth,
uuid,
}: {
collectionSlug: string,
id: string,
depth: number,
uuid?: string, // supply when creating a new document and no id is available
}) => `doc-drawer_${collectionSlug}_${depth}_${id || uuid || '0'}`;
export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
children,
className,
drawerSlug,
id,
collectionSlug,
...rest
}) => {
const { t, i18n } = useTranslation(['fields', 'general']);
const [collectionConfig] = useRelatedCollections(collectionSlug);
return (
<DrawerToggler
slug={drawerSlug}
formatSlug={false}
className={className}
aria-label={t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) })}
{...rest}
>
{children}
</DrawerToggler>
);
};
export const DocumentDrawer: React.FC<DocumentDrawerProps> = ({
collectionSlug,
id,
drawerSlug,
onSave,
customHeader,
}) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal, modalState, closeModal } = useModal();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const { t, i18n } = useTranslation(['fields', 'general']);
const hasInitializedState = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [collectionConfig] = useRelatedCollections(collectionSlug);
const [fields, setFields] = useState(() => formatFields(collectionConfig, true));
useEffect(() => {
setFields(formatFields(collectionConfig, true));
}, [collectionSlug, collectionConfig]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(id ? `${serverURL}${api}/${collectionSlug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
useEffect(() => {
if (isLoadingDocument || hasInitializedState.current === true) {
return;
}
const awaitInitialState = async () => {
const state = await buildStateFromSchema({
fieldSchema: fields,
data,
user,
operation: id ? 'update' : 'create',
id,
locale,
t,
});
setInitialState(state);
};
awaitInitialState();
hasInitializedState.current = true;
}, [data, fields, id, user, locale, isLoadingDocument, t]);
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
useEffect(() => {
if (isOpen && !isLoadingDocument && isError) {
closeModal(drawerSlug);
toast.error(data.errors?.[0].message || t('error:unspecific'));
}
}, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]);
if (isError) return null;
if (isOpen) {
// IMPORTANT: we must ensure that modals are not recursively rendered
// to do this, do not render the drawer until it is open
return (
<Drawer
slug={drawerSlug}
formatSlug={false}
className={baseClass}
>
<DocumentInfoProvider collection={collectionConfig}>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
componentProps={{
isLoading: !initialState,
data,
id,
collection: collectionConfig,
permissions: permissions.collections[collectionConfig.slug],
isEditing: Boolean(id),
apiURL: id ? `${serverURL}${api}/${collectionSlug}/${id}` : null,
onSave,
initialState,
hasSavePermission: true,
action: `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`,
disableEyebrow: true,
disableActions: true,
me: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<h2>
{!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(drawerSlug)}
aria-label={t('general:close')}
>
<X />
</Button>
</div>
{id && (
<IDLabel id={id} />
)}
</div>
),
}}
/>
</DocumentInfoProvider>
</Drawer>
);
}
return null;
};
export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => {
const drawerDepth = useEditDepth();
const uuid = useId();
const { modalState, toggleModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
const drawerSlug = formatDocumentDrawerSlug({
collectionSlug,
id,
depth: drawerDepth,
uuid,
});
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
const toggleDrawer = useCallback(() => {
toggleModal(drawerSlug);
}, [toggleModal, drawerSlug]);
const MemoizedDrawer = useMemo(() => {
return ((props) => (
<DocumentDrawer
{...props}
collectionSlug={collectionSlug}
id={id}
drawerSlug={drawerSlug}
key={drawerSlug}
/>
));
}, [id, drawerSlug, collectionSlug]);
const MemoizedDrawerToggler = useMemo(() => {
return ((props) => (
<DocumentDrawerToggler
{...props}
id={id}
collectionSlug={collectionSlug}
drawerSlug={drawerSlug}
/>
));
}, [id, drawerSlug, collectionSlug]);
const MemoizedDrawerState = useMemo(() => ({
drawerSlug,
drawerDepth,
isDrawerOpen: isOpen,
toggleDrawer,
}), [drawerDepth, drawerSlug, isOpen, toggleDrawer]);
return [
MemoizedDrawer,
MemoizedDrawerToggler,
MemoizedDrawerState,
];
};

View File

@@ -0,0 +1,31 @@
import React, { HTMLAttributes } from 'react';
export type DocumentDrawerProps = {
collectionSlug: string
id?: string
onSave?: (json: Record<string, unknown>) => void
customHeader?: React.ReactNode
drawerSlug?: string
}
export type DocumentTogglerProps = HTMLAttributes<HTMLButtonElement> & {
children?: React.ReactNode
className?: string
drawerSlug?: string
id?: string
collectionSlug: string
}
export type UseDocumentDrawer = (args: {
id?: string
collectionSlug: string
}) => [
React.FC<Omit<DocumentDrawerProps, 'collectionSlug' | 'id'>>, // drawer
React.FC<Omit<DocumentTogglerProps, 'collectionSlug' | 'id'>>, // toggler
{
drawerSlug: string,
drawerDepth: number
isDrawerOpen: boolean
toggleDrawer: () => void
}
]

View File

@@ -0,0 +1,86 @@
@import '../../../scss/styles.scss';
.drawer {
display: flex;
overflow: hidden;
position: fixed;
height: 100vh;
&__blur-bg {
@include blur-bg();
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
transition: all 300ms ease-out;
}
&__content {
@include blur-bg();
opacity: 0;
transform: translateX(#{base(4)});
position: relative;
z-index: 2;
width: 100%;
transition: all 300ms ease-out;
}
&__content-children {
position: relative;
z-index: 1;
overflow: auto;
height: 100%;
}
&--is-open {
.drawer__content,
.drawer__blur-bg,
.drawer__close {
opacity: 1;
}
.drawer__close {
transition: opacity 300ms ease-in-out;
transition-delay: 100ms;
}
.drawer__content {
transform: translateX(0);
}
}
&__close {
@extend %btn-reset;
position: relative;
z-index: 2;
flex-shrink: 0;
text-indent: -9999px;
background: rgba(0, 0, 0, 0.08);
cursor: pointer;
opacity: 0;
will-change: opacity;
transition: none;
transition-delay: 0ms;
&:active,
&:focus {
outline: 0;
}
}
@include mid-break {
&__close {
width: base(1);
}
}
}
html[data-theme=dark] {
.drawer__close {
background: rgba(0, 0, 0, 0.2);
}
}

View File

@@ -0,0 +1,100 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useTranslation } from 'react-i18next';
import { Props, TogglerProps } from './types';
import { EditDepthContext, useEditDepth } from '../../utilities/EditDepth';
import './index.scss';
const baseClass = 'drawer';
const zBase = 100;
const formatDrawerSlug = ({
slug,
depth,
}: {
slug: string,
depth: number,
}) => `drawer_${depth}_${slug}`;
export const DrawerToggler: React.FC<TogglerProps> = ({
slug,
formatSlug,
children,
className,
onClick,
...rest
}) => {
const { openModal } = useModal();
const drawerDepth = useEditDepth();
const handleClick = useCallback((e) => {
openModal(formatSlug !== false ? formatDrawerSlug({ slug, depth: drawerDepth }) : slug);
if (typeof onClick === 'function') onClick(e);
}, [openModal, drawerDepth, slug, onClick, formatSlug]);
return (
<button
onClick={handleClick}
type="button"
className={className}
{...rest}
>
{children}
</button>
);
};
export const Drawer: React.FC<Props> = ({
slug,
formatSlug,
children,
className,
}) => {
const { t } = useTranslation('general');
const { closeModal, modalState } = useModal();
const { breakpoints: { m: midBreak } } = useWindowInfo();
const drawerDepth = useEditDepth();
const [isOpen, setIsOpen] = useState(false);
const [modalSlug] = useState(() => (formatSlug !== false ? formatDrawerSlug({ slug, depth: drawerDepth }) : slug));
useEffect(() => {
setIsOpen(modalState[modalSlug].isOpen);
}, [modalSlug, modalState]);
return (
<Modal
slug={modalSlug}
className={[
className,
baseClass,
isOpen && `${baseClass}--is-open`,
].filter(Boolean).join(' ')}
style={{
zIndex: zBase + drawerDepth,
}}
>
{drawerDepth === 1 && (
<div className={`${baseClass}__blur-bg`} />
)}
<button
className={`${baseClass}__close`}
id={`close-drawer__${modalSlug}`}
type="button"
onClick={() => closeModal(modalSlug)}
style={{
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${drawerDepth - 1} * 25px)`,
}}
aria-label={t('close')}
/>
<div className={`${baseClass}__content`}>
<div className={`${baseClass}__content-children`}>
<EditDepthContext.Provider value={drawerDepth + 1}>
{children}
</EditDepthContext.Provider>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,15 @@
import { HTMLAttributes } from 'react';
export type Props = {
slug: string
formatSlug?: boolean
children: React.ReactNode
className?: string
}
export type TogglerProps = HTMLAttributes<HTMLButtonElement> & {
slug: string
formatSlug?: boolean
children: React.ReactNode
className?: string
}

View File

@@ -0,0 +1,5 @@
@import '../../../../scss/styles.scss';
.clear-indicator {
cursor: pointer;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IndicatorProps } from 'react-select';
import X from '../../../icons/X';
import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'clear-indicator';
export const ClearIndicator: React.FC<IndicatorProps<OptionType, true>> = (props) => {
const {
innerProps: { ref, ...restInnerProps },
} = props;
return (
<div
className={baseClass}
ref={ref}
{...restInnerProps}
>
<X className={`${baseClass}__icon`} />
</div>
);
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { components as SelectComponents, ControlProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
export const Control: React.FC<ControlProps<Option, any>> = (props) => {
const {
children,
innerProps,
selectProps: {
selectProps: {
disableMouseDown,
disableKeyDown,
},
},
} = props;
return (
<SelectComponents.Control
{...props}
innerProps={{
...innerProps,
onMouseDown: (e) => {
// we need to prevent react-select from hijacking the 'onMouseDown' event while modals are open (i.e. the 'Relationship' field component)
if (!disableMouseDown) {
innerProps.onMouseDown(e);
}
},
// react-select has this typed incorrectly so we disable the linting rule
// we need to prevent react-select from hijacking the 'onKeyDown' event while modals are open (i.e. the 'Relationship' field component)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onKeyDown: (e) => {
if (disableKeyDown) {
e.stopPropagation();
}
},
}}
>
{children}
</SelectComponents.Control>
);
};

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.multi-value {
&.rs__multi-value {
padding: 0;
background: transparent;
border: $style-stroke-width-s solid var(--theme-elevation-800);
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
&.draggable {
cursor: grab;
}
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import {
MultiValueProps,
components as SelectComponents,
} from 'react-select';
import { useSortable } from '@dnd-kit/sortable';
import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'multi-value';
export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
const {
className,
isDisabled,
innerProps,
data: {
value,
},
selectProps: {
selectProps,
selectProps: {
disableMouseDown,
},
},
} = props;
const { attributes, listeners, setNodeRef, transform } = useSortable({
id: value as string,
});
const classes = [
baseClass,
className,
!isDisabled && 'draggable',
].filter(Boolean).join(' ');
return (
<SelectComponents.MultiValue
{...props}
className={classes}
innerProps={{
...innerProps,
ref: setNodeRef,
onMouseDown: (e) => {
if (!disableMouseDown) {
// we need to prevent the dropdown from opening when clicking on the drag handle, but not when a modal is open (i.e. the 'Relationship' field component)
e.preventDefault();
e.stopPropagation();
}
},
style: {
...transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : {},
},
}}
selectProps={{
...selectProps,
// pass the draggable props through to the label so it alone acts as the draggable handle
draggableProps: {
...attributes,
...listeners,
},
}}
/>
);
};

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.multi-value-label {
@extend %small;
display: flex;
align-items: center;
padding: 0 base(.125) 0 base(.25);
max-width: 150px;
color: currentColor;
&__text {
text-overflow: ellipsis;
overflow: hidden;
}
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { components as SelectComponents, MultiValueProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
import './index.scss';
const baseClass = 'multi-value-label';
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const {
selectProps: {
draggableProps,
},
} = props;
return (
<div className={baseClass}>
<SelectComponents.MultiValueLabel
{...props}
innerProps={{
className: `${baseClass}__text`,
...draggableProps || {},
}}
/>
</div>
);
};

View File

@@ -0,0 +1,24 @@
@import '../../../../scss/styles.scss';
.multi-value-remove {
cursor: pointer;
width: base(.75);
height: base(.75);
display: flex;
align-items: center;
justify-content: center;
position: relative;
background-color: transparent;
border: none;
padding: 0;
&:hover {
color: var(--theme-elevation-800);
background: var(--theme-error-150);
}
&__icon {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
import X from '../../../icons/X';
import Tooltip from '../../Tooltip';
import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'multi-value-remove';
export const MultiValueRemove: React.FC<MultiValueRemoveProps<OptionType>> = (props) => {
const {
innerProps,
} = props;
const [showTooltip, setShowTooltip] = React.useState(false);
const { t } = useTranslation('general');
return (
<button
{...innerProps}
type="button"
className={baseClass}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={(e) => {
setShowTooltip(false);
innerProps.onClick(e);
}}
aria-label={t('remove')}
>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{t('remove')}
</Tooltip>
<X className={`${baseClass}__icon`} />
</button>
);
};

View File

@@ -0,0 +1,33 @@
@import '../../../../scss/styles.scss';
.value-container {
flex-grow: 1;
.rs__value-container {
padding: base(.25) 0;
min-height: base(1.5);
overflow: visible;
> * {
margin: 0;
padding-top: 0;
padding-bottom: 0;
}
&--is-multi {
margin-left: - base(0.25);
width: calc(100% + base(0.5));
padding-top: base(0.25);
padding-bottom: base(0.25);
padding-left: base(0.25);
.rs__multi-value {
margin: calc(#{base(.125)} - #{$style-stroke-width-s * 2});
}
&.rs__value-container--has-value {
padding-left: 0;
}
}
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { components as SelectComponents, ValueContainerProps } from 'react-select';
import { Option } from '../types';
import './index.scss';
const baseClass = 'value-container';
export const ValueContainer: React.FC<ValueContainerProps<Option, any>> = (props) => {
const {
selectProps,
} = props;
return (
<div
ref={selectProps.selectProps.droppableRef}
className={baseClass}
>
<SelectComponents.ValueContainer {...props} />
</div>
);
};

View File

@@ -1,41 +1,17 @@
@import '../../../scss/styles';
div.react-select {
div.rs__control {
.react-select {
.rs__control {
@include formInput;
height: auto;
padding-top: base(.25);
padding-bottom: base(.25);
}
.rs__value-container {
padding: base(.25) 0;
min-height: base(1.5);
>* {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
&--is-multi {
margin-left: - base(.25);
padding-top: 0;
padding-bottom: 0;
}
}
.rs__indicators {
.arrow {
margin-left: base(.5);
transform: rotate(90deg);
width: base(.3);
}
flex-wrap: nowrap;
}
.rs__indicator {
padding: 0px 4px;
cursor: pointer;
}
.rs__indicator-separator {
@@ -47,7 +23,7 @@ div.react-select {
input {
font-family: var(--font-body);
width: 100% !important;
width: 10px;
}
}
@@ -79,38 +55,6 @@ div.react-select {
}
}
.rs__single-value {
color: currentColor;
}
.rs__multi-value {
padding: 0;
background: transparent;
border: $style-stroke-width-s solid var(--theme-elevation-800);
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
&.draggable {
cursor: grab;
}
}
.rs__multi-value__label {
padding: 0 base(.125) 0 base(.25);
max-width: 150px;
color: currentColor;
}
.rs__multi-value__remove {
padding: 0 base(.125);
cursor: pointer;
&:hover {
color: var(--theme-elevation-800);
background: var(--theme-error-150);
}
}
&--error {
div.rs__control {
background-color: var(--theme-error-200);
@@ -120,4 +64,4 @@ div.react-select {
&.rs--is-disabled .rs__control {
background: var(--theme-elevation-200);
}
}
}

View File

@@ -1,61 +1,39 @@
import React, { MouseEventHandler, useCallback } from 'react';
import Select, {
components,
MultiValueProps,
Props as SelectProps,
} from 'react-select';
import React, { useCallback, useId } from 'react';
import {
SortableContainer,
SortableContainerProps,
SortableElement,
SortStartHandler,
SortEndHandler,
SortableHandle,
} from 'react-sortable-hoc';
DragEndEvent,
useDroppable,
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { arrayMove } from '../../../../utilities/arrayMove';
import { Props, Option } from './types';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { Props } from './types';
import Chevron from '../../icons/Chevron';
import { getTranslation } from '../../../../utilities/getTranslation';
import { MultiValueLabel } from './MultiValueLabel';
import { MultiValue } from './MultiValue';
import { SingleValue } from '../../forms/field-types/Relationship/select-components/SingleValue';
import { ValueContainer } from './ValueContainer';
import { ClearIndicator } from './ClearIndicator';
import { MultiValueRemove } from './MultiValueRemove';
import { Control } from './Control';
import './index.scss';
const SortableMultiValue = SortableElement(
(props: MultiValueProps<Option>) => {
// this prevents the menu from being opened/closed when the user clicks
// on a value to begin dragging it. ideally, detecting a click (instead of
// a drag) would still focus the control and toggle the menu, but that
// requires some magic with refs that are out of scope for this example
const onMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
e.stopPropagation();
};
const classes = [
props.className,
!props.isDisabled && 'draggable',
].filter(Boolean).join(' ');
return (
<components.MultiValue
{...props}
className={classes}
innerProps={{ ...props.innerProps, onMouseDown }}
/>
);
},
);
const SortableMultiValueLabel = SortableHandle((props) => <components.MultiValueLabel {...props} />);
const SortableSelect = SortableContainer(Select) as React.ComponentClass<SelectProps<Option, true> & SortableContainerProps>;
const ReactSelect: React.FC<Props> = (props) => {
const SelectAdapter: React.FC<Props> = (props) => {
const { t, i18n } = useTranslation();
const {
className,
showError = false,
showError,
options,
onChange,
value,
@@ -63,11 +41,12 @@ const ReactSelect: React.FC<Props> = (props) => {
placeholder = t('general:selectValue'),
isSearchable = true,
isClearable = true,
isMulti,
isSortable,
filterOption = undefined,
isLoading,
onMenuOpen,
components,
droppableRef,
selectProps,
} = props;
const classes = [
@@ -76,53 +55,6 @@ const ReactSelect: React.FC<Props> = (props) => {
showError && 'react-select--error',
].filter(Boolean).join(' ');
const onSortStart: SortStartHandler = useCallback(({ helper }) => {
const portalNode = helper;
if (portalNode && portalNode.style) {
portalNode.style.cssText += 'pointer-events: auto; cursor: grabbing;';
}
}, []);
const onSortEnd: SortEndHandler = useCallback(({ oldIndex, newIndex }) => {
onChange(arrayMove(value as Option[], oldIndex, newIndex));
}, [onChange, value]);
if (isMulti && isSortable) {
return (
<SortableSelect
useDragHandle
// react-sortable-hoc props:
axis="xy"
onSortStart={onSortStart}
onSortEnd={onSortEnd}
// small fix for https://github.com/clauderic/react-sortable-hoc/pull/352:
getHelperDimensions={({ node }) => node.getBoundingClientRect()}
// react-select props:
placeholder={getTranslation(placeholder, i18n)}
{...props}
value={value as Option[]}
onChange={onChange}
disabled={disabled ? 'disabled' : undefined}
className={classes}
classNamePrefix="rs"
captureMenuScroll
options={options}
isSearchable={isSearchable}
isClearable={isClearable}
isLoading={isLoading}
onMenuOpen={onMenuOpen}
components={{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We're failing to provide a required index prop to SortableElement
MultiValue: SortableMultiValue,
MultiValueLabel: SortableMultiValueLabel,
DropdownIndicator: Chevron,
}}
filterOption={filterOption}
/>
);
}
return (
<Select
isLoading={isLoading}
@@ -132,7 +64,6 @@ const ReactSelect: React.FC<Props> = (props) => {
value={value}
onChange={onChange}
disabled={disabled ? 'disabled' : undefined}
components={{ DropdownIndicator: Chevron }}
className={classes}
classNamePrefix="rs"
options={options}
@@ -140,8 +71,94 @@ const ReactSelect: React.FC<Props> = (props) => {
isClearable={isClearable}
filterOption={filterOption}
onMenuOpen={onMenuOpen}
selectProps={{
...selectProps,
droppableRef,
}}
components={{
ValueContainer,
SingleValue,
MultiValue,
MultiValueLabel,
MultiValueRemove,
DropdownIndicator: Chevron,
ClearIndicator,
Control,
...components,
}}
/>
);
};
const SortableSelect: React.FC<Props> = (props) => {
const {
onChange,
value,
} = props;
const uuid = useId();
const { setNodeRef } = useDroppable({
id: uuid,
});
const onDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over) return;
let sorted = value;
if (value && Array.isArray(value)) {
const oldIndex = value.findIndex((item) => item.value === active.id);
const newIndex = value.findIndex((item) => item.value === over.id);
sorted = arrayMove(value, oldIndex, newIndex);
}
onChange(sorted);
}, [onChange, value]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
let ids: string[] = [];
if (value) ids = Array.isArray(value) ? value.map((item) => item?.value as string) : [value?.value as string]; // TODO: fix these types
return (
<DndContext
onDragEnd={onDragEnd}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={ids}>
<SelectAdapter
{...props}
droppableRef={setNodeRef}
/>
</SortableContext>
</DndContext>
);
};
const ReactSelect: React.FC<Props> = (props) => {
const {
isMulti,
isSortable,
} = props;
if (isMulti && isSortable) {
return (
<SortableSelect {...props} />
);
}
return (
<SelectAdapter {...props} />
);
};
export default ReactSelect;

View File

@@ -1,3 +1,5 @@
import { Ref } from 'react';
export type Option = {
[key: string]: unknown
value: unknown
@@ -9,6 +11,7 @@ export type OptionGroup = {
}
export type Props = {
droppableRef?: Ref<HTMLElement>
className?: string
value?: Option | Option[],
onChange?: (value: any) => void, // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -26,7 +29,16 @@ export type Props = {
placeholder?: string
isSearchable?: boolean
isClearable?: boolean
blurInputOnSelect?: boolean
filterOption?:
| (({ label, value, data }: { label: string, value: string, data: Option }, search: string) => boolean)
| undefined,
components?: {
[key: string]: React.FC<any>
}
selectProps?: {
disableMouseDown?: boolean
disableKeyDown?: boolean
[key: string]: unknown
}
}

View File

@@ -1,27 +1,41 @@
@import '../../../scss/styles.scss';
$caretSize: 6;
.tooltip {
opacity: 0;
background-color: var(--theme-elevation-800);
position: absolute;
z-index: 2;
bottom: 100%;
left: 50%;
transform: translate3d(-50%, -20%, 0);
transform: translate3d(-50%, calc(#{$caretSize}px * -1), 0);
padding: base(.2) base(.4);
color: var(--theme-elevation-0);
line-height: base(.75);
font-weight: normal;
white-space: nowrap;
border-radius: 2px;
visibility: hidden;
span {
position: absolute;
transform: translateX(-50%);
top: calc(100% - #{base(.0625)});
left: 50%;
height: 0;
width: 0;
border: 10px solid transparent;
border-top-color: var(--theme-elevation-800);
}
&::after {
content: ' ';
display: block;
position: absolute;
bottom: 0;
left: 50%;
transform: translate3d(-50%, 100%, 0);
width: 0;
height: 0;
border-left: #{$caretSize}px solid transparent;
border-right: #{$caretSize}px solid transparent;
border-top: #{$caretSize}px solid var(--theme-elevation-800);
}
&--show {
visibility: visible;
opacity: 1;
transition: opacity .2s ease-in-out;
cursor: default;
}
}

View File

@@ -1,20 +1,44 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Props } from './types';
import './index.scss';
const Tooltip: React.FC<Props> = (props) => {
const { className, children } = props;
const classes = [
'tooltip',
const {
className,
].filter(Boolean).join(' ');
children,
show: showFromProps = true,
delay = 350,
} = props;
const [show, setShow] = React.useState(showFromProps);
useEffect(() => {
let timerId: NodeJS.Timeout;
// do not use the delay on transition-out
if (delay && showFromProps) {
timerId = setTimeout(() => {
setShow(showFromProps);
}, delay);
} else {
setShow(showFromProps);
}
return () => {
if (timerId) clearTimeout(timerId);
};
}, [showFromProps, delay]);
return (
<aside className={classes}>
<aside
className={[
'tooltip',
className,
show && 'tooltip--show',
].filter(Boolean).join(' ')}
>
{children}
<span />
</aside>
);
};

View File

@@ -1,4 +1,6 @@
export type Props = {
className?: string,
children: React.ReactNode,
className?: string
children: React.ReactNode
show?: boolean
delay?: number
}

View File

@@ -14,7 +14,10 @@ const Error: React.FC<Props> = (props) => {
if (showError) {
return (
<Tooltip className={baseClass}>
<Tooltip
className={baseClass}
delay={0}
>
{message}
</Tooltip>
);

View File

@@ -1,117 +0,0 @@
@import '../../../../../../scss/styles.scss';
.relationship-add-new-modal {
display: flex;
overflow: hidden;
position: fixed;
height: 100vh;
&__blur-bg {
@include blur-bg();
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
transition: all 300ms ease-out;
}
.collection-edit {
@include blur-bg();
transform: translateX(#{base(4)});
opacity: 0;
transition: all 300ms ease-out;
position: relative;
z-index: 2;
}
.collection-edit__form {
overflow: auto;
position: relative;
z-index: 1;
}
.collection-edit__document-actions {
&:before,
&:after {
content: none;
}
}
&--animated {
.collection-edit,
.relationship-add-new-modal__blur-bg,
.relationship-add-new-modal__close {
opacity: 1;
}
.collection-edit {
transform: translateX(0);
}
}
.collection-edit__document-actions {
margin-top: base(2.75);
}
&__close {
@extend %btn-reset;
position: relative;
z-index: 2;
flex-shrink: 0;
text-indent: -9999px;
background: rgba(0, 0, 0, 0.08);
cursor: pointer;
opacity: 0;
transition: all 300ms ease-in-out;
transition-delay: 100ms;
&:active,
&:focus {
outline: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: base(2.5);
}
&__header-close {
svg {
width: base(2.5);
height: base(2.5);
position: relative;
top: base(-.5);
right: base(-.75);
.stroke {
stroke-width: .5px;
}
}
}
@include mid-break {
&__header-close {
svg {
top: base(-.75);
}
}
&__close {
width: base(1);
}
}
}
html[data-theme=dark] {
.relationship-add-new-modal__close {
background: rgba(0, 0, 0, 0.2);
}
}

View File

@@ -1,110 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useTranslation } from 'react-i18next';
import Button from '../../../../../elements/Button';
import { Props } from './types';
import { useAuth } from '../../../../../utilities/Auth';
import RenderCustomComponent from '../../../../../utilities/RenderCustomComponent';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import DefaultEdit from '../../../../../views/collections/Edit/Default';
import X from '../../../../../icons/X';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { EditDepthContext, useEditDepth } from '../../../../../utilities/EditDepth';
import { getTranslation } from '../../../../../../../utilities/getTranslation';
import { DocumentInfoProvider } from '../../../../../utilities/DocumentInfo';
import './index.scss';
const baseClass = 'relationship-add-new-modal';
export const AddNewRelationModal: React.FC<Props> = ({ modalCollection, onSave, modalSlug }) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal } = useModal();
const { breakpoints: { m: midBreak } } = useWindowInfo();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const [isAnimated, setIsAnimated] = useState(false);
const editDepth = useEditDepth();
const { t, i18n } = useTranslation('fields');
const modalAction = `${serverURL}${api}/${modalCollection.slug}?locale=${locale}&depth=0&fallback-locale=null`;
useEffect(() => {
const buildState = async () => {
const state = await buildStateFromSchema({ fieldSchema: modalCollection.fields, data: {}, user, operation: 'create', locale, t });
setInitialState(state);
};
buildState();
}, [modalCollection, locale, user, t]);
useEffect(() => {
setIsAnimated(true);
}, []);
return (
<Modal
slug={modalSlug}
className={[
baseClass,
isAnimated && `${baseClass}--animated`,
].filter(Boolean).join(' ')}
>
{editDepth === 1 && (
<div className={`${baseClass}__blur-bg`} />
)}
<DocumentInfoProvider collection={modalCollection}>
<EditDepthContext.Provider value={editDepth + 1}>
<button
className={`${baseClass}__close`}
type="button"
onClick={() => toggleModal(modalSlug)}
style={{
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${editDepth - 1} * 25px)`,
}}
>
<span>
Close
</span>
</button>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={modalCollection.admin?.components?.views?.Edit}
componentProps={{
isLoading: !initialState,
data: {},
collection: modalCollection,
permissions: permissions.collections[modalCollection.slug],
isEditing: false,
onSave,
initialState,
hasSavePermission: true,
action: modalAction,
disableEyebrow: true,
disableActions: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<h2>
{t('addNewLabel', { label: getTranslation(modalCollection.labels.singular, i18n) })}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(modalSlug)}
>
<X />
</Button>
</div>
),
}}
/>
</EditDepthContext.Provider>
</DocumentInfoProvider>
</Modal>
);
};

View File

@@ -1,7 +0,0 @@
import { SanitizedCollectionConfig } from '../../../../../../../collections/config/types';
export type Props = {
modalSlug: string
modalCollection: SanitizedCollectionConfig
onSave: (json: Record<string, unknown>) => void
}

View File

@@ -12,28 +12,16 @@
&__add-button {
@include formInput;
position: relative;
height: 100%;
margin-left: -1px;
display: flex;
padding: 0;
.btn__content,
.btn__label {
display: flex;
}
.btn__content,
.btn__label {
height: 100%;
}
.btn__label {
padding: 0 base(.5);
align-items: center;
}
padding: 0 base(0.5);
align-items: center;
display: flex;
cursor: pointer;
}
&__relations {
list-style: none;
margin: 0;
@@ -57,4 +45,4 @@
opacity: .7;
}
}
}
}

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '../../../../elements/Button';
import { Props } from './types';
@@ -7,10 +6,10 @@ import { SanitizedCollectionConfig } from '../../../../../../collections/config/
import Popup from '../../../../elements/Popup';
import { useRelatedCollections } from './useRelatedCollections';
import { useAuth } from '../../../../utilities/Auth';
import { AddNewRelationModal } from './Modal';
import { useEditDepth } from '../../../../utilities/EditDepth';
import Plus from '../../../../icons/Plus';
import { getTranslation } from '../../../../../../utilities/getTranslation';
import Tooltip from '../../../../elements/Tooltip';
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
import './index.scss';
@@ -18,30 +17,31 @@ const baseClass = 'relationship-add-new';
export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, value, setValue, dispatchOptions }) => {
const relatedCollections = useRelatedCollections(relationTo);
const { toggleModal, isModalOpen } = useModal();
const { permissions } = useAuth();
const [hasPermission, setHasPermission] = useState(false);
const [modalCollection, setModalCollection] = useState<SanitizedCollectionConfig>();
const [show, setShow] = useState(false);
const [selectedCollection, setSelectedCollection] = useState<string>();
const relatedToMany = relatedCollections.length > 1;
const [collectionConfig, setCollectionConfig] = useState<SanitizedCollectionConfig>(() => (!relatedToMany ? relatedCollections[0] : undefined));
const [popupOpen, setPopupOpen] = useState(false);
const editDepth = useEditDepth();
const { t, i18n } = useTranslation('fields');
const modalSlug = `${path}-add-modal-depth-${editDepth}`;
const openModal = useCallback(async (collection: SanitizedCollectionConfig) => {
setModalCollection(collection);
toggleModal(modalSlug);
}, [toggleModal, modalSlug]);
const [showTooltip, setShowTooltip] = useState(false);
const [
DocumentDrawer,
DocumentDrawerToggler,
{ toggleDrawer, isDrawerOpen },
] = useDocumentDrawer({
collectionSlug: collectionConfig?.slug,
});
const onSave = useCallback((json) => {
const newValue = Array.isArray(relationTo) ? {
relationTo: modalCollection.slug,
relationTo: collectionConfig.slug,
value: json.doc.id,
} : json.doc.id;
dispatchOptions({
type: 'ADD',
collection: modalCollection,
collection: collectionConfig,
docs: [
json.doc,
],
@@ -55,9 +55,8 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
setValue(newValue);
}
setModalCollection(undefined);
toggleModal(modalSlug);
}, [relationTo, modalCollection, dispatchOptions, i18n, hasMany, toggleModal, modalSlug, setValue, value]);
setSelectedCollection(undefined);
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value]);
const onPopopToggle = useCallback((state) => {
setPopupOpen(state);
@@ -66,76 +65,108 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
useEffect(() => {
if (permissions) {
if (relatedCollections.length === 1) {
setHasPermission(permissions.collections[relatedCollections[0].slug].create.permission);
setShow(permissions.collections[relatedCollections[0].slug].create.permission);
} else {
setHasPermission(relatedCollections.some((collection) => permissions.collections[collection.slug].create.permission));
setShow(relatedCollections.some((collection) => permissions.collections[collection.slug].create.permission));
}
}
}, [permissions, relatedCollections]);
useEffect(() => {
if (!isModalOpen(modalSlug)) {
setModalCollection(undefined);
if (relatedToMany && selectedCollection) {
setCollectionConfig(relatedCollections.find((collection) => collection.slug === selectedCollection));
}
}, [isModalOpen, modalSlug]);
}, [selectedCollection, relatedToMany, relatedCollections]);
return hasPermission ? (
<div
className={baseClass}
id={`${path}-add-new`}
>
{relatedCollections.length === 1 && (
<Button
className={`${baseClass}__add-button`}
onClick={() => openModal(relatedCollections[0])}
buttonStyle="none"
tooltip={t('addNewLabel', { label: relatedCollections[0].labels.singular })}
>
<Plus />
</Button>
)}
{relatedCollections.length > 1 && (
<Popup
buttonType="custom"
horizontalAlign="center"
onToggleOpen={onPopopToggle}
button={(
<Button
useEffect(() => {
if (relatedToMany && collectionConfig) {
// the drawer must be rendered on the page before before opening it
// this is why 'selectedCollection' is different from 'collectionConfig'
toggleDrawer();
setSelectedCollection(undefined);
}
}, [toggleDrawer, relatedToMany, collectionConfig]);
useEffect(() => {
if (relatedToMany && !isDrawerOpen) {
setCollectionConfig(undefined);
}
}, [isDrawerOpen, relatedToMany]);
if (show) {
return (
<div
className={baseClass}
id={`${path}-add-new`}
>
{relatedCollections.length === 1 && (
<Fragment>
<DocumentDrawerToggler
className={`${baseClass}__add-button`}
buttonStyle="none"
tooltip={popupOpen ? undefined : t('addNew')}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(false)}
>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{t('addNewLabel', { label: relatedCollections[0].labels.singular })}
</Tooltip>
<Plus />
</Button>
)}
render={({ close: closePopup }) => (
<ul className={`${baseClass}__relations`}>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection.slug].create.permission) {
return (
<li key={relatedCollection.slug}>
<button
className={`${baseClass}__relation-button ${baseClass}__relation-button--${relatedCollection.slug}`}
type="button"
onClick={() => { closePopup(); openModal(relatedCollection); }}
>
{getTranslation(relatedCollection.labels.singular, i18n)}
</button>
</li>
);
}
</DocumentDrawerToggler>
<DocumentDrawer onSave={onSave} />
</Fragment>
)}
{relatedCollections.length > 1 && (
<Fragment>
<Popup
buttonType="custom"
horizontalAlign="center"
onToggleOpen={onPopopToggle}
button={(
<Button
className={`${baseClass}__add-button`}
buttonStyle="none"
tooltip={popupOpen ? undefined : t('addNew')}
>
<Plus />
</Button>
)}
render={({ close: closePopup }) => (
<ul className={`${baseClass}__relations`}>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection.slug].create.permission) {
return (
<li key={relatedCollection.slug}>
<button
type="button"
className={`${baseClass}__relation-button ${baseClass}__relation-button--${relatedCollection.slug}`}
onClick={() => {
closePopup();
setSelectedCollection(relatedCollection.slug);
}}
>
{getTranslation(relatedCollection.labels.singular, i18n)}
</button>
</li>
);
}
return null;
})}
</ul>
)}
/>
)}
{modalCollection && (
<AddNewRelationModal
{...{ onSave, modalSlug, modalCollection }}
/>
)}
</div>
) : null;
return null;
})}
</ul>
)}
/>
{collectionConfig && permissions.collections[collectionConfig.slug].create.permission && (
<DocumentDrawer
onSave={onSave}
/>
)}
</Fragment>
)}
</div>
);
}
return null;
};

View File

@@ -5,8 +5,11 @@ import { useConfig } from '../../../../utilities/Config';
export const useRelatedCollections = (relationTo: string | string[]): SanitizedCollectionConfig[] => {
const config = useConfig();
const [relatedCollections] = useState(() => {
const relations = typeof relationTo === 'string' ? [relationTo] : relationTo;
return relations.map((relation) => config.collections.find((collection) => collection.slug === relation));
if (relationTo) {
const relations = typeof relationTo === 'string' ? [relationTo] : relationTo;
return relations.map((relation) => config.collections.find((collection) => collection.slug === relation));
}
return [];
});
return relatedCollections;

View File

@@ -23,6 +23,8 @@ import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex';
import { AddNewRelation } from './AddNew';
import { findOptionsByValue } from './findOptionsByValue';
import { GetFilterOptions } from './GetFilterOptions';
import { SingleValue } from './select-components/SingleValue';
import { MultiValueLabel } from './select-components/MultiValueLabel';
import './index.scss';
@@ -73,7 +75,6 @@ const Relationship: React.FC<Props> = (props) => {
const [hasLoadedFirstPage, setHasLoadedFirstPage] = useState(false);
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false);
const firstRun = useRef(true);
const pathOrName = path || name;
const memoizedValidate = useCallback((value, validationOptions) => {
@@ -92,6 +93,8 @@ const Relationship: React.FC<Props> = (props) => {
condition,
});
const [drawerIsOpen, setDrawerIsOpen] = useState(false);
const getResults: GetResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
@@ -370,6 +373,15 @@ const Relationship: React.FC<Props> = (props) => {
isMulti={hasMany}
isSortable={isSortable}
isLoading={isLoading}
components={{
SingleValue,
MultiValueLabel,
}}
selectProps={{
disableMouseDown: drawerIsOpen,
disableKeyDown: drawerIsOpen,
setDrawerIsOpen,
}}
onMenuOpen={() => {
if (!hasLoadedFirstPage) {
setIsLoading(true);

View File

@@ -0,0 +1,40 @@
@import '../../../../../../scss/styles.scss';
.relationship--multi-value-label {
display: flex;
&__content {
@extend %small;
padding: 0 base(.125) 0 base(.25);
max-width: 150px;
color: currentColor;
display: flex;
align-items: center;
}
&__text {
text-overflow: ellipsis;
overflow: hidden;
}
&__drawer-toggler {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
margin-left: base(0.25);
.icon {
width: base(0.75);
height: base(0.75);
}
&:hover {
background-color: var(--theme-elevation-100);
}
}
}

View File

@@ -0,0 +1,73 @@
import React, { Fragment, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { components, MultiValueProps } from 'react-select';
import { useDocumentDrawer } from '../../../../../elements/DocumentDrawer';
import Tooltip from '../../../../../elements/Tooltip';
import Edit from '../../../../../icons/Edit';
import { useAuth } from '../../../../../utilities/Auth';
import { Option } from '../../types';
import './index.scss';
const baseClass = 'relationship--multi-value-label';
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const {
data: {
value,
relationTo,
label,
},
selectProps: {
setDrawerIsOpen,
draggableProps,
},
} = props;
const { permissions } = useAuth();
const [showTooltip, setShowTooltip] = useState(false);
const { t } = useTranslation('general');
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission);
const [DocumentDrawer, DocumentDrawerToggler, { isDrawerOpen }] = useDocumentDrawer({
id: value?.toString(),
collectionSlug: relationTo,
});
useEffect(() => {
if (typeof setDrawerIsOpen === 'function') setDrawerIsOpen(isDrawerOpen);
}, [isDrawerOpen, setDrawerIsOpen]);
return (
<div className={baseClass}>
<div className={`${baseClass}__content`}>
<components.MultiValueLabel
{...props}
innerProps={{
className: `${baseClass}__text`,
...draggableProps || {},
}}
/>
</div>
{relationTo && hasReadPermission && (
<Fragment>
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={`Edit ${label}`}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(false)}
>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{t('editLabel', { label: '' })}
</Tooltip>
<Edit />
</DocumentDrawerToggler>
<DocumentDrawer />
</Fragment>
)}
</div>
);
};

View File

@@ -0,0 +1,43 @@
@import '../../../../../../scss/styles.scss';
.relationship--single-value {
display: flex;
align-items: center;
.rs__single-value {
color: currentColor;
max-width: unset;
display: flex;
align-items: center;
overflow: visible;
}
&__drawer-toggler {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: base(0.25);
.icon {
width: base(0.75);
height: base(0.75);
}
&:focus {
outline: none;
}
&:hover {
background-color: var(--theme-elevation-100);
}
}
&__label {
flex-grow: 1;
}
}

View File

@@ -0,0 +1,76 @@
import React, { Fragment, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { components as SelectComponents, SingleValueProps } from 'react-select';
import { useDocumentDrawer } from '../../../../../elements/DocumentDrawer';
import Tooltip from '../../../../../elements/Tooltip';
import Edit from '../../../../../icons/Edit';
import { useAuth } from '../../../../../utilities/Auth';
import { Option } from '../../types';
import './index.scss';
const baseClass = 'relationship--single-value';
export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
const {
data: {
value,
relationTo,
label,
},
children,
selectProps: {
selectProps: {
setDrawerIsOpen,
},
},
} = props;
const [showTooltip, setShowTooltip] = useState(false);
const { t } = useTranslation('general');
const { permissions } = useAuth();
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission);
const [DocumentDrawer, DocumentDrawerToggler, { isDrawerOpen }] = useDocumentDrawer({
id: value.toString(),
collectionSlug: relationTo,
});
useEffect(() => {
if (typeof setDrawerIsOpen === 'function') setDrawerIsOpen(isDrawerOpen);
}, [isDrawerOpen, setDrawerIsOpen]);
return (
<div className={baseClass}>
<div className={`${baseClass}__label`}>
<SelectComponents.SingleValue {...props}>
<div className={`${baseClass}__text`}>
{children}
</div>
{relationTo && hasReadPermission && (
<Fragment>
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={t('editLabel', { label })}
onMouseDown={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(false)}
>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{t('edit')}
</Tooltip>
<Edit />
</DocumentDrawerToggler>
</Fragment>
)}
</SelectComponents.SingleValue>
</div>
{relationTo && hasReadPermission && (
<DocumentDrawer />
)}
</div>
);
};

View File

@@ -70,16 +70,19 @@
}
&__document-actions {
@include blur-bg;
padding-right: $baseline;
position: sticky;
top: 0;
z-index: var(--z-nav);
>* {
> * {
position: relative;
z-index: 1;
}
@include mid-break {
@include blur-bg;
}
}
&__document-actions--has-2 {
@@ -233,4 +236,4 @@
padding-bottom: base(3.5);
}
}
}
}

View File

@@ -79,7 +79,7 @@
%small {
margin: 0;
font-size: base(.4);
font-size: 11px;
line-height: 1.5;
}

View File

@@ -1,9 +0,0 @@
export function arrayMove<T>(array: readonly T[], from: number, to: number) {
const slicedArray = array.slice();
slicedArray.splice(
to < 0 ? array.length + to : to,
0,
slicedArray.splice(from, 1)[0],
);
return slicedArray;
}