chore: merge master
This commit is contained in:
@@ -34,6 +34,20 @@ export const requests = {
|
||||
return fetch(url, formattedOptions);
|
||||
},
|
||||
|
||||
patch: (url: string, options: RequestInit = { headers: {} }): Promise<Response> => {
|
||||
const headers = options && options.headers ? { ...options.headers } : {};
|
||||
|
||||
const formattedOptions = {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(url, formattedOptions);
|
||||
},
|
||||
|
||||
delete: (url: string, options: RequestInit = { headers: {} }): Promise<Response> => {
|
||||
const headers = options && options.headers ? { ...options.headers } : {};
|
||||
return fetch(url, {
|
||||
|
||||
@@ -208,6 +208,7 @@ const Routes = () => {
|
||||
if (permissions?.collections?.[collection.slug]?.read?.permission) {
|
||||
return (
|
||||
<DocumentInfoProvider
|
||||
key={`${collection.slug}-edit-${id}`}
|
||||
collection={collection}
|
||||
id={id}
|
||||
>
|
||||
|
||||
@@ -77,7 +77,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
|
||||
|
||||
if (collection && id) {
|
||||
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${locale}`;
|
||||
method = 'PUT';
|
||||
method = 'PATCH';
|
||||
}
|
||||
|
||||
if (global) {
|
||||
|
||||
@@ -31,6 +31,7 @@ const DateTime: React.FC<Props> = (props) => {
|
||||
if (dateTimeFormat === undefined) {
|
||||
if (pickerAppearance === 'dayAndTime') dateTimeFormat = 'MMM d, yyy h:mm a';
|
||||
else if (pickerAppearance === 'timeOnly') dateTimeFormat = 'h:mm a';
|
||||
else if (pickerAppearance === 'monthOnly') dateTimeFormat = 'MM/yyyy';
|
||||
else dateTimeFormat = 'MMM d, yyy';
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ const DateTime: React.FC<Props> = (props) => {
|
||||
showPopperArrow: false,
|
||||
selected: value && new Date(value),
|
||||
customInputRef: 'ref',
|
||||
showMonthYearPicker: pickerAppearance === 'monthOnly',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type SharedProps = {
|
||||
displayFormat?: string | undefined
|
||||
pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly'
|
||||
displayFormat?: string
|
||||
pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly' | 'monthOnly'
|
||||
}
|
||||
|
||||
type TimePickerProps = {
|
||||
@@ -16,6 +16,11 @@ type DayPickerProps = {
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
type MonthPickerProps = {
|
||||
minDate?: Date
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
export type ConditionalDateProps =
|
||||
| SharedProps & DayPickerProps & TimePickerProps & {
|
||||
pickerAppearance?: 'dayAndTime'
|
||||
@@ -26,6 +31,9 @@ export type ConditionalDateProps =
|
||||
| SharedProps & DayPickerProps & {
|
||||
pickerAppearance: 'dayOnly'
|
||||
}
|
||||
| SharedProps & MonthPickerProps & {
|
||||
pickerAppearance: 'monthOnly'
|
||||
}
|
||||
|
||||
export type Props = SharedProps & DayPickerProps & TimePickerProps & {
|
||||
value?: Date
|
||||
|
||||
@@ -33,7 +33,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
const { serverURL, routes: { api, admin } } = useConfig();
|
||||
const { setModified } = useForm();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { closeAll, toggle } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const history = useHistory();
|
||||
const title = useTitle(useAsTitle) || id;
|
||||
const titleToRender = titleFromProps || title;
|
||||
@@ -55,12 +55,12 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (res.status < 400) {
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
toast.success(`${singular} "${title}" successfully deleted.`);
|
||||
return history.push(`${admin}/collections/${slug}`);
|
||||
}
|
||||
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
|
||||
if (json.errors) {
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
@@ -72,7 +72,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
return addDefaultError();
|
||||
}
|
||||
});
|
||||
}, [addDefaultError, closeAll, history, id, singular, slug, title, admin, api, serverURL, setModified]);
|
||||
}, [addDefaultError, toggleModal, modalSlug, history, id, singular, slug, title, admin, api, serverURL, setModified]);
|
||||
|
||||
if (id) {
|
||||
return (
|
||||
@@ -84,7 +84,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleting(false);
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
@@ -110,7 +110,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
id="confirm-cancel"
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={deleting ? undefined : () => toggle(modalSlug)}
|
||||
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.duplicate {
|
||||
|
||||
&__modal {
|
||||
@include blur-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.btn {
|
||||
margin-right: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&__modal-template {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,39 +1,142 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Modal, useModal } from '@faceless-ui/modal';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
import { Props } from './types';
|
||||
import Button from '../Button';
|
||||
import { useForm } from '../../forms/Form/context';
|
||||
import { requests } from '../../../api';
|
||||
import { useForm, useFormModified } from '../../forms/Form/context';
|
||||
import MinimalTemplate from '../../templates/Minimal';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'duplicate';
|
||||
|
||||
const Duplicate: React.FC<Props> = ({ slug }) => {
|
||||
const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
|
||||
const { push } = useHistory();
|
||||
const { getData } = useForm();
|
||||
const modified = useFormModified();
|
||||
const { toggleModal } = useModal();
|
||||
const { setModified } = useForm();
|
||||
const { serverURL, routes: { api }, localization } = useConfig();
|
||||
const { routes: { admin } } = useConfig();
|
||||
const [hasClicked, setHasClicked] = useState<boolean>(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const data = getData();
|
||||
const modalSlug = `duplicate-${id}`;
|
||||
|
||||
push({
|
||||
pathname: `${admin}/collections/${slug}/create`,
|
||||
state: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, [push, getData, slug, admin]);
|
||||
const handleClick = useCallback(async (override = false) => {
|
||||
setHasClicked(true);
|
||||
|
||||
if (modified && !override) {
|
||||
toggleModal(modalSlug);
|
||||
return;
|
||||
}
|
||||
|
||||
const create = async (locale?: string): Promise<string | null> => {
|
||||
const localeParam = locale ? `locale=${locale}` : '';
|
||||
const response = await requests.get(`${serverURL}${api}/${slug}/${id}?${localeParam}`);
|
||||
const data = await response.json();
|
||||
const result = await requests.post(`${serverURL}${api}/${slug}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await result.json();
|
||||
|
||||
if (result.status === 201) {
|
||||
return json.doc.id;
|
||||
}
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
return null;
|
||||
};
|
||||
|
||||
let duplicateID;
|
||||
if (localization) {
|
||||
duplicateID = await create(localization.defaultLocale);
|
||||
let abort = false;
|
||||
localization.locales
|
||||
.filter((locale) => locale !== localization.defaultLocale)
|
||||
.forEach(async (locale) => {
|
||||
if (!abort) {
|
||||
const res = await requests.get(`${serverURL}${api}/${slug}/${id}?locale=${locale}`);
|
||||
const localizedDoc = await res.json();
|
||||
const patchResult = await requests.patch(`${serverURL}${api}/${slug}/${duplicateID}?locale=${locale}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(localizedDoc),
|
||||
});
|
||||
if (patchResult.status > 400) {
|
||||
abort = true;
|
||||
const json = await patchResult.json();
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (abort) {
|
||||
// delete the duplicate doc to prevent incomplete
|
||||
await requests.delete(`${serverURL}${api}/${slug}/${id}`);
|
||||
}
|
||||
} else {
|
||||
duplicateID = await create();
|
||||
}
|
||||
|
||||
toast.success(`${collection.labels.singular} successfully duplicated.`,
|
||||
{ autoClose: 3000 });
|
||||
|
||||
setModified(false);
|
||||
|
||||
setTimeout(() => {
|
||||
push({
|
||||
pathname: `${admin}/collections/${slug}/${duplicateID}`,
|
||||
});
|
||||
}, 10);
|
||||
}, [modified, localization, collection.labels.singular, setModified, toggleModal, modalSlug, serverURL, api, slug, id, push, admin]);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
setHasClicked(false);
|
||||
await handleClick(true);
|
||||
}, [handleClick]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
id="action-duplicate"
|
||||
buttonStyle="none"
|
||||
className={baseClass}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<React.Fragment>
|
||||
<Button
|
||||
id="action-duplicate"
|
||||
buttonStyle="none"
|
||||
className={baseClass}
|
||||
onClick={() => handleClick(false)}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
{modified && hasClicked && (
|
||||
<Modal
|
||||
slug={modalSlug}
|
||||
className={`${baseClass}__modal`}
|
||||
>
|
||||
<MinimalTemplate className={`${baseClass}__modal-template`}>
|
||||
<h1>Confirm duplicate</h1>
|
||||
<p>
|
||||
You have unsaved changes. Would you like to continue to duplicate?
|
||||
</p>
|
||||
<Button
|
||||
id="confirm-cancel"
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={() => toggleModal(modalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirm}
|
||||
id="confirm-duplicate"
|
||||
>
|
||||
Duplicate without saving changes
|
||||
</Button>
|
||||
</MinimalTemplate>
|
||||
</Modal>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
|
||||
export type Props = {
|
||||
slug: string,
|
||||
slug: string
|
||||
collection: SanitizedCollectionConfig
|
||||
id: string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Modal, useModal } from '@faceless-ui/modal';
|
||||
import Button from '../Button';
|
||||
import MinimalTemplate from '../../templates/Minimal';
|
||||
import { Props } from './types';
|
||||
import { useDocumentInfo } from '../../utilities/DocumentInfo';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -15,13 +16,14 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
highlightField,
|
||||
} = props;
|
||||
|
||||
const { toggle } = useModal();
|
||||
const { id } = useDocumentInfo();
|
||||
const { toggleModal } = useModal();
|
||||
|
||||
const modalSlug = 'generate-confirmation';
|
||||
const modalSlug = `generate-confirmation-${id}`;
|
||||
|
||||
const handleGenerate = () => {
|
||||
setKey();
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
toast.success('New API Key Generated.', { autoClose: 3000 });
|
||||
highlightField(true);
|
||||
};
|
||||
@@ -32,7 +34,7 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Generate new API key
|
||||
@@ -57,7 +59,7 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||
import { useScrollInfo } from '@faceless-ui/scroll-info';
|
||||
import { Props } from './types';
|
||||
|
||||
import useThrottledEffect from '../../../hooks/useThrottledEffect';
|
||||
import PopupButton from './PopupButton';
|
||||
|
||||
import './index.scss';
|
||||
import useIntersect from '../../../hooks/useIntersect';
|
||||
|
||||
const baseClass = 'popup';
|
||||
|
||||
@@ -30,26 +28,29 @@ const Popup: React.FC<Props> = (props) => {
|
||||
boundingRef,
|
||||
} = props;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowInfo();
|
||||
const [intersectionRef, intersectionEntry] = useIntersect({
|
||||
threshold: 1,
|
||||
rootMargin: '-100px 0px 0px 0px',
|
||||
root: boundingRef?.current || null,
|
||||
});
|
||||
|
||||
const buttonRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [active, setActive] = useState(initActive);
|
||||
const [verticalAlign, setVerticalAlign] = useState(verticalAlignFromProps);
|
||||
const [horizontalAlign, setHorizontalAlign] = useState(horizontalAlignFromProps);
|
||||
|
||||
const { y: scrollY } = useScrollInfo();
|
||||
const { height: windowHeight, width: windowWidth } = useWindowInfo();
|
||||
|
||||
const handleClickOutside = useCallback((e) => {
|
||||
if (contentRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActive(false);
|
||||
}, []);
|
||||
}, [contentRef]);
|
||||
|
||||
useThrottledEffect(() => {
|
||||
if (contentRef.current && buttonRef.current) {
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const {
|
||||
left: contentLeftPos,
|
||||
right: contentRightPos,
|
||||
@@ -79,13 +80,11 @@ const Popup: React.FC<Props> = (props) => {
|
||||
|
||||
if (contentTopPos < boundingTopPos && contentBottomPos < boundingBottomPos) {
|
||||
setVerticalAlign('bottom');
|
||||
} else if (contentBottomPos > boundingBottomPos && contentTopPos < boundingTopPos) {
|
||||
} else if (contentBottomPos > boundingBottomPos && contentTopPos > boundingTopPos) {
|
||||
setVerticalAlign('top');
|
||||
}
|
||||
|
||||
setMounted(true);
|
||||
}
|
||||
}, 500, [scrollY, windowHeight, windowWidth]);
|
||||
}, [boundingRef, intersectionEntry, windowHeight, windowWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onToggleOpen === 'function') onToggleOpen(active);
|
||||
@@ -112,7 +111,7 @@ const Popup: React.FC<Props> = (props) => {
|
||||
`${baseClass}--color-${color}`,
|
||||
`${baseClass}--v-align-${verticalAlign}`,
|
||||
`${baseClass}--h-align-${horizontalAlign}`,
|
||||
(active && mounted) && `${baseClass}--active`,
|
||||
(active) && `${baseClass}--active`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
@@ -144,7 +143,7 @@ const Popup: React.FC<Props> = (props) => {
|
||||
>
|
||||
<div
|
||||
className={`${baseClass}__wrap`}
|
||||
// TODO: color ::after with bg color
|
||||
ref={intersectionRef}
|
||||
>
|
||||
<div
|
||||
className={`${baseClass}__scroll`}
|
||||
|
||||
@@ -89,6 +89,10 @@ div.react-select {
|
||||
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 {
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
import React from 'react';
|
||||
import Select from 'react-select';
|
||||
import { Props } from './types';
|
||||
import React, { MouseEventHandler, useCallback } from 'react';
|
||||
import Select, {
|
||||
components,
|
||||
MultiValueProps,
|
||||
Props as SelectProps,
|
||||
} from 'react-select';
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableContainerProps,
|
||||
SortableElement,
|
||||
SortStartHandler,
|
||||
SortEndHandler,
|
||||
SortableHandle,
|
||||
} from 'react-sortable-hoc';
|
||||
import { arrayMove } from '../../../../utilities/arrayMove';
|
||||
import { Props, Value } from './types';
|
||||
import Chevron from '../../icons/Chevron';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const SortableMultiValue = SortableElement(
|
||||
(props: MultiValueProps<Value>) => {
|
||||
// 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<Value, true> & SortableContainerProps>;
|
||||
|
||||
const ReactSelect: React.FC<Props> = (props) => {
|
||||
const {
|
||||
className,
|
||||
@@ -16,6 +59,9 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
placeholder,
|
||||
isSearchable = true,
|
||||
isClearable,
|
||||
isMulti,
|
||||
isSortable,
|
||||
filterOption = undefined,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -24,6 +70,50 @@ 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 Value[], 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={placeholder}
|
||||
{...props}
|
||||
value={value as Value[]}
|
||||
onChange={onChange}
|
||||
disabled={disabled ? 'disabled' : undefined}
|
||||
className={classes}
|
||||
classNamePrefix="rs"
|
||||
options={options}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
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
|
||||
placeholder={placeholder}
|
||||
@@ -37,6 +127,7 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
options={options}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
filterOption={filterOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ import { OptionsType, GroupedOptionsType } from 'react-select';
|
||||
|
||||
export type Options = OptionsType<Value> | GroupedOptionsType<Value>;
|
||||
|
||||
export type OptionType = {
|
||||
[key: string]: any,
|
||||
};
|
||||
|
||||
|
||||
export type Value = {
|
||||
label: string
|
||||
value: string | null
|
||||
@@ -16,10 +21,14 @@ export type Props = {
|
||||
showError?: boolean,
|
||||
options: Options
|
||||
isMulti?: boolean,
|
||||
isSortable?: boolean,
|
||||
isDisabled?: boolean
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
isSearchable?: boolean
|
||||
isClearable?: boolean
|
||||
filterOption?:
|
||||
| (({ label, value, data }: { label: string, value: string, data: OptionType }, search: string) => boolean)
|
||||
| undefined,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const SaveDraft: React.FC = () => {
|
||||
|
||||
if (collection) {
|
||||
action = `${serverURL}${api}/${collection.slug}${id ? `/${id}` : ''}${search}`;
|
||||
if (id) method = 'PUT';
|
||||
if (id) method = 'PATCH';
|
||||
}
|
||||
|
||||
if (global) {
|
||||
|
||||
@@ -15,17 +15,17 @@ import './index.scss';
|
||||
|
||||
const baseClass = 'status';
|
||||
|
||||
const unPublishModalSlug = 'confirm-un-publish';
|
||||
const revertModalSlug = 'confirm-revert';
|
||||
|
||||
const Status: React.FC<Props> = () => {
|
||||
const { publishedDoc, unpublishedVersions, collection, global, id, getVersions } = useDocumentInfo();
|
||||
const { toggle, closeAll: closeAllModals } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const { serverURL, routes: { api } } = useConfig();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { reset: resetForm } = useForm();
|
||||
const locale = useLocale();
|
||||
|
||||
const unPublishModalSlug = `confirm-un-publish-${id}`;
|
||||
const revertModalSlug = `confirm-revert-${id}`;
|
||||
|
||||
let statusToRender;
|
||||
|
||||
if (unpublishedVersions?.docs?.length > 0 && publishedDoc) {
|
||||
@@ -55,7 +55,7 @@ const Status: React.FC<Props> = () => {
|
||||
|
||||
if (collection) {
|
||||
url = `${serverURL}${api}/${collection.slug}/${id}?depth=0&locale=${locale}&fallback-locale=null`;
|
||||
method = 'put';
|
||||
method = 'patch';
|
||||
}
|
||||
if (global) {
|
||||
url = `${serverURL}${api}/globals/${global.slug}?depth=0&locale=${locale}&fallback-locale=null`;
|
||||
@@ -92,8 +92,14 @@ const Status: React.FC<Props> = () => {
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
closeAllModals();
|
||||
}, [closeAllModals, collection, global, serverURL, api, resetForm, id, locale, getVersions, publishedDoc]);
|
||||
if (action === 'revert') {
|
||||
toggleModal(revertModalSlug);
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
toggleModal(unPublishModalSlug);
|
||||
}
|
||||
}, [collection, global, publishedDoc, serverURL, api, id, locale, resetForm, getVersions, toggleModal, revertModalSlug, unPublishModalSlug]);
|
||||
|
||||
if (statusToRender) {
|
||||
return (
|
||||
@@ -104,7 +110,7 @@ const Status: React.FC<Props> = () => {
|
||||
<React.Fragment>
|
||||
—
|
||||
<Button
|
||||
onClick={() => toggle(unPublishModalSlug)}
|
||||
onClick={() => toggleModal(unPublishModalSlug)}
|
||||
className={`${baseClass}__action`}
|
||||
buttonStyle="none"
|
||||
>
|
||||
@@ -120,7 +126,7 @@ const Status: React.FC<Props> = () => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={processing ? undefined : () => toggle(unPublishModalSlug)}
|
||||
onClick={processing ? undefined : () => toggleModal(unPublishModalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -137,7 +143,7 @@ const Status: React.FC<Props> = () => {
|
||||
<React.Fragment>
|
||||
—
|
||||
<Button
|
||||
onClick={() => toggle(revertModalSlug)}
|
||||
onClick={() => toggleModal(revertModalSlug)}
|
||||
className={`${baseClass}__action`}
|
||||
buttonStyle="none"
|
||||
>
|
||||
@@ -153,7 +159,7 @@ const Status: React.FC<Props> = () => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={processing ? undefined : () => toggle(revertModalSlug)}
|
||||
onClick={processing ? undefined : () => toggleModal(revertModalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
export type DescriptionFunction = () => string
|
||||
|
||||
export type DescriptionComponent = React.ComponentType
|
||||
export type DescriptionComponent = React.ComponentType<any>
|
||||
|
||||
type Description = string | DescriptionFunction | DescriptionComponent
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const baseClass = 'condition-value-relationship';
|
||||
const maxResultsPerRequest = 10;
|
||||
|
||||
const RelationshipField: React.FC<Props> = (props) => {
|
||||
const { onChange, value, relationTo, hasMany } = props;
|
||||
const { onChange, value, relationTo, hasMany, admin: { isSortable } = {} } = props;
|
||||
|
||||
const {
|
||||
serverURL,
|
||||
@@ -253,6 +253,7 @@ const RelationshipField: React.FC<Props> = (props) => {
|
||||
value={valueToRender}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
/>
|
||||
)}
|
||||
{errorLoading && (
|
||||
|
||||
@@ -71,7 +71,10 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
|
||||
if (handleChange) handleChange(newWhereQuery as Where);
|
||||
|
||||
if (modifySearchQuery) {
|
||||
const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where;
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) {
|
||||
history.replace({
|
||||
search: queryString.stringify({
|
||||
...currentParams,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import '../../../scss/styles';
|
||||
|
||||
.field-error.tooltip {
|
||||
font-family: var(--font-body);
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
@@ -11,4 +12,4 @@
|
||||
span {
|
||||
border-top-color: var(--theme-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import equal from 'deep-equal';
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { unflatten, flatten } from 'flatley';
|
||||
import flattenFilters from './flattenFilters';
|
||||
import getSiblingData from './getSiblingData';
|
||||
@@ -65,7 +64,7 @@ function fieldReducer(state: Fields, action): Fields {
|
||||
|
||||
case 'REMOVE': {
|
||||
const newState = { ...state };
|
||||
delete newState[action.path];
|
||||
if (newState[action.path]) delete newState[action.path];
|
||||
return newState;
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,10 @@ const Form: React.FC<Props> = (props) => {
|
||||
refreshCookie();
|
||||
}, 15000, [fields]);
|
||||
|
||||
// Re-run form validation every second
|
||||
// as fields change, because field validations can
|
||||
// potentially rely on OTHER field values to determine
|
||||
// if they are valid or not (siblingData, data)
|
||||
useThrottledEffect(() => {
|
||||
validateForm();
|
||||
}, 1000, [validateForm, fields]);
|
||||
|
||||
@@ -27,7 +27,7 @@ export type Preferences = {
|
||||
export type Props = {
|
||||
disabled?: boolean
|
||||
onSubmit?: (fields: Fields, data: Data) => void
|
||||
method?: 'get' | 'put' | 'delete' | 'post'
|
||||
method?: 'get' | 'patch' | 'delete' | 'post'
|
||||
action?: string
|
||||
handleResponse?: (res: Response) => void
|
||||
onSuccess?: (json: unknown) => void
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
label.field-label {
|
||||
@extend %body;
|
||||
display: flex;
|
||||
padding-bottom: base(.25);
|
||||
color: var(--theme-elevation-800);
|
||||
font-family: var(--font-body);
|
||||
|
||||
.required {
|
||||
color: var(--theme-error-500);
|
||||
margin-left: base(.25);
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const RenderFields: React.FC<Props> = (props) => {
|
||||
forceRender,
|
||||
} = props;
|
||||
|
||||
const [hasRendered, setHasRendered] = useState(false);
|
||||
const [hasRendered, setHasRendered] = useState(Boolean(forceRender));
|
||||
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions);
|
||||
const operation = useOperation();
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import useField from '../../useField';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import Error from '../../Error';
|
||||
import { array } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Props } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'array-field';
|
||||
|
||||
const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
const {
|
||||
name,
|
||||
path: pathFromProps,
|
||||
fields,
|
||||
fieldTypes,
|
||||
validate = array,
|
||||
required,
|
||||
maxRows,
|
||||
minRows,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
// Handle labeling for Arrays, Global Arrays, and Blocks
|
||||
const getLabels = (p: Props) => {
|
||||
if (p?.labels) return p.labels;
|
||||
if (p?.label) return { singular: p.label, plural: undefined };
|
||||
return { singular: 'Row', plural: 'Rows' };
|
||||
};
|
||||
|
||||
const labels = getLabels(props);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const label = props?.label ?? props?.labels?.singular;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number) => {
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [dispatchRows, dispatchFields, path, value, setValue]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, path, setPreference, rows, getPreference]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [path, getPreference, preferencesKey, rows, setPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
<Droppable droppableId="array-drop">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const rowNumber = i + 1;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`}
|
||||
actions={!readOnly ? (
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={duplicateRow}
|
||||
addRow={addRow}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{minRows
|
||||
? `${minRows} ${labels.plural}`
|
||||
: `1 ${labels.singular}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Button
|
||||
onClick={() => addRow(value as number)}
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconStyle="with-border"
|
||||
iconPosition="left"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(ArrayFieldType);
|
||||
@@ -1,13 +1,350 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Loading from '../../../elements/Loading';
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import useField from '../../useField';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import Error from '../../Error';
|
||||
import { array } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Props } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
const ArrayField = lazy(() => import('./Array'));
|
||||
import './index.scss';
|
||||
|
||||
const ArrayFieldType: React.FC<Props> = (props) => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ArrayField {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
const baseClass = 'array-field';
|
||||
|
||||
export default ArrayFieldType;
|
||||
const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
const {
|
||||
name,
|
||||
path: pathFromProps,
|
||||
fields,
|
||||
fieldTypes,
|
||||
validate = array,
|
||||
required,
|
||||
maxRows,
|
||||
minRows,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
// Handle labeling for Arrays, Global Arrays, and Blocks
|
||||
const getLabels = (p: Props) => {
|
||||
if (p?.labels) return p.labels;
|
||||
if (p?.label) return { singular: p.label, plural: undefined };
|
||||
return { singular: 'Row', plural: 'Rows' };
|
||||
};
|
||||
|
||||
const labels = getLabels(props);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const label = props?.label ?? props?.labels?.singular;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number) => {
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [dispatchRows, dispatchFields, path, value, setValue]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, path, setPreference, rows, getPreference]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [path, getPreference, preferencesKey, rows, setPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
<Droppable droppableId="array-drop">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const rowNumber = i + 1;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`}
|
||||
actions={!readOnly ? (
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={duplicateRow}
|
||||
addRow={addRow}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{minRows
|
||||
? `${minRows} ${labels.plural}`
|
||||
: `1 ${labels.singular}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Button
|
||||
onClick={() => addRow(value as number)}
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconStyle="with-border"
|
||||
iconPosition="left"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(ArrayFieldType);
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import Error from '../../Error';
|
||||
import useField from '../../useField';
|
||||
import Popup from '../../../elements/Popup';
|
||||
import BlockSelector from './BlockSelector';
|
||||
import { blocks as blocksValidator } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { Props } from './types';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import Pill from '../../../elements/Pill';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'blocks-field';
|
||||
|
||||
const labelDefaults = {
|
||||
singular: 'Block',
|
||||
plural: 'Blocks',
|
||||
};
|
||||
|
||||
const Blocks: React.FC<Props> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
path: pathFromProps,
|
||||
blocks,
|
||||
labels = labelDefaults,
|
||||
fieldTypes,
|
||||
maxRows,
|
||||
minRows,
|
||||
required,
|
||||
validate = blocksValidator,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField<number>({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const onAddPopupToggle = useCallback((open) => {
|
||||
if (!open) {
|
||||
setSelectorIndexOpen(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [path, setValue, value, dispatchFields]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, getPreference, path, setPreference, rows]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [getPreference, path, preferencesKey, rows, setPreference]);
|
||||
|
||||
// Set row count on mount and when form context is reset
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<Droppable
|
||||
droppableId="blocks-drop"
|
||||
isDropDisabled={readOnly}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const { blockType } = row;
|
||||
const blockToRender = blocks.find((block) => block.slug === blockType);
|
||||
|
||||
const rowNumber = i + 1;
|
||||
|
||||
if (blockToRender) {
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={(
|
||||
<div className={`${baseClass}__block-header`}>
|
||||
<span className={`${baseClass}__block-number`}>
|
||||
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
|
||||
</span>
|
||||
<Pill
|
||||
pillStyle="white"
|
||||
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
|
||||
>
|
||||
{blockToRender.labels.singular}
|
||||
</Pill>
|
||||
<SectionTitle
|
||||
path={`${path}.${i}.blockName`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
actions={!readOnly ? (
|
||||
<React.Fragment>
|
||||
<Popup
|
||||
key={`${blockType}-${i}`}
|
||||
forceOpen={selectorIndexOpen === i}
|
||||
onToggleOpen={onAddPopupToggle}
|
||||
buttonType="none"
|
||||
size="large"
|
||||
horizontalAlign="right"
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={i}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={() => duplicateRow(i, blockType)}
|
||||
addRow={() => setSelectorIndexOpen(i)}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={blockToRender.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Popup
|
||||
buttonType="custom"
|
||||
size="large"
|
||||
horizontalAlign="left"
|
||||
button={(
|
||||
<Button
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
)}
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={value}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(Blocks);
|
||||
@@ -1,13 +1,417 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Loading from '../../../elements/Loading';
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import Error from '../../Error';
|
||||
import useField from '../../useField';
|
||||
import Popup from '../../../elements/Popup';
|
||||
import BlockSelector from './BlockSelector';
|
||||
import { blocks as blocksValidator } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { Props } from './types';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import Pill from '../../../elements/Pill';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
const Blocks = lazy(() => import('./Blocks'));
|
||||
import './index.scss';
|
||||
|
||||
const BlocksField: React.FC<Props> = (props) => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Blocks {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
const baseClass = 'blocks-field';
|
||||
|
||||
export default BlocksField;
|
||||
const labelDefaults = {
|
||||
singular: 'Block',
|
||||
plural: 'Blocks',
|
||||
};
|
||||
|
||||
const Index: React.FC<Props> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
path: pathFromProps,
|
||||
blocks,
|
||||
labels = labelDefaults,
|
||||
fieldTypes,
|
||||
maxRows,
|
||||
minRows,
|
||||
required,
|
||||
validate = blocksValidator,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField<number>({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const onAddPopupToggle = useCallback((open) => {
|
||||
if (!open) {
|
||||
setSelectorIndexOpen(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [path, setValue, value, dispatchFields]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, getPreference, path, setPreference, rows]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [getPreference, path, preferencesKey, rows, setPreference]);
|
||||
|
||||
// Set row count on mount and when form context is reset
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<Droppable
|
||||
droppableId="blocks-drop"
|
||||
isDropDisabled={readOnly}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const { blockType } = row;
|
||||
const blockToRender = blocks.find((block) => block.slug === blockType);
|
||||
|
||||
const rowNumber = i + 1;
|
||||
|
||||
if (blockToRender) {
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={(
|
||||
<div className={`${baseClass}__block-header`}>
|
||||
<span className={`${baseClass}__block-number`}>
|
||||
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
|
||||
</span>
|
||||
<Pill
|
||||
pillStyle="white"
|
||||
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
|
||||
>
|
||||
{blockToRender.labels.singular}
|
||||
</Pill>
|
||||
<SectionTitle
|
||||
path={`${path}.${i}.blockName`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
actions={!readOnly ? (
|
||||
<React.Fragment>
|
||||
<Popup
|
||||
key={`${blockType}-${i}`}
|
||||
forceOpen={selectorIndexOpen === i}
|
||||
onToggleOpen={onAddPopupToggle}
|
||||
buttonType="none"
|
||||
size="large"
|
||||
horizontalAlign="right"
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={i}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={() => duplicateRow(i, blockType)}
|
||||
addRow={() => setSelectorIndexOpen(i)}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={blockToRender.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Popup
|
||||
buttonType="custom"
|
||||
size="large"
|
||||
horizontalAlign="left"
|
||||
button={(
|
||||
<Button
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
)}
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={value}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(Index);
|
||||
|
||||
@@ -2,7 +2,14 @@ import React, { useCallback, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs/components/prism-core';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import useField from '../../useField';
|
||||
import withCondition from '../../withCondition';
|
||||
import Label from '../../Label';
|
||||
|
||||
@@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import withCondition from '../../withCondition';
|
||||
import { Props } from './types';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import toKebabCase from '../../../../../utilities/toKebabCase';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { DocumentPreferences } from '../../../../../preferences/types';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { getFieldPath } from '../getFieldPath';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -78,7 +78,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
path: getFieldPath(path, field),
|
||||
}))}
|
||||
/>
|
||||
</Collapsible>
|
||||
|
||||
@@ -11,6 +11,10 @@ const ConfirmPassword: React.FC = () => {
|
||||
const password = getField('password');
|
||||
|
||||
const validate = useCallback((value) => {
|
||||
if (!value) {
|
||||
return 'This field is required';
|
||||
}
|
||||
|
||||
if (value === password?.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useState, useReducer,
|
||||
useCallback, useEffect, useState, useReducer, useRef,
|
||||
} from 'react';
|
||||
import equal from 'deep-equal';
|
||||
import qs from 'qs';
|
||||
@@ -22,6 +22,7 @@ import { createRelationMap } from './createRelationMap';
|
||||
import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { getFilterOptionsQuery } from '../getFilterOptionsQuery';
|
||||
import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -46,6 +47,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
width,
|
||||
description,
|
||||
condition,
|
||||
isSortable,
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
@@ -66,9 +68,11 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
|
||||
const [lastLoadedPage, setLastLoadedPage] = useState(1);
|
||||
const [errorLoading, setErrorLoading] = useState('');
|
||||
const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>();
|
||||
const [optionFilters, setOptionFilters] = useState<{ [relation: string]: Where }>();
|
||||
const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false);
|
||||
const firstRun = useRef(true);
|
||||
|
||||
const memoizedValidate = useCallback((value, validationOptions) => {
|
||||
return validate(value, { ...validationOptions, required });
|
||||
@@ -321,6 +325,30 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [initialValue, getResults, optionFilters, filterOptions]);
|
||||
|
||||
// Determine if we should switch to word boundary search
|
||||
useEffect(() => {
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
|
||||
const isIdOnly = relations.reduce((idOnly, relation) => {
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
|
||||
return fieldToSearch === 'id' && idOnly;
|
||||
}, true);
|
||||
setEnableWordBoundarySearch(!isIdOnly);
|
||||
}, [relationTo, collections]);
|
||||
|
||||
|
||||
// When relationTo changes, reset relationship options
|
||||
// Note - effect should not run on first run
|
||||
useEffect(() => {
|
||||
if (firstRun.current) {
|
||||
firstRun.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchOptions({ type: 'CLEAR' });
|
||||
setHasLoadedValueOptions(false);
|
||||
}, [relationTo]);
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
@@ -390,6 +418,11 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
disabled={formProcessing}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
filterOption={enableWordBoundarySearch ? (item, searchFilter) => {
|
||||
const r = wordBoundariesRegex(searchFilter || '');
|
||||
return r.test(item.label);
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
{errorLoading && (
|
||||
|
||||
@@ -25,7 +25,7 @@ const sortOptions = (options: Option[]): Option[] => options.sort((a: Option, b:
|
||||
const optionsReducer = (state: Option[], action: Action): Option[] => {
|
||||
switch (action.type) {
|
||||
case 'CLEAR': {
|
||||
return action.required ? [] : [{ value: 'null', label: 'None' }];
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'ADD': {
|
||||
@@ -51,7 +51,7 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
|
||||
}
|
||||
return docs;
|
||||
},
|
||||
[]),
|
||||
[]),
|
||||
];
|
||||
|
||||
ids.forEach((id) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ export type Option = {
|
||||
|
||||
type CLEAR = {
|
||||
type: 'CLEAR'
|
||||
required: boolean
|
||||
}
|
||||
|
||||
type ADD = {
|
||||
|
||||
@@ -16,7 +16,7 @@ import enablePlugins from './enablePlugins';
|
||||
import defaultValue from '../../../../../fields/richText/defaultValue';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import withHTML from './plugins/withHTML';
|
||||
import { Props, BlurSelectionEditor } from './types';
|
||||
import { Props } from './types';
|
||||
import { RichTextElement, RichTextLeaf } from '../../../../../fields/config/types';
|
||||
import listTypes from './elements/listTypes';
|
||||
import mergeCustomFunctions from './mergeCustomFunctions';
|
||||
@@ -34,7 +34,7 @@ type CustomElement = { type?: string; children: CustomText[] }
|
||||
|
||||
declare module 'slate' {
|
||||
interface CustomTypes {
|
||||
Editor: BaseEditor & ReactEditor & HistoryEditor & BlurSelectionEditor
|
||||
Editor: BaseEditor & ReactEditor & HistoryEditor
|
||||
Element: CustomElement
|
||||
Text: CustomText
|
||||
}
|
||||
@@ -152,18 +152,14 @@ const RichText: React.FC<Props> = (props) => {
|
||||
),
|
||||
);
|
||||
|
||||
CreatedEditor = withHTML(CreatedEditor);
|
||||
|
||||
CreatedEditor = enablePlugins(CreatedEditor, elements);
|
||||
CreatedEditor = enablePlugins(CreatedEditor, leaves);
|
||||
|
||||
CreatedEditor = withHTML(CreatedEditor);
|
||||
|
||||
return CreatedEditor;
|
||||
}, [elements, leaves]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
editor.blurSelection = editor.selection;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) {
|
||||
const mergedElements = mergeCustomFunctions(elements, elementTypes);
|
||||
@@ -238,6 +234,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
if (Button) {
|
||||
return (
|
||||
<Button
|
||||
fieldProps={props}
|
||||
key={i}
|
||||
path={path}
|
||||
/>
|
||||
@@ -257,6 +254,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
if (Button) {
|
||||
return (
|
||||
<Button
|
||||
fieldProps={props}
|
||||
key={i}
|
||||
path={path}
|
||||
/>
|
||||
@@ -279,7 +277,6 @@ const RichText: React.FC<Props> = (props) => {
|
||||
placeholder={placeholder}
|
||||
spellCheck
|
||||
readOnly={readOnly}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
@@ -289,7 +286,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
const selectedElement = Node.descendant(editor, editor.selection.anchor.path.slice(0, -1));
|
||||
|
||||
if (SlateElement.isElement(selectedElement)) {
|
||||
// Allow hard enter to "break out" of certain elements
|
||||
// Allow hard enter to "break out" of certain elements
|
||||
if (editor.shouldBreakOutOnEnter(selectedElement)) {
|
||||
event.preventDefault();
|
||||
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path);
|
||||
|
||||
@@ -24,10 +24,6 @@ const indent = {
|
||||
const handleIndent = useCallback((e, dir) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (editor.blurSelection) {
|
||||
Transforms.select(editor, editor.blurSelection);
|
||||
}
|
||||
|
||||
if (dir === 'left') {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Transforms, Editor, Range } from 'slate';
|
||||
import { useModal } from '@faceless-ui/modal';
|
||||
import ElementButton from '../Button';
|
||||
import { unwrapLink } from './utilities';
|
||||
import LinkIcon from '../../../../../icons/Link';
|
||||
import { EditModal } from './Modal';
|
||||
import { modalSlug as baseModalSlug } from './shared';
|
||||
import isElementActive from '../isActive';
|
||||
import { Fields } from '../../../../Form/types';
|
||||
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
|
||||
import { useAuth } from '../../../../../utilities/Auth';
|
||||
import { useLocale } from '../../../../../utilities/Locale';
|
||||
import { useConfig } from '../../../../../utilities/Config';
|
||||
import { getBaseFields } from './Modal/baseFields';
|
||||
import { Field } from '../../../../../../../fields/config/types';
|
||||
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
|
||||
|
||||
export const LinkButton = ({ fieldProps }) => {
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields;
|
||||
|
||||
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
|
||||
|
||||
const config = useConfig();
|
||||
const editor = useSlate();
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
const { toggleModal } = useModal();
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
const [initialState, setInitialState] = useState<Fields>({});
|
||||
const [fieldSchema] = useState(() => {
|
||||
const fields: Field[] = [
|
||||
...getBaseFields(config),
|
||||
];
|
||||
|
||||
if (customFieldSchema) {
|
||||
fields.push({
|
||||
name: 'fields',
|
||||
type: 'group',
|
||||
admin: {
|
||||
style: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
fields: customFieldSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ElementButton
|
||||
format="link"
|
||||
onClick={async () => {
|
||||
if (isElementActive(editor, 'link')) {
|
||||
unwrapLink(editor);
|
||||
} else {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(true);
|
||||
|
||||
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
|
||||
|
||||
if (!isCollapsed) {
|
||||
const data = {
|
||||
text: editor.selection ? Editor.string(editor, editor.selection) : '',
|
||||
};
|
||||
|
||||
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale });
|
||||
setInitialState(state);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ElementButton>
|
||||
{renderModal && (
|
||||
<EditModal
|
||||
modalSlug={modalSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
initialState={initialState}
|
||||
close={() => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
handleModalSubmit={(fields) => {
|
||||
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
|
||||
const data = reduceFieldsToValues(fields, true);
|
||||
|
||||
const newLink = {
|
||||
type: 'link',
|
||||
linkType: data.linkType,
|
||||
url: data.url,
|
||||
doc: data.doc,
|
||||
newTab: data.newTab,
|
||||
fields: data.fields,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (isCollapsed || !editor.selection) {
|
||||
// If selection anchor and focus are the same,
|
||||
// Just inject a new node with children already set
|
||||
Transforms.insertNodes(editor, {
|
||||
...newLink,
|
||||
children: [{ text: String(data.text) }],
|
||||
});
|
||||
} else if (editor.selection) {
|
||||
// Otherwise we need to wrap the selected node in a link,
|
||||
// Delete its old text,
|
||||
// Move the selection one position forward into the link,
|
||||
// And insert the text back into the new link
|
||||
Transforms.wrapNodes(editor, newLink, { split: true });
|
||||
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' });
|
||||
Transforms.move(editor, { distance: 1, unit: 'offset' });
|
||||
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
|
||||
}
|
||||
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Transforms, Node, Editor } from 'slate';
|
||||
import { useModal } from '@faceless-ui/modal';
|
||||
import { unwrapLink } from './utilities';
|
||||
import Popup from '../../../../../elements/Popup';
|
||||
import { EditModal } from './Modal';
|
||||
import { modalSlug as baseModalSlug } from './shared';
|
||||
import { Fields } from '../../../../Form/types';
|
||||
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
|
||||
import { useAuth } from '../../../../../utilities/Auth';
|
||||
import { useLocale } from '../../../../../utilities/Locale';
|
||||
import { useConfig } from '../../../../../utilities/Config';
|
||||
import { getBaseFields } from './Modal/baseFields';
|
||||
import { Field } from '../../../../../../../fields/config/types';
|
||||
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
|
||||
import deepCopyObject from '../../../../../../../utilities/deepCopyObject';
|
||||
import Button from '../../../../../elements/Button';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'rich-text-link';
|
||||
|
||||
// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text
|
||||
|
||||
export const LinkElement = ({ attributes, children, element, editorRef, fieldProps }) => {
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields;
|
||||
|
||||
const editor = useSlate();
|
||||
const config = useConfig();
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
const { openModal, toggleModal } = useModal();
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
const [renderPopup, setRenderPopup] = useState(false);
|
||||
const [initialState, setInitialState] = useState<Fields>({});
|
||||
const [fieldSchema] = useState(() => {
|
||||
const fields: Field[] = [
|
||||
...getBaseFields(config),
|
||||
];
|
||||
|
||||
if (customFieldSchema) {
|
||||
fields.push({
|
||||
name: 'fields',
|
||||
type: 'group',
|
||||
admin: {
|
||||
style: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
fields: customFieldSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
|
||||
|
||||
const handleTogglePopup = useCallback((render) => {
|
||||
if (!render) {
|
||||
setRenderPopup(render);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const awaitInitialState = async () => {
|
||||
const data = {
|
||||
text: Node.string(element),
|
||||
linkType: element.linkType,
|
||||
url: element.url,
|
||||
doc: element.doc,
|
||||
newTab: element.newTab,
|
||||
fields: deepCopyObject(element.fields),
|
||||
};
|
||||
|
||||
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'update', locale });
|
||||
setInitialState(state);
|
||||
};
|
||||
|
||||
awaitInitialState();
|
||||
}, [renderModal, element, fieldSchema, user, locale]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={baseClass}
|
||||
{...attributes}
|
||||
>
|
||||
<span
|
||||
style={{ userSelect: 'none' }}
|
||||
contentEditable={false}
|
||||
>
|
||||
{renderModal && (
|
||||
<EditModal
|
||||
modalSlug={modalSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
close={() => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
handleModalSubmit={(fields) => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
|
||||
const data = reduceFieldsToValues(fields, true);
|
||||
|
||||
const [, parentPath] = Editor.above(editor);
|
||||
|
||||
const newNode: Record<string, unknown> = {
|
||||
newTab: data.newTab,
|
||||
url: data.url,
|
||||
linkType: data.linkType,
|
||||
doc: data.doc,
|
||||
};
|
||||
|
||||
if (customFieldSchema) {
|
||||
newNode.fields = data.fields;
|
||||
}
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
newNode,
|
||||
{ at: parentPath },
|
||||
);
|
||||
|
||||
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' });
|
||||
Transforms.move(editor, { distance: 1, unit: 'offset' });
|
||||
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
initialState={initialState}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
buttonType="none"
|
||||
size="small"
|
||||
forceOpen={renderPopup}
|
||||
onToggleOpen={handleTogglePopup}
|
||||
horizontalAlign="left"
|
||||
verticalAlign="bottom"
|
||||
boundingRef={editorRef}
|
||||
render={() => (
|
||||
<div className={`${baseClass}__popup`}>
|
||||
{element.linkType === 'internal' && element.doc?.relationTo && element.doc?.value && (
|
||||
<Fragment>
|
||||
Linked to
|
||||
<a
|
||||
className={`${baseClass}__link-label`}
|
||||
href={`${config.routes.admin}/collections/${element.doc.relationTo}/${element.doc.value}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels?.singular}
|
||||
</a>
|
||||
</Fragment>
|
||||
)}
|
||||
{(element.linkType === 'custom' || !element.linkType) && (
|
||||
<a
|
||||
className={`${baseClass}__link-label`}
|
||||
href={element.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{element.url}
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__link-edit`}
|
||||
icon="edit"
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setRenderPopup(false);
|
||||
openModal(modalSlug);
|
||||
setRenderModal(true);
|
||||
}}
|
||||
tooltip="Edit"
|
||||
/>
|
||||
<Button
|
||||
className={`${baseClass}__link-close`}
|
||||
icon="x"
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
unwrapLink(editor);
|
||||
}}
|
||||
tooltip="Remove"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className={[
|
||||
`${baseClass}__button`,
|
||||
].filter(Boolean).join(' ')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }}
|
||||
onClick={() => setRenderPopup(true)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Config } from '../../../../../../../../config/types';
|
||||
import { Field } from '../../../../../../../../fields/config/types';
|
||||
|
||||
export const getBaseFields = (config: Config): Field[] => [
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Text to display',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'linkType',
|
||||
label: 'Link Type',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Choose between entering a custom text URL or linking to another document.',
|
||||
},
|
||||
defaultValue: 'custom',
|
||||
options: [
|
||||
{
|
||||
label: 'Custom URL',
|
||||
value: 'custom',
|
||||
},
|
||||
{
|
||||
label: 'Internal Link',
|
||||
value: 'internal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'Enter a URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
condition: ({ linkType, url }) => {
|
||||
return (typeof linkType === 'undefined' && url) || linkType === 'custom';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'doc',
|
||||
label: 'Choose a document to link to',
|
||||
type: 'relationship',
|
||||
required: true,
|
||||
relationTo: config.collections.map(({ slug }) => slug),
|
||||
admin: {
|
||||
condition: ({ linkType }) => {
|
||||
return linkType === 'internal';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newTab',
|
||||
label: 'Open in new tab',
|
||||
type: 'checkbox',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
@import '../../../../../../../scss/styles.scss';
|
||||
|
||||
.rich-text-link-edit-modal {
|
||||
@include blur-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
&__template {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__header {
|
||||
width: 100%;
|
||||
margin-bottom: $baseline;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: base(1.5);
|
||||
height: base(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Modal } from '@faceless-ui/modal';
|
||||
import React from 'react';
|
||||
import { MinimalTemplate } from '../../../../../..';
|
||||
import Button from '../../../../../../elements/Button';
|
||||
import X from '../../../../../../icons/X';
|
||||
import Form from '../../../../../Form';
|
||||
import FormSubmit from '../../../../../Submit';
|
||||
import { Props } from './types';
|
||||
import fieldTypes from '../../../..';
|
||||
import RenderFields from '../../../../../RenderFields';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'rich-text-link-edit-modal';
|
||||
|
||||
export const EditModal: React.FC<Props> = ({
|
||||
close,
|
||||
handleModalSubmit,
|
||||
initialState,
|
||||
fieldSchema,
|
||||
modalSlug,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
slug={modalSlug}
|
||||
className={baseClass}
|
||||
>
|
||||
<MinimalTemplate className={`${baseClass}__template`}>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<h3>Edit Link</h3>
|
||||
<Button
|
||||
buttonStyle="none"
|
||||
onClick={close}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</header>
|
||||
<Form
|
||||
onSubmit={handleModalSubmit}
|
||||
initialState={initialState}
|
||||
>
|
||||
<RenderFields
|
||||
fieldTypes={fieldTypes}
|
||||
readOnly={false}
|
||||
fieldSchema={fieldSchema}
|
||||
forceRender
|
||||
/>
|
||||
<FormSubmit>
|
||||
Confirm
|
||||
</FormSubmit>
|
||||
</Form>
|
||||
</MinimalTemplate>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Field } from '../../../../../../../../fields/config/types';
|
||||
import { Fields } from '../../../../../Form/types';
|
||||
|
||||
export type Props = {
|
||||
modalSlug: string
|
||||
close: () => void
|
||||
handleModalSubmit: (fields: Fields, data: Record<string, unknown>) => void
|
||||
initialState?: Fields
|
||||
fieldSchema: Field[]
|
||||
}
|
||||
@@ -10,14 +10,44 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
|
||||
.popup__scroll,
|
||||
.popup__wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.popup__scroll {
|
||||
padding-right: base(.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-link__popup-wrap {
|
||||
cursor: pointer;
|
||||
.icon--x line {
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
bottom: 80%;
|
||||
&__popup {
|
||||
@extend %body;
|
||||
font-family: var(--font-body);
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
@extend %btn-reset;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin: 0 0 0 base(.25);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link-label {
|
||||
max-width: base(8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: base(.25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,61 +66,4 @@
|
||||
&--open {
|
||||
z-index: var(--z-popup);
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-link__url-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-bottom: base(.5);
|
||||
}
|
||||
|
||||
.rich-text-link__confirm {
|
||||
position: absolute;
|
||||
right: base(.5);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
svg {
|
||||
@include color-svg(var(--theme-elevation-0));
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-link__url {
|
||||
@include formInput;
|
||||
padding-right: base(1.75);
|
||||
min-width: base(12);
|
||||
width: 100%;
|
||||
background: var(--theme-input-bg);
|
||||
color: var(--theme-elevation-1000);
|
||||
}
|
||||
|
||||
.rich-text-link__new-tab {
|
||||
svg {
|
||||
@include color-svg(var(--theme-elevation-900));
|
||||
background: var(--theme-elevation-100);
|
||||
margin-right: base(.5);
|
||||
}
|
||||
|
||||
path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
path {
|
||||
opacity: .2;
|
||||
}
|
||||
}
|
||||
|
||||
&--checked {
|
||||
path {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
path {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +1,10 @@
|
||||
import React, { Fragment, useCallback, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Transforms } from 'slate';
|
||||
import ElementButton from '../Button';
|
||||
import { withLinks, wrapLink } from './utilities';
|
||||
import LinkIcon from '../../../../../icons/Link';
|
||||
import Popup from '../../../../../elements/Popup';
|
||||
import Button from '../../../../../elements/Button';
|
||||
import Check from '../../../../../icons/Check';
|
||||
import Error from '../../../../Error';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'rich-text-link';
|
||||
|
||||
const Link = ({ attributes, children, element, editorRef }) => {
|
||||
const editor = useSlate();
|
||||
const [error, setError] = useState(false);
|
||||
const [open, setOpen] = useState(element.url === undefined);
|
||||
|
||||
const handleToggleOpen = useCallback((newOpen) => {
|
||||
setOpen(newOpen);
|
||||
|
||||
if (element.url === undefined && !newOpen) {
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{ url: '' },
|
||||
{ at: path },
|
||||
);
|
||||
}
|
||||
}, [editor, element]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={baseClass}
|
||||
{...attributes}
|
||||
>
|
||||
<span
|
||||
style={{ userSelect: 'none' }}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Popup
|
||||
initActive={element.url === undefined}
|
||||
buttonType="none"
|
||||
size="small"
|
||||
horizontalAlign="center"
|
||||
forceOpen={open}
|
||||
onToggleOpen={handleToggleOpen}
|
||||
boundingRef={editorRef}
|
||||
render={({ close }) => (
|
||||
<Fragment>
|
||||
<div className={`${baseClass}__url-wrap`}>
|
||||
<input
|
||||
value={element.url || ''}
|
||||
className={`${baseClass}__url`}
|
||||
placeholder="Enter a URL"
|
||||
onChange={(e) => {
|
||||
const { value } = e.target;
|
||||
|
||||
if (value && error) {
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{ url: value },
|
||||
{ at: path },
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={`${baseClass}__confirm`}
|
||||
buttonStyle="none"
|
||||
icon="chevron"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (element.url) {
|
||||
close();
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<Error
|
||||
showError={error}
|
||||
message="Please enter a valid URL."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={[`${baseClass}__new-tab`, element.newTab && `${baseClass}__new-tab--checked`].filter(Boolean).join(' ')}
|
||||
buttonStyle="none"
|
||||
onClick={() => {
|
||||
const path = ReactEditor.findPath(editor, element);
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{ newTab: !element.newTab },
|
||||
{ at: path },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check />
|
||||
Open link in new tab
|
||||
</Button>
|
||||
</Fragment>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
className={[
|
||||
`${baseClass}__button`,
|
||||
open && `${baseClass}__button--open`,
|
||||
].filter(Boolean).join(' ')}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkButton = () => {
|
||||
const editor = useSlate();
|
||||
|
||||
return (
|
||||
<ElementButton
|
||||
format="link"
|
||||
onClick={() => wrapLink(editor)}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ElementButton>
|
||||
);
|
||||
};
|
||||
import { withLinks } from './utilities';
|
||||
import { LinkButton } from './Button';
|
||||
import { LinkElement } from './Element';
|
||||
|
||||
const link = {
|
||||
Button: LinkButton,
|
||||
Element: Link,
|
||||
Element: LinkElement,
|
||||
plugins: [
|
||||
withLinks,
|
||||
],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const modalSlug = 'rich-text-link-modal';
|
||||
@@ -1,37 +1,25 @@
|
||||
import { Editor, Transforms, Range, Element } from 'slate';
|
||||
import isElementActive from '../isActive';
|
||||
|
||||
export const unwrapLink = (editor: Editor): void => {
|
||||
Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === 'link' });
|
||||
};
|
||||
|
||||
export const wrapLink = (editor: Editor, url?: string, newTab?: boolean): void => {
|
||||
const { selection, blurSelection } = editor;
|
||||
export const wrapLink = (editor: Editor): void => {
|
||||
const { selection } = editor;
|
||||
const isCollapsed = selection && Range.isCollapsed(selection);
|
||||
|
||||
if (blurSelection) {
|
||||
Transforms.select(editor, blurSelection);
|
||||
}
|
||||
const link = {
|
||||
type: 'link',
|
||||
url: undefined,
|
||||
newTab: false,
|
||||
children: isCollapsed ? [{ text: '' }] : [],
|
||||
};
|
||||
|
||||
if (isElementActive(editor, 'link')) {
|
||||
unwrapLink(editor);
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, link);
|
||||
} else {
|
||||
const selectionToUse = selection || blurSelection;
|
||||
|
||||
const isCollapsed = selectionToUse && Range.isCollapsed(selectionToUse);
|
||||
|
||||
const link = {
|
||||
type: 'link',
|
||||
url,
|
||||
newTab,
|
||||
children: isCollapsed ? [{ text: url }] : [],
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
Transforms.insertNodes(editor, link);
|
||||
} else {
|
||||
Transforms.wrapNodes(editor, link, { split: true });
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
}
|
||||
Transforms.wrapNodes(editor, link, { split: true });
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { Modal, useModal } from '@faceless-ui/modal';
|
||||
import { Transforms } from 'slate';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { useConfig } from '../../../../../../utilities/Config';
|
||||
import ElementButton from '../../Button';
|
||||
@@ -32,17 +31,13 @@ const insertRelationship = (editor, { value, relationTo }) => {
|
||||
],
|
||||
};
|
||||
|
||||
if (editor.blurSelection) {
|
||||
Transforms.select(editor, editor.blurSelection);
|
||||
}
|
||||
|
||||
injectVoidElement(editor, relationship);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
};
|
||||
|
||||
const RelationshipButton: React.FC<{path: string}> = ({ path }) => {
|
||||
const { open, closeAll } = useModal();
|
||||
const RelationshipButton: React.FC<{ path: string }> = ({ path }) => {
|
||||
const { toggleModal } = useModal();
|
||||
const editor = useSlate();
|
||||
const { serverURL, routes: { api }, collections } = useConfig();
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
@@ -57,16 +52,16 @@ const RelationshipButton: React.FC<{path: string}> = ({ path }) => {
|
||||
const json = await res.json();
|
||||
|
||||
insertRelationship(editor, { value: { id: json.id }, relationTo });
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
setLoading(false);
|
||||
}, [editor, closeAll, api, serverURL]);
|
||||
}, [editor, toggleModal, modalSlug, api, serverURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderModal) {
|
||||
open(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}
|
||||
}, [renderModal, open, modalSlug]);
|
||||
}, [renderModal, toggleModal, modalSlug]);
|
||||
|
||||
if (!hasEnabledCollections) return null;
|
||||
|
||||
@@ -90,7 +85,7 @@ const RelationshipButton: React.FC<{path: string}> = ({ path }) => {
|
||||
<Button
|
||||
buttonStyle="none"
|
||||
onClick={() => {
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,10 +15,6 @@ const toggleElement = (editor, format) => {
|
||||
type = 'li';
|
||||
}
|
||||
|
||||
if (editor.blurSelection) {
|
||||
Transforms.select(editor, editor.blurSelection);
|
||||
}
|
||||
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: (n) => Element.isElement(n) && listTypes.includes(n.type as string),
|
||||
split: true,
|
||||
|
||||
@@ -36,22 +36,18 @@ const insertUpload = (editor, { value, relationTo }) => {
|
||||
],
|
||||
};
|
||||
|
||||
if (editor.blurSelection) {
|
||||
Transforms.select(editor, editor.blurSelection);
|
||||
}
|
||||
|
||||
injectVoidElement(editor, upload);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
};
|
||||
|
||||
const UploadButton: React.FC<{path: string}> = ({ path }) => {
|
||||
const { open, closeAll, currentModal } = useModal();
|
||||
const UploadButton: React.FC<{ path: string }> = ({ path }) => {
|
||||
const { toggleModal, modalState } = useModal();
|
||||
const editor = useSlate();
|
||||
const { serverURL, routes: { api }, collections } = useConfig();
|
||||
const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string}>(() => {
|
||||
const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string }>(() => {
|
||||
const firstAvailableCollection = collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship));
|
||||
if (firstAvailableCollection) {
|
||||
return { label: firstAvailableCollection.labels.singular, value: firstAvailableCollection.slug };
|
||||
@@ -69,7 +65,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
|
||||
|
||||
const modalSlug = `${path}-add-upload`;
|
||||
const moreThanOneAvailableCollection = availableCollections.length > 1;
|
||||
const isOpen = currentModal === modalSlug;
|
||||
const isOpen = modalState[modalSlug]?.isOpen;
|
||||
|
||||
// If modal is open, get active page of upload gallery
|
||||
const apiURL = isOpen ? `${serverURL}${api}/${modalCollection.slug}` : null;
|
||||
@@ -83,9 +79,9 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (renderModal) {
|
||||
open(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}
|
||||
}, [renderModal, open, modalSlug]);
|
||||
}, [renderModal, toggleModal, modalSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
const params: {
|
||||
@@ -141,7 +137,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
|
||||
buttonStyle="icon-label"
|
||||
iconStyle="with-border"
|
||||
onClick={() => {
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
/>
|
||||
@@ -179,7 +175,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
|
||||
relationTo: modalCollection.slug,
|
||||
});
|
||||
setRenderModal(false);
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
/>
|
||||
<div className={`${baseModalClass}__page-controls`}>
|
||||
|
||||
@@ -10,7 +10,6 @@ import Button from '../../../../../../../elements/Button';
|
||||
import RenderFields from '../../../../../../RenderFields';
|
||||
import fieldTypes from '../../../../..';
|
||||
import Form from '../../../../../../Form';
|
||||
import reduceFieldsToValues from '../../../../../../Form/reduceFieldsToValues';
|
||||
import Submit from '../../../../../../Submit';
|
||||
import { Field } from '../../../../../../../../../fields/config/types';
|
||||
import { useLocale } from '../../../../../../../utilities/Locale';
|
||||
@@ -34,9 +33,9 @@ export const EditModal: React.FC<Props> = ({ slug, closeModal, relatedCollection
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
|
||||
const handleUpdateEditData = useCallback((fields) => {
|
||||
const handleUpdateEditData = useCallback((_, data) => {
|
||||
const newNode = {
|
||||
fields: reduceFieldsToValues(fields, true),
|
||||
fields: data,
|
||||
};
|
||||
|
||||
const elementPath = ReactEditor.findPath(editor, element);
|
||||
@@ -90,7 +89,6 @@ export const EditModal: React.FC<Props> = ({ slug, closeModal, relatedCollection
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={fieldSchema}
|
||||
/>
|
||||
|
||||
<Submit>
|
||||
Save changes
|
||||
</Submit>
|
||||
|
||||
@@ -21,7 +21,7 @@ const initialParams = {
|
||||
|
||||
const Element = ({ attributes, children, element, path, fieldProps }) => {
|
||||
const { relationTo, value } = element;
|
||||
const { closeAll, open } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const { collections, serverURL, routes: { api } } = useConfig();
|
||||
const [modalToRender, setModalToRender] = useState(undefined);
|
||||
const [relatedCollection, setRelatedCollection] = useState<SanitizedCollectionConfig>(() => collections.find((coll) => coll.slug === relationTo));
|
||||
@@ -50,15 +50,15 @@ const Element = ({ attributes, children, element, path, fieldProps }) => {
|
||||
}, [editor, element]);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
setModalToRender(null);
|
||||
}, [closeAll]);
|
||||
}, [toggleModal, modalSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalToRender && modalSlug) {
|
||||
open(`${modalSlug}`);
|
||||
if (modalToRender) {
|
||||
toggleModal(modalSlug);
|
||||
}
|
||||
}, [modalToRender, open, modalSlug]);
|
||||
}, [modalToRender, toggleModal, modalSlug]);
|
||||
|
||||
const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const ELEMENT_TAGS = {
|
||||
H4: () => ({ type: 'h4' }),
|
||||
H5: () => ({ type: 'h5' }),
|
||||
H6: () => ({ type: 'h6' }),
|
||||
IMG: (el) => ({ type: 'image', url: el.getAttribute('src') }),
|
||||
LI: () => ({ type: 'li' }),
|
||||
OL: () => ({ type: 'ol' }),
|
||||
P: () => ({ type: 'p' }),
|
||||
@@ -47,10 +46,15 @@ const deserialize = (el) => {
|
||||
) {
|
||||
[parent] = el.childNodes;
|
||||
}
|
||||
const children = Array.from(parent.childNodes)
|
||||
|
||||
let children = Array.from(parent.childNodes)
|
||||
.map(deserialize)
|
||||
.flat();
|
||||
|
||||
if (children.length === 0) {
|
||||
children = [{ text: '' }];
|
||||
}
|
||||
|
||||
if (el.nodeName === 'BODY') {
|
||||
return jsx('fragment', {}, children);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,3 @@ import { RichTextField } from '../../../../../fields/config/types';
|
||||
export type Props = Omit<RichTextField, 'type'> & {
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface BlurSelectionEditor extends BaseEditor {
|
||||
blurSelection?: Selection
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import withCondition from '../../withCondition';
|
||||
import { Props } from './types';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { getFieldPath } from '../getFieldPath';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -32,7 +32,7 @@ const Row: React.FC<Props> = (props) => {
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
path: getFieldPath(path, field),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SelectInputProps = Omit<SelectField, 'type' | 'value' | 'options'> &
|
||||
className?: string
|
||||
width?: string
|
||||
hasMany?: boolean
|
||||
isSortable?: boolean
|
||||
options?: OptionObject[]
|
||||
isClearable?: boolean
|
||||
}
|
||||
@@ -43,6 +44,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
|
||||
width,
|
||||
options,
|
||||
hasMany,
|
||||
isSortable,
|
||||
isClearable,
|
||||
} = props;
|
||||
|
||||
@@ -87,6 +89,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
|
||||
isDisabled={readOnly}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
<FieldDescription
|
||||
|
||||
@@ -34,6 +34,7 @@ const Select: React.FC<Props> = (props) => {
|
||||
description,
|
||||
isClearable,
|
||||
condition,
|
||||
isSortable
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
@@ -99,6 +100,7 @@ const Select: React.FC<Props> = (props) => {
|
||||
className={className}
|
||||
width={width}
|
||||
hasMany={hasMany}
|
||||
isSortable={isSortable}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
|
||||
.tabs-field__tabs {
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: ' ';
|
||||
@@ -111,4 +112,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,26 +58,26 @@ const TabsField: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
<div className={`${baseClass}__content-wrap`}>
|
||||
{activeTab && (
|
||||
<div className={[
|
||||
`${baseClass}__tab`,
|
||||
`${baseClass}__tab-${toKebabCase(activeTab.label)}`,
|
||||
].join(' ')}
|
||||
>
|
||||
<FieldDescription
|
||||
className={`${baseClass}__description`}
|
||||
description={activeTab.description}
|
||||
/>
|
||||
<RenderFields
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
permissions={permissions?.fields}
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={activeTab.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${tabHasName(activeTab) ? `${activeTab.name}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className={[
|
||||
`${baseClass}__tab`,
|
||||
`${baseClass}__tab-${toKebabCase(activeTab.label)}`,
|
||||
].join(' ')}
|
||||
>
|
||||
<FieldDescription
|
||||
className={`${baseClass}__description`}
|
||||
description={activeTab.description}
|
||||
/>
|
||||
<RenderFields
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
permissions={permissions?.fields}
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={activeTab.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${tabHasName(activeTab) ? `${activeTab.name}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ import Error from '../../Error';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { TextField } from '../../../../../fields/config/types';
|
||||
import { Description } from '../../FieldDescription/types';
|
||||
// import { FieldType } from '../../useField/types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -17,10 +16,12 @@ export type TextInputProps = Omit<TextField, 'type'> & {
|
||||
value?: string
|
||||
description?: Description
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
width?: string
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>
|
||||
}
|
||||
|
||||
const TextInput: React.FC<TextInputProps> = (props) => {
|
||||
@@ -34,10 +35,12 @@ const TextInput: React.FC<TextInputProps> = (props) => {
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
description,
|
||||
style,
|
||||
className,
|
||||
width,
|
||||
inputRef,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -66,9 +69,11 @@ const TextInput: React.FC<TextInputProps> = (props) => {
|
||||
required={required}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={readOnly}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
|
||||
@@ -23,6 +23,7 @@ const Text: React.FC<Props> = (props) => {
|
||||
description,
|
||||
condition,
|
||||
} = {},
|
||||
inputRef,
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
@@ -63,6 +64,7 @@ const Text: React.FC<Props> = (props) => {
|
||||
className={className}
|
||||
width={width}
|
||||
description={description}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,4 +2,6 @@ import { TextField } from '../../../../../fields/config/types';
|
||||
|
||||
export type Props = Omit<TextField, 'type'> & {
|
||||
path?: string
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ const AddUploadModal: React.FC<Props> = (props) => {
|
||||
|
||||
const { permissions } = useAuth();
|
||||
const { serverURL, routes: { api } } = useConfig();
|
||||
const { closeAll } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
|
||||
const onSuccess = useCallback((json) => {
|
||||
closeAll();
|
||||
toggleModal(slug);
|
||||
setValue(json.doc);
|
||||
}, [closeAll, setValue]);
|
||||
}, [toggleModal, slug, setValue]);
|
||||
|
||||
const classes = [
|
||||
baseClass,
|
||||
@@ -69,7 +69,7 @@ const AddUploadModal: React.FC<Props> = (props) => {
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
iconStyle="with-border"
|
||||
onClick={closeAll}
|
||||
onClick={() => toggleModal(slug)}
|
||||
/>
|
||||
</div>
|
||||
{description && (
|
||||
|
||||
@@ -58,7 +58,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
|
||||
filterOptions,
|
||||
} = props;
|
||||
|
||||
const { toggle } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
|
||||
const addModalSlug = `${path}-add`;
|
||||
const selectExistingModalSlug = `${path}-select-existing`;
|
||||
@@ -131,7 +131,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
toggle(addModalSlug);
|
||||
toggleModal(addModalSlug);
|
||||
}}
|
||||
>
|
||||
Upload new
|
||||
@@ -141,7 +141,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
toggle(selectExistingModalSlug);
|
||||
toggleModal(selectExistingModalSlug);
|
||||
}}
|
||||
>
|
||||
Choose from existing
|
||||
@@ -153,7 +153,10 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
|
||||
collection,
|
||||
slug: addModalSlug,
|
||||
fieldTypes,
|
||||
setValue: onChange,
|
||||
setValue: (e) => {
|
||||
setMissingFile(false);
|
||||
onChange(e);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SelectExistingModal
|
||||
|
||||
@@ -44,7 +44,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
|
||||
const { id } = useDocumentInfo();
|
||||
const { user } = useAuth();
|
||||
const { getData, getSiblingData } = useWatchForm();
|
||||
const { closeAll, currentModal } = useModal();
|
||||
const { toggleModal, modalState } = useModal();
|
||||
const [fields] = useState(() => formatFields(collection));
|
||||
const [limit, setLimit] = useState(defaultLimit);
|
||||
const [sort, setSort] = useState(null);
|
||||
@@ -56,7 +56,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
|
||||
baseClass,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const isOpen = currentModal === modalSlug;
|
||||
const isOpen = modalState[modalSlug]?.isOpen;
|
||||
|
||||
const apiURL = isOpen ? `${serverURL}${api}/${collectionSlug}` : null;
|
||||
|
||||
@@ -115,7 +115,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
iconStyle="with-border"
|
||||
onClick={closeAll}
|
||||
onClick={() => toggleModal(modalSlug)}
|
||||
/>
|
||||
</div>
|
||||
{description && (
|
||||
@@ -140,7 +140,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
|
||||
collection={collection}
|
||||
onCardClick={(doc) => {
|
||||
setValue(doc);
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
/>
|
||||
<div className={`${baseClass}__page-controls`}>
|
||||
|
||||
7
src/admin/components/forms/field-types/getFieldPath.ts
Normal file
7
src/admin/components/forms/field-types/getFieldPath.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Field, fieldAffectsData } from '../../../../fields/config/types';
|
||||
|
||||
export const getFieldPath = (path: string, field: Field): string => {
|
||||
// prevents duplicate . on nesting non-named fields
|
||||
const dot = path && path.slice(-1) === '.' ? '' : '.';
|
||||
return `${path ? `${path}${dot}` : ''}${fieldAffectsData(field) ? field.name : ''}`;
|
||||
};
|
||||
@@ -25,29 +25,29 @@ import upload from './Upload';
|
||||
import ui from './UI';
|
||||
|
||||
export type FieldTypes = {
|
||||
code: React.ComponentType
|
||||
email: React.ComponentType
|
||||
hidden: React.ComponentType
|
||||
text: React.ComponentType
|
||||
date: React.ComponentType
|
||||
password: React.ComponentType
|
||||
confirmPassword: React.ComponentType
|
||||
relationship: React.ComponentType
|
||||
textarea: React.ComponentType
|
||||
select: React.ComponentType
|
||||
number: React.ComponentType
|
||||
point: React.ComponentType
|
||||
checkbox: React.ComponentType
|
||||
richText: React.ComponentType
|
||||
radio: React.ComponentType
|
||||
blocks: React.ComponentType
|
||||
group: React.ComponentType
|
||||
array: React.ComponentType
|
||||
row: React.ComponentType
|
||||
collapsible: React.ComponentType
|
||||
tabs: React.ComponentType
|
||||
upload: React.ComponentType
|
||||
ui: React.ComponentType
|
||||
code: React.ComponentType<any>
|
||||
email: React.ComponentType<any>
|
||||
hidden: React.ComponentType<any>
|
||||
text: React.ComponentType<any>
|
||||
date: React.ComponentType<any>
|
||||
password: React.ComponentType<any>
|
||||
confirmPassword: React.ComponentType<any>
|
||||
relationship: React.ComponentType<any>
|
||||
textarea: React.ComponentType<any>
|
||||
select: React.ComponentType<any>
|
||||
number: React.ComponentType<any>
|
||||
point: React.ComponentType<any>
|
||||
checkbox: React.ComponentType<any>
|
||||
richText: React.ComponentType<any>
|
||||
radio: React.ComponentType<any>
|
||||
blocks: React.ComponentType<any>
|
||||
group: React.ComponentType<any>
|
||||
array: React.ComponentType<any>
|
||||
row: React.ComponentType<any>
|
||||
collapsible: React.ComponentType<any>
|
||||
tabs: React.ComponentType<any>
|
||||
upload: React.ComponentType<any>
|
||||
ui: React.ComponentType<any>
|
||||
}
|
||||
|
||||
const fieldTypes: FieldTypes = {
|
||||
|
||||
@@ -13,7 +13,7 @@ const useField = <T extends unknown>(options: Options): FieldType<T> => {
|
||||
path,
|
||||
validate,
|
||||
enableDebouncedValue,
|
||||
disableFormData,
|
||||
disableFormData = false,
|
||||
condition,
|
||||
} = options;
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ import './index.scss';
|
||||
|
||||
const baseClass = 'stay-logged-in';
|
||||
|
||||
const modalSlug = 'stay-logged-in';
|
||||
|
||||
const StayLoggedInModal: React.FC<Props> = (props) => {
|
||||
const { refreshCookie } = props;
|
||||
const history = useHistory();
|
||||
const { routes: { admin } } = useConfig();
|
||||
const { closeAll: closeAllModals } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,7 +30,7 @@ const StayLoggedInModal: React.FC<Props> = (props) => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
closeAllModals();
|
||||
toggleModal(modalSlug);
|
||||
history.push(`${admin}/logout`);
|
||||
}}
|
||||
>
|
||||
@@ -36,7 +38,7 @@ const StayLoggedInModal: React.FC<Props> = (props) => {
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
refreshCookie();
|
||||
closeAllModals();
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Stay logged in
|
||||
|
||||
@@ -38,7 +38,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [permissions, setPermissions] = useState<Permissions>();
|
||||
|
||||
|
||||
const { open: openModal, closeAll: closeAllModals } = useModal();
|
||||
const { openModal, closeAllModals } = useModal();
|
||||
const [lastLocationChange, setLastLocationChange] = useState(0);
|
||||
const debouncedLocationChange = useDebounce(lastLocationChange, 10000);
|
||||
|
||||
@@ -64,7 +64,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}, [setUser, push, exp, admin, api, serverURL, userSlug]);
|
||||
|
||||
const setToken = useCallback((token: string) => {
|
||||
const decoded = jwtDecode(token) as User;
|
||||
const decoded = jwtDecode<User>(token);
|
||||
setUser(decoded);
|
||||
setTokenInMemory(token);
|
||||
}, []);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
CustomComponent: React.ComponentType
|
||||
DefaultComponent: React.ComponentType
|
||||
CustomComponent: React.ComponentType<any>
|
||||
DefaultComponent: React.ComponentType<any>
|
||||
componentProps?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
|
||||
<OperationContext.Provider value="update">
|
||||
<Form
|
||||
className={`${baseClass}__form`}
|
||||
method="put"
|
||||
method="patch"
|
||||
action={action}
|
||||
initialState={initialState}
|
||||
disabled={!hasSavePermission}
|
||||
|
||||
@@ -40,7 +40,7 @@ const AccountView: React.FC = () => {
|
||||
},
|
||||
} = useConfig();
|
||||
|
||||
const collection = collections.find((coll) => coll.slug === user.collection);
|
||||
const collection = collections.find((coll) => coll.slug === adminUser);
|
||||
|
||||
const { fields } = collection;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const modalSlug = 'restore-version';
|
||||
const Restore: React.FC<Props> = ({ collection, global, className, versionID, originalDocID, versionDate }) => {
|
||||
const { serverURL, routes: { api, admin } } = useConfig();
|
||||
const history = useHistory();
|
||||
const { toggle } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
let fetchURL = `${serverURL}${api}`;
|
||||
@@ -51,7 +51,7 @@ const Restore: React.FC<Props> = ({ collection, global, className, versionID, or
|
||||
return (
|
||||
<Fragment>
|
||||
<Pill
|
||||
onClick={() => toggle(modalSlug)}
|
||||
onClick={() => toggleModal(modalSlug)}
|
||||
className={[baseClass, className].filter(Boolean).join(' ')}
|
||||
>
|
||||
Restore this version
|
||||
@@ -66,7 +66,7 @@ const Restore: React.FC<Props> = ({ collection, global, className, versionID, or
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={processing ? undefined : () => toggle(modalSlug)}
|
||||
onClick={processing ? undefined : () => toggleModal(modalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -18,17 +18,11 @@ const baseClass = 'auth-fields';
|
||||
const Auth: React.FC<Props> = (props) => {
|
||||
const { useAPIKey, requirePassword, verify, collection: { slug }, collection, email, operation } = props;
|
||||
const [changingPassword, setChangingPassword] = useState(requirePassword);
|
||||
const { getField } = useWatchForm();
|
||||
const { getField, dispatchFields } = useWatchForm();
|
||||
const modified = useFormModified();
|
||||
|
||||
const enableAPIKey = getField('enableAPIKey');
|
||||
|
||||
useEffect(() => {
|
||||
if (!modified) {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
}, [modified]);
|
||||
|
||||
const {
|
||||
serverURL,
|
||||
routes: {
|
||||
@@ -36,6 +30,15 @@ const Auth: React.FC<Props> = (props) => {
|
||||
},
|
||||
} = useConfig();
|
||||
|
||||
const handleChangePassword = useCallback(async (state: boolean) => {
|
||||
if (!state) {
|
||||
dispatchFields({ type: 'REMOVE', path: 'password' });
|
||||
dispatchFields({ type: 'REMOVE', path: 'confirm-password' });
|
||||
}
|
||||
|
||||
setChangingPassword(state);
|
||||
}, [dispatchFields]);
|
||||
|
||||
const unlock = useCallback(async () => {
|
||||
const url = `${serverURL}${api}/${slug}/unlock`;
|
||||
const response = await fetch(url, {
|
||||
@@ -55,6 +58,12 @@ const Auth: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [serverURL, api, slug, email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modified) {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
}, [modified]);
|
||||
|
||||
if (collection.auth.disableLocalStrategy) {
|
||||
return null;
|
||||
}
|
||||
@@ -80,7 +89,7 @@ const Auth: React.FC<Props> = (props) => {
|
||||
<Button
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => setChangingPassword(false)}
|
||||
onClick={() => handleChangePassword(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -91,7 +100,7 @@ const Auth: React.FC<Props> = (props) => {
|
||||
<Button
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => setChangingPassword(true)}
|
||||
onClick={() => handleChangePassword(true)}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
|
||||
@@ -81,7 +81,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
<OperationContext.Provider value={operation}>
|
||||
<Form
|
||||
className={`${baseClass}__form`}
|
||||
method={id ? 'put' : 'post'}
|
||||
method={id ? 'patch' : 'post'}
|
||||
action={action}
|
||||
onSuccess={onSave}
|
||||
disabled={!hasSavePermission}
|
||||
@@ -142,8 +142,14 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
Create New
|
||||
</Link>
|
||||
</li>
|
||||
{!disableDuplicate && (
|
||||
<li><DuplicateDocument slug={slug} /></li>
|
||||
{!disableDuplicate && isEditing && (
|
||||
<li>
|
||||
<DuplicateDocument
|
||||
collection={collection}
|
||||
id={id}
|
||||
slug={slug}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,7 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
|
||||
const [fields] = useState(() => formatFields(incomingCollection, isEditing));
|
||||
const [collection] = useState(() => ({ ...incomingCollection, fields }));
|
||||
const [redirect, setRedirect] = useState<string>();
|
||||
|
||||
const locale = useLocale();
|
||||
const { serverURL, routes: { admin, api } } = useConfig();
|
||||
@@ -51,12 +52,12 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
const onSave = useCallback(async (json: any) => {
|
||||
getVersions();
|
||||
if (!isEditing) {
|
||||
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
|
||||
setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`);
|
||||
} else {
|
||||
const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale });
|
||||
setInitialState(state);
|
||||
}
|
||||
}, [admin, collection, history, isEditing, getVersions, user, id, locale]);
|
||||
}, [admin, collection, isEditing, getVersions, user, id, locale]);
|
||||
|
||||
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
|
||||
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
|
||||
@@ -111,6 +112,12 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
awaitInitialState();
|
||||
}, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument, preferencesKey, getPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirect) {
|
||||
history.push(redirect);
|
||||
}
|
||||
}, [history, redirect]);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Redirect to={`${admin}/not-found`} />
|
||||
|
||||
@@ -17,7 +17,7 @@ const RelationshipCell = (props) => {
|
||||
const { getRelationships, documents } = useListRelationships();
|
||||
const [hasRequested, setHasRequested] = useState(false);
|
||||
|
||||
const isAboveViewport = entry?.boundingClientRect?.top > 0;
|
||||
const isAboveViewport = entry?.boundingClientRect?.top < window.innerHeight;
|
||||
|
||||
useEffect(() => {
|
||||
if (cellData && isAboveViewport && !hasRequested) {
|
||||
|
||||
@@ -9,9 +9,9 @@ import useDebounce from '../../../../../hooks/useDebounce';
|
||||
// set to false when no doc is returned
|
||||
// or set to the document returned
|
||||
export type Documents = {
|
||||
[slug: string]: {
|
||||
[id: string | number]: TypeWithID | null | false
|
||||
}
|
||||
[slug: string]: {
|
||||
[id: string | number]: TypeWithID | null | false
|
||||
}
|
||||
}
|
||||
|
||||
type ListRelationshipContext = {
|
||||
@@ -24,7 +24,7 @@ type ListRelationshipContext = {
|
||||
|
||||
const Context = createContext({} as ListRelationshipContext);
|
||||
|
||||
export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ children }) => {
|
||||
export const RelationshipProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const [documents, dispatchDocuments] = useReducer(reducer, {});
|
||||
const debouncedDocuments = useDebounce(documents, 100);
|
||||
const config = useConfig();
|
||||
@@ -48,7 +48,7 @@ export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ c
|
||||
const params = {
|
||||
depth: 0,
|
||||
'where[id][in]': idsToLoad,
|
||||
pagination: false,
|
||||
limit: 250,
|
||||
};
|
||||
|
||||
const query = querystring.stringify(params, { addQueryPrefix: true });
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useEffect, useRef } from 'react';
|
||||
type useThrottledEffect = (callback: React.EffectCallback, delay: number, deps: React.DependencyList) => void;
|
||||
|
||||
const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => {
|
||||
const lastRan = useRef(Date.now());
|
||||
const lastRan = useRef<number>(null);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
useEffect(() => {
|
||||
if (lastRan) {
|
||||
const handler = setTimeout(() => {
|
||||
if (Date.now() - lastRan.current >= delay) {
|
||||
callback();
|
||||
@@ -18,9 +18,12 @@ const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => {
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[delay, ...deps],
|
||||
);
|
||||
}
|
||||
|
||||
callback();
|
||||
lastRan.current = Date.now();
|
||||
return () => null;
|
||||
}, [delay, ...deps]);
|
||||
};
|
||||
|
||||
export default useThrottledEffect;
|
||||
|
||||
@@ -35,6 +35,7 @@ const Index = () => (
|
||||
<ModalProvider
|
||||
classPrefix="payload"
|
||||
zIndex={50}
|
||||
transTime={0}
|
||||
>
|
||||
<AuthProvider>
|
||||
<PreferencesProvider>
|
||||
|
||||
@@ -129,7 +129,7 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500);
|
||||
|
||||
&:before {
|
||||
background: $color;
|
||||
opacity: .9;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
&:after {
|
||||
@@ -181,4 +181,4 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500);
|
||||
border-color: var(--theme-elevation-150);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user