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

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