chore: merge master

This commit is contained in:
James
2022-09-11 18:07:05 -07:00
245 changed files with 4708 additions and 2347 deletions

View File

@@ -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, {

View File

@@ -208,6 +208,7 @@ const Routes = () => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<DocumentInfoProvider
key={`${collection.slug}-edit-${id}`}
collection={collection}
id={id}
>

View File

@@ -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) {

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -1,3 +1,7 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type Props = {
slug: string,
slug: string
collection: SanitizedCollectionConfig
id: string
}

View File

@@ -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

View File

@@ -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`}

View File

@@ -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 {

View File

@@ -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}
/>
);
};

View File

@@ -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,
}

View File

@@ -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) {

View File

@@ -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>
&nbsp;&mdash;&nbsp;
<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>
&nbsp;&mdash;&nbsp;
<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>

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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]);

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 && (

View File

@@ -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) => {

View File

@@ -15,7 +15,6 @@ export type Option = {
type CLEAR = {
type: 'CLEAR'
required: boolean
}
type ADD = {

View File

@@ -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);

View File

@@ -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),

View File

@@ -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>
);
};

View File

@@ -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&nbsp;
<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>
);
};

View File

@@ -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',
},
];

View File

@@ -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);
}
}
}

View File

@@ -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>
);
};

View File

@@ -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[]
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -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,
],

View File

@@ -0,0 +1 @@
export const modalSlug = 'rich-text-link-modal';

View File

@@ -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' });
}
};

View File

@@ -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);
}}
>

View File

@@ -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,

View File

@@ -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`}>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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),
}))}
/>
);

View File

@@ -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

View File

@@ -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}
/>
);

View File

@@ -20,6 +20,7 @@
}
.tabs-field__tabs {
&:before,
&:after {
content: ' ';
@@ -111,4 +112,4 @@
}
}
}
}
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}
/>
);
};

View File

@@ -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>
}

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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`}>

View 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 : ''}`;
};

View File

@@ -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 = {

View File

@@ -13,7 +13,7 @@ const useField = <T extends unknown>(options: Options): FieldType<T> => {
path,
validate,
enableDebouncedValue,
disableFormData,
disableFormData = false,
condition,
} = options;

View File

@@ -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

View File

@@ -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);
}, []);

View File

@@ -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>
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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`} />

View File

@@ -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) {

View File

@@ -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 });

View File

@@ -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;

View File

@@ -35,6 +35,7 @@ const Index = () => (
<ModalProvider
classPrefix="payload"
zIndex={50}
transTime={0}
>
<AuthProvider>
<PreferencesProvider>

View File

@@ -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);
}
}
}
}