renames client to admin, sets up component library

This commit is contained in:
James
2020-10-10 18:28:17 -04:00
parent e88be6b251
commit 84191ec8fd
397 changed files with 2042 additions and 579 deletions

View File

@@ -0,0 +1,125 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import Popup from '../../../elements/Popup';
import BlockSelector from '../../field-types/Blocks/BlockSelector';
import './index.scss';
const baseClass = 'action-panel';
const ActionPanel = (props) => {
const {
addRow,
removeRow,
label,
blockType,
blocks,
rowIndex,
isHovered,
} = props;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__remove-row`}
round
buttonStyle="none"
icon="x"
iconPosition="left"
iconStyle="with-border"
onClick={removeRow}
/>
)}
>
Remove&nbsp;
{label}
</Popup>
{blockType === 'blocks'
? (
<Popup
buttonType="custom"
size="large"
horizontalAlign="center"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
/>
)}
render={({ close }) => (
<BlockSelector
blocks={blocks}
addRow={addRow}
addRowIndex={rowIndex}
close={close}
parentIsHovered={isHovered}
watchParentHover
/>
)}
/>
)
: (
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={addRow}
/>
)}
>
Add&nbsp;
{label}
</Popup>
)}
</div>
);
};
ActionPanel.defaultProps = {
label: 'Row',
blockType: null,
isHovered: false,
blocks: [],
};
ActionPanel.propTypes = {
label: PropTypes.string,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
blockType: PropTypes.oneOf(['blocks', 'array']),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
),
isHovered: PropTypes.bool,
rowIndex: PropTypes.number.isRequired,
};
export default ActionPanel;

View File

@@ -0,0 +1,12 @@
@import '../../../../scss/styles';
.action-panel {
&__remove-row {
margin: 0 0 base(.3);
}
&__add-row {
margin: base(.3) 0 0;
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import './index.scss';
const baseClass = 'position-panel';
const PositionPanel = (props) => {
const { moveRow, positionIndex, rowCount } = props;
const adjustedIndex = positionIndex + 1;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Button
className={`${baseClass}__move-backward ${positionIndex === 0 ? 'first-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex - 1)}
/>
{(adjustedIndex && typeof positionIndex === 'number')
&& <div className={`${baseClass}__current-position`}>{adjustedIndex >= 10 ? adjustedIndex : `0${adjustedIndex}`}</div>}
<Button
className={`${baseClass}__move-forward ${(positionIndex === rowCount - 1) ? 'last-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex + 1)}
/>
</div>
);
};
PositionPanel.defaultProps = {
positionIndex: null,
};
PositionPanel.propTypes = {
positionIndex: PropTypes.number,
moveRow: PropTypes.func.isRequired,
rowCount: PropTypes.number.isRequired,
};
export default PositionPanel;

View File

@@ -0,0 +1,27 @@
@import '../../../../scss/styles';
.position-panel {
&__move-backward {
transform: rotate(.5turn);
margin: 0;
opacity: 0;
}
&__move-forward {
margin: 0;
opacity: 0;
}
&__current-position {
text-align: center;
color: $color-gray;
}
@include large-break {
padding-right: base(.5);
&__controls {
padding-right: base(.75);
}
}
}

View File

@@ -0,0 +1,63 @@
import React, { useRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../../useFieldType';
import './index.scss';
const baseClass = 'editable-block-title';
const EditableBlockTitle = (props) => {
const { path } = props;
const inputRef = useRef(null);
const inputCloneRef = useRef(null);
const [inputWidth, setInputWidth] = useState(0);
const {
value,
setValue,
} = useFieldType({
path,
});
useEffect(() => {
setInputWidth(inputCloneRef.current.offsetWidth + 5);
}, [value]);
const onKeyDown = (e) => {
const blurKeys = [13, 27];
if (blurKeys.indexOf(e.keyCode) !== -1) inputRef.current.blur();
};
return (
<React.Fragment>
<div className={baseClass}>
<input
ref={inputRef}
id={path}
value={value || ''}
placeholder="Untitled"
type="text"
name={path}
onChange={setValue}
onKeyDown={onKeyDown}
style={{
width: `${inputWidth + 1}px`,
}}
/>
</div>
<span
ref={inputCloneRef}
className={`${baseClass}__input-clone`}
>
{value || 'Untitled'}
</span>
</React.Fragment>
);
};
EditableBlockTitle.propTypes = {
path: PropTypes.string.isRequired,
};
export default EditableBlockTitle;

View File

@@ -0,0 +1,46 @@
@import '../../../../../scss/styles.scss';
.editable-block-title {
position: relative;
z-index: 1;
display: flex;
max-width: 100%;
overflow-x: auto;
&__input-clone {
position: absolute;
top: 0; left: 0;
visibility: hidden;
white-space: pre;
}
&__input-clone,
input {
padding: base(.1) base(.2);
font-family: $font-body;
font-weight: 600;
margin-right: base(.5);
font-size: base(.75);
color: $color-dark-gray
}
input {
border: none;
width: 100%;
margin-left: base(.5);
background-color: transparent;
&:hover {
box-shadow: inset 0px -2px 0px -1px $color-light-gray;
}
&:hover,
&:focus {
outline: 0;
}
&:focus {
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import EditableBlockTitle from './EditableBlockTitle';
import Pill from '../../../elements/Pill';
import './index.scss';
const baseClass = 'section-title';
const SectionTitle = (props) => {
const { label, ...remainingProps } = props;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Pill pillStyle="light-gray">{label}</Pill>
<EditableBlockTitle {...remainingProps} />
</div>
);
};
SectionTitle.defaultProps = {
label: '',
};
SectionTitle.propTypes = {
label: PropTypes.string,
};
export default SectionTitle;

View File

@@ -0,0 +1,6 @@
@import '../../../../scss/styles';
.section-title {
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import { Draggable } from 'react-beautiful-dnd';
import ActionPanel from './ActionPanel';
import SectionTitle from './SectionTitle';
import PositionPanel from './PositionPanel';
import Button from '../../elements/Button';
import { NegativeFieldGutterProvider } from '../FieldTypeGutter/context';
import FieldTypeGutter from '../FieldTypeGutter';
import RenderFields from '../RenderFields';
import './index.scss';
const baseClass = 'draggable-section';
const DraggableSection = (props) => {
const {
moveRow,
addRow,
removeRow,
rowIndex,
rowCount,
parentPath,
fieldSchema,
label,
blockType,
fieldTypes,
toggleRowCollapse,
id,
positionPanelVerticalAlignment,
actionPanelVerticalAlignment,
permissions,
isOpen,
readOnly,
} = props;
const [isHovered, setIsHovered] = useState(false);
const classes = [
baseClass,
isOpen ? 'is-open' : 'is-closed',
isHovered && 'is-hovered',
].filter(Boolean).join(' ');
return (
<Draggable
draggableId={id}
index={rowIndex}
isDropDisabled={readOnly}
>
{(providedDrag) => (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
<div className={`${baseClass}__content-wrapper`}>
<FieldTypeGutter
variant="left"
dragHandleProps={providedDrag.dragHandleProps}
>
<PositionPanel
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
</FieldTypeGutter>
<div className={`${baseClass}__render-fields-wrapper`}>
{blockType === 'blocks' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={label}
path={`${parentPath}.${rowIndex}.blockName`}
readOnly={readOnly}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<NegativeFieldGutterProvider allow={false}>
<RenderFields
readOnly={readOnly}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
}))}
/>
</NegativeFieldGutterProvider>
</AnimateHeight>
</div>
<FieldTypeGutter
variant="right"
className="actions"
dragHandleProps={providedDrag.dragHandleProps}
>
{!readOnly && (
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
label={label}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
)}
</FieldTypeGutter>
</div>
</div>
)}
</Draggable>
);
};
DraggableSection.defaultProps = {
toggleRowCollapse: undefined,
rowCount: null,
initialData: undefined,
label: '',
blockType: '',
isOpen: true,
positionPanelVerticalAlignment: 'sticky',
actionPanelVerticalAlignment: 'sticky',
permissions: {},
readOnly: false,
};
DraggableSection.propTypes = {
moveRow: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
toggleRowCollapse: PropTypes.func,
rowIndex: PropTypes.number.isRequired,
parentPath: PropTypes.string.isRequired,
label: PropTypes.string,
fieldSchema: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
rowCount: PropTypes.number,
initialData: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]),
isOpen: PropTypes.bool,
blockType: PropTypes.string,
fieldTypes: PropTypes.shape({}).isRequired,
id: PropTypes.string.isRequired,
positionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
actionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
permissions: PropTypes.shape({}),
readOnly: PropTypes.bool,
};
export default DraggableSection;

View File

@@ -0,0 +1,111 @@
@import '../../../scss/styles.scss';
//////////////////////
// COMPONENT STYLES
//////////////////////
.draggable-section {
padding-bottom: base(.5);
.draggable-section {
padding-bottom: 0;
}
&__content-wrapper {
display: flex;
position: relative;
margin-bottom: $baseline;
}
&__section-header {
@include blur-bg(white);
display: flex;
position: sticky;
top: $top-header-offset;
z-index: 1;
padding: base(.75) base(.75);
margin-left: - base(.75);
margin-right: - base(.75);
width: calc(100% + #{base(1.5)});
.toggle-collapse {
margin: 0 0 0 auto;
transform: rotate(.5turn);
.btn__icon {
background-color: white;
}
&--is-closed {
transform: rotate(0turn);
}
}
}
&__render-fields-wrapper {
width: 100%;
}
&.is-hovered > div {
> .field-type-gutter {
&.actions {
.field-type-gutter__content {
&:hover {
z-index: $z-nav;
}
}
.field-type-gutter__content-container {
box-shadow: none;
}
}
.field-type-gutter__content-container {
box-shadow: #{$style-stroke-width-m} 0px 0px 0px $color-dark-gray;
}
.position-panel__move-forward,
.position-panel__move-backward {
opacity: 1;
&.first-row,
&.last-row {
opacity: .15;
pointer-events: none;
}
}
.position-panel__current-position {
color: $color-dark-gray;
}
}
.toggle-collapse {
@include color-svg(white);
.btn__icon {
background-color: $color-gray;
&:hover {
background-color: $color-dark-gray;
}
}
}
}
label.field-label {
line-height: 1;
padding-bottom: base(.75)
}
@include mid-break {
.position-panel__move-forward,
.position-panel__move-backward {
opacity: 1;
}
&__section-header {
top: $top-header-offset-m;
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import Tooltip from '../../elements/Tooltip';
import './index.scss';
const Error = (props) => {
const { showError, message } = props;
if (showError) {
return (
<Tooltip className="error-message">
{message}
</Tooltip>
);
}
return null;
};
Error.defaultProps = {
showError: false,
message: 'Please complete this field.',
};
Error.propTypes = {
showError: PropTypes.bool,
message: PropTypes.string,
};
export default Error;

View File

@@ -0,0 +1,13 @@
@import '../../../scss/styles';
.error-message {
left: auto;
right: base(.5);
transform: none;
background-color: $color-red;
span {
border-top-color: $color-red;
}
}

View File

@@ -0,0 +1,27 @@
import React, { createContext, useContext } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@faceless-ui/window-info';
const context = createContext(false);
const { Provider } = context;
export const NegativeFieldGutterProvider = ({ children, allow }) => {
const { breakpoints: { m: midBreak } } = useWindowInfo();
return (
<Provider value={allow && !midBreak}>
{children}
</Provider>
);
};
export const useNegativeFieldGutter = () => useContext(context);
NegativeFieldGutterProvider.defaultProps = {
allow: false,
};
NegativeFieldGutterProvider.propTypes = {
children: PropTypes.node.isRequired,
allow: PropTypes.bool,
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useNegativeFieldGutter } from './context';
import './index.scss';
const baseClass = 'field-type-gutter';
const FieldTypeGutter = (props) => {
const { children, variant, verticalAlignment, className, dragHandleProps } = props;
const allowNegativeGutter = useNegativeFieldGutter();
const classes = [
baseClass,
`${baseClass}--${variant}`,
`${baseClass}--v-align-${verticalAlignment}`,
allowNegativeGutter && `${baseClass}--negative-gutter`,
className && className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
{...dragHandleProps}
>
<div className={`${baseClass}__content-container`}>
<div className={`${baseClass}__content`}>
{children}
</div>
</div>
</div>
);
};
const { oneOf, shape, string, node } = PropTypes;
FieldTypeGutter.defaultProps = {
variant: 'left',
verticalAlignment: 'sticky',
dragHandleProps: {},
className: null,
children: null,
};
FieldTypeGutter.propTypes = {
variant: oneOf(['left', 'right']),
verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
dragHandleProps: shape({}),
className: string,
children: node,
};
export default FieldTypeGutter;

View File

@@ -0,0 +1,85 @@
@import '../../../scss/styles.scss';
$controls-top-adjustment: base(.1);
@mixin nestedStickyOffsets ($loopCount, $currentCount: 0) {
.field-type {
@if $loopCount > $currentCount {
.field-type-gutter--v-align-sticky .field-type-gutter__content {
top: calc(#{$top-header-offset} + (#{base(2.75)} * #{$currentCount}));
}
@include nestedStickyOffsets($loopCount, $currentCount + 1);
}
}
}
@include nestedStickyOffsets(4);
.field-type-gutter {
&--left {
margin-right: base(1.25);
}
&--right {
padding-right: 0;
padding-left: base(1.25);
.field-type-gutter__content {
margin-bottom: base(1);
}
.field-type-gutter__content-container {
padding-right: 0;
box-shadow: none !important;
}
}
&--v-align-top {
.field-type-gutter__content {
justify-content: flex-start;
}
}
&--v-align-sticky {
.field-type-gutter__content {
position: sticky;
top: $top-header-offset;
height: unset;
}
}
&__content-container {
padding-right: base(.75);
position: relative;
min-height: 100%;
box-shadow: #{$style-stroke-width-s} 0px 0px 0px $color-light-gray;
}
&__content {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
&--negative-gutter {
&.field-type-gutter--left {
position: absolute;
top: 0; right: 100%; bottom: 0;
}
}
@include mid-break {
&--left {
.field-type-gutter__content-container {
padding-right: $style-stroke-width-m;
padding-left: 0;
}
}
&--right {
padding-right: 0;
}
}
}

View File

@@ -0,0 +1,16 @@
const buildInitialState = (data) => {
if (data) {
return Object.entries(data).reduce((state, [path, value]) => ({
...state,
[path]: {
value,
initialValue: value,
valid: true,
},
}), {});
}
return undefined;
};
export default buildInitialState;

View File

@@ -0,0 +1,120 @@
const buildValidationPromise = async (fieldState, field) => {
const validatedFieldState = fieldState;
validatedFieldState.valid = typeof field.validate === 'function' ? await field.validate(fieldState.value, field) : true;
if (typeof validatedFieldState.valid === 'string') {
validatedFieldState.errorMessage = validatedFieldState.valid;
validatedFieldState.valid = false;
}
};
const buildStateFromSchema = async (fieldSchema, fullData = {}) => {
if (fieldSchema) {
const validationPromises = [];
const structureFieldState = (field, data = {}) => {
const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue;
const fieldState = {
value,
initialValue: value,
};
validationPromises.push(buildValidationPromise(fieldState, field));
return fieldState;
};
const iterateFields = (fields, data, path = '') => fields.reduce((state, field) => {
let initialData = data;
if (field.name && field.defaultValue && typeof initialData?.[field.name] === 'undefined') {
initialData = { [field.name]: field.defaultValue };
}
if (field.name) {
if (field.type === 'relationship' && initialData?.[field.name] === null) {
initialData[field.name] = 'null';
}
if (field.type === 'array' || field.type === 'blocks') {
if (Array.isArray(initialData?.[field.name])) {
if (field.type === 'array') {
return {
...state,
...initialData[field.name].reduce((rowState, row, i) => ({
...rowState,
...iterateFields(field.fields, row, `${path}${field.name}.${i}.`),
}), {}),
};
}
if (field.type === 'blocks') {
return {
...state,
...initialData[field.name].reduce((rowState, row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
const rowPath = `${path}${field.name}.${i}.`;
return {
...rowState,
[`${rowPath}blockType`]: {
value: row.blockType,
initialValue: row.blockType,
valid: true,
},
[`${rowPath}blockName`]: {
value: row.blockName,
initialValue: row.blockName,
valid: true,
},
...(block?.fields ? iterateFields(block.fields, row, rowPath) : {}),
};
}, {}),
};
}
}
return state;
}
// Handle non-array-based nested fields (group, etc)
if (field.fields) {
return {
...state,
...iterateFields(field.fields, initialData?.[field.name], `${path}${field.name}.`),
};
}
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}
// Handle field types that do not use names (row, etc)
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data, path),
};
}
// Handle normal fields
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}, {});
const resultingState = iterateFields(fieldSchema, fullData);
await Promise.all(validationPromises);
return resultingState;
}
return {};
};
export default buildStateFromSchema;

View File

@@ -0,0 +1,17 @@
import buildStateFromSchema from './buildStateFromSchema';
describe('Form - buildStateFromSchema', () => {
it('populates default value - normal fields', async () => {
const defaultValue = 'Default';
const fieldSchema = [
{
name: 'text',
type: 'text',
label: 'Text',
defaultValue,
},
];
const state = await buildStateFromSchema(fieldSchema, {});
expect(state.text.value).toBe(defaultValue);
});
});

View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from 'react';
const FormContext = createContext({});
const FieldContext = createContext({});
const SubmittedContext = createContext(false);
const ProcessingContext = createContext(false);
const ModifiedContext = createContext(false);
const useForm = () => useContext(FormContext);
const useFormFields = () => useContext(FieldContext);
const useFormSubmitted = () => useContext(SubmittedContext);
const useFormProcessing = () => useContext(ProcessingContext);
const useFormModified = () => useContext(ModifiedContext);
export {
FormContext,
FieldContext,
SubmittedContext,
ProcessingContext,
ModifiedContext,
useForm,
useFormFields,
useFormSubmitted,
useFormProcessing,
useFormModified,
};

View File

@@ -0,0 +1,3 @@
export default {
413: 'Your request was too large to submit successfully.',
};

View File

@@ -0,0 +1,131 @@
import { unflatten, flatten } from 'flatley';
import flattenFilters from './flattenFilters';
const unflattenRowsFromState = (state, path) => {
// Take a copy of state
const remainingFlattenedState = { ...state };
const rowsFromStateObject = {};
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
// Loop over all keys from state
// If the key begins with the name of the parent field,
// Add value to rowsFromStateObject and delete it from remaining state
Object.keys(state).forEach((key) => {
if (key.indexOf(`${path}.`) === 0) {
if (!state[key].ignoreWhileFlattening) {
const name = key.replace(pathPrefixToRemove, '');
rowsFromStateObject[name] = state[key];
rowsFromStateObject[name].initialValue = rowsFromStateObject[name].value;
}
delete remainingFlattenedState[key];
}
});
const unflattenedRows = unflatten(rowsFromStateObject);
return {
unflattenedRows: unflattenedRows[path.replace(pathPrefixToRemove, '')] || [],
remainingFlattenedState,
};
};
function fieldReducer(state, action) {
switch (action.type) {
case 'REPLACE_STATE': {
return action.state;
}
case 'REMOVE': {
const newState = { ...state };
delete newState[action.path];
return newState;
}
case 'REMOVE_ROW': {
const { rowIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
unflattenedRows.splice(rowIndex, 1);
const flattenedRowState = unflattenedRows.length > 0 ? flatten({ [path]: unflattenedRows }, { filters: flattenFilters }) : {};
return {
...remainingFlattenedState,
...flattenedRowState,
};
}
case 'ADD_ROW': {
const {
rowIndex, path, subFieldState, blockType,
} = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
if (blockType) {
subFieldState.blockType = {
value: blockType,
initialValue: blockType,
valid: true,
};
subFieldState.blockName = {
value: null,
initialValue: null,
valid: true,
};
}
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, subFieldState);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
};
return newState;
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
// copy the row to move
const copyOfMovingRow = unflattenedRows[moveFromIndex];
// delete the row by index
unflattenedRows.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
unflattenedRows.splice(moveToIndex, 0, copyOfMovingRow);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
};
return newState;
}
default: {
const newField = {
value: action.value,
valid: action.valid,
errorMessage: action.errorMessage,
disableFormData: action.disableFormData,
ignoreWhileFlattening: action.ignoreWhileFlattening,
initialValue: action.initialValue,
stringify: action.stringify,
validate: action.validate,
};
return {
...state,
[action.path]: newField,
};
}
}
}
export default fieldReducer;

View File

@@ -0,0 +1,10 @@
const flattenFilters = [{
test: (_, value) => {
const hasValidProperty = Object.prototype.hasOwnProperty.call(value, 'valid');
const hasValueProperty = Object.prototype.hasOwnProperty.call(value, 'value');
return (hasValidProperty && hasValueProperty);
},
}];
export default flattenFilters;

View File

@@ -0,0 +1,24 @@
import { unflatten } from 'flatley';
import reduceFieldsToValues from './reduceFieldsToValues';
const getDataByPath = (fields, path) => {
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
const name = path.split('.').pop();
const data = Object.keys(fields).reduce((matchedData, key) => {
if (key.indexOf(`${path}.`) === 0) {
return {
...matchedData,
[key.replace(pathPrefixToRemove, '')]: fields[key],
};
}
return matchedData;
}, {});
const values = reduceFieldsToValues(data, true);
const unflattenedData = unflatten(values);
return unflattenedData?.[name];
};
export default getDataByPath;

View File

@@ -0,0 +1,25 @@
import reduceFieldsToValues from './reduceFieldsToValues';
const getSiblingData = (fields, path) => {
let siblingFields = fields;
// If this field is nested
// We can provide a list of sibling fields
if (path.indexOf('.') > 0) {
const parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1);
siblingFields = Object.keys(fields).reduce((siblings, fieldKey) => {
if (fieldKey.indexOf(parentFieldPath) === 0) {
return {
...siblings,
[fieldKey.replace(parentFieldPath, '')]: fields[fieldKey],
};
}
return siblings;
}, {});
}
return reduceFieldsToValues(siblingFields, true);
};
export default getSiblingData;

View File

@@ -0,0 +1,398 @@
import React, {
useReducer, useEffect, useRef, useState, useCallback,
} from 'react';
import { objectToFormData } from 'object-to-formdata';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useLocale } from '../../utilities/Locale';
import { useStatusList } from '../../elements/Status';
import { requests } from '../../../api';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import { useAuthentication } from '../../providers/Authentication';
import fieldReducer from './fieldReducer';
import initContextState from './initContextState';
import reduceFieldsToValues from './reduceFieldsToValues';
import getSiblingDataFunc from './getSiblingData';
import getDataByPathFunc from './getDataByPath';
import wait from '../../../../utilities/wait';
import buildInitialState from './buildInitialState';
import errorMessages from './errorMessages';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FieldContext } from './context';
import './index.scss';
const baseClass = 'form';
const Form = (props) => {
const {
disabled,
onSubmit,
method,
action,
handleResponse,
onSuccess,
children,
className,
redirect,
disableSuccessStatus,
initialState, // fully formed initial field state
initialData, // values only, paths are required as key - form should build initial state as convenience
disableScrollOnSuccess,
waitForAutocomplete,
log,
} = props;
const history = useHistory();
const locale = useLocale();
const { replaceStatus, addStatus, clearStatus } = useStatusList();
const { refreshCookie } = useAuthentication();
const [modified, setModified] = useState(false);
const [processing, setProcessing] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [formattedInitialData, setFormattedInitialData] = useState(buildInitialState(initialData));
const contextRef = useRef({ ...initContextState });
let initialFieldState = {};
if (formattedInitialData) initialFieldState = formattedInitialData;
if (initialState) initialFieldState = initialState;
// Allow access to initialState for field types such as Blocks and Array
contextRef.current.initialState = initialState;
const [fields, dispatchFields] = useReducer(fieldReducer, {}, () => initialFieldState);
contextRef.current.fields = fields;
const validateForm = useCallback(async () => {
const validatedFieldState = {};
let isValid = true;
const validationPromises = Object.entries(contextRef.current.fields).map(async ([path, field]) => {
const validatedField = { ...field };
validatedField.valid = typeof field.validate === 'function' ? await field.validate(field.value) : true;
if (typeof validatedField.valid === 'string') {
validatedField.errorMessage = validatedField.valid;
validatedField.valid = false;
isValid = false;
}
validatedFieldState[path] = validatedField;
});
await Promise.all(validationPromises);
dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState });
return isValid;
}, [contextRef]);
const submit = useCallback(async (e) => {
if (disabled) {
e.preventDefault();
return false;
}
e.stopPropagation();
e.preventDefault();
setProcessing(true);
if (waitForAutocomplete) await wait(100);
const isValid = await contextRef.current.validateForm();
setSubmitted(true);
// If not valid, prevent submission
if (!isValid) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
if (!disableScrollOnSuccess) {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}
return false;
}
// If submit handler comes through via props, run that
if (onSubmit) {
return onSubmit(fields, reduceFieldsToValues(fields));
}
if (!disableScrollOnSuccess) {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}
const formData = contextRef.current.createFormData();
try {
const res = await requests[method.toLowerCase()](action, {
body: formData,
});
setModified(false);
if (typeof handleResponse === 'function') return handleResponse(res);
setProcessing(false);
clearStatus();
const contentType = res.headers.get('content-type');
const isJSON = contentType && contentType.indexOf('application/json') !== -1;
let json = {};
if (isJSON) json = await res.json();
if (res.status < 400) {
setSubmitted(false);
if (typeof onSuccess === 'function') onSuccess(json);
if (redirect) {
const destination = {
pathname: redirect,
};
if (json.message && !disableSuccessStatus) {
destination.state = {
status: [
{
message: json.message,
type: 'success',
},
],
};
}
history.push(destination);
} else if (!disableSuccessStatus) {
replaceStatus([{
message: json.message || 'Submission successful.',
type: 'success',
disappear: 3000,
}]);
}
} else {
contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form
if (json.message) {
addStatus({
message: json.message,
type: 'error',
});
return json;
}
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(([fieldErrs, nonFieldErrs], err) => (err.field && err.message ? [[...fieldErrs, err], nonFieldErrs] : [fieldErrs, [...nonFieldErrs, err]]), [[], []]);
fieldErrors.forEach((err) => {
dispatchFields({
...(contextRef.current?.fields?.[err.field] || {}),
valid: false,
errorMessage: err.message,
path: err.field,
});
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
});
if (fieldErrors.length > 0 && nonFieldErrors.length === 0) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
}
return json;
}
const message = errorMessages[res.status] || 'An unknown error occurrred.';
addStatus({
message,
type: 'error',
});
}
return json;
} catch (err) {
setProcessing(false);
return addStatus({
message: err,
type: 'error',
});
}
}, [
action,
addStatus,
clearStatus,
disableSuccessStatus,
disabled,
fields,
handleResponse,
history,
method,
onSubmit,
onSuccess,
redirect,
replaceStatus,
disableScrollOnSuccess,
waitForAutocomplete,
]);
const getFields = useCallback(() => contextRef.current.fields, [contextRef]);
const getField = useCallback((path) => contextRef.current.fields[path], [contextRef]);
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
const getSiblingData = useCallback((path) => getSiblingDataFunc(contextRef.current.fields, path), [contextRef]);
const getDataByPath = useCallback((path) => getDataByPathFunc(contextRef.current.fields, path), [contextRef]);
const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]);
const createFormData = useCallback(() => {
const data = reduceFieldsToValues(contextRef.current.fields);
// nullAsUndefineds is important to allow uploads and relationship fields to clear themselves
const formData = objectToFormData(data, { indices: true, nullsAsUndefineds: false });
return formData;
}, [contextRef]);
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = submit;
contextRef.current.getFields = getFields;
contextRef.current.getField = getField;
contextRef.current.getData = getData;
contextRef.current.getSiblingData = getSiblingData;
contextRef.current.getDataByPath = getDataByPath;
contextRef.current.getUnflattenedValues = getUnflattenedValues;
contextRef.current.validateForm = validateForm;
contextRef.current.createFormData = createFormData;
contextRef.current.setModified = setModified;
contextRef.current.setProcessing = setProcessing;
contextRef.current.setSubmitted = setSubmitted;
contextRef.current.disabled = disabled;
useEffect(() => {
if (initialState) {
contextRef.current = { ...initContextState };
dispatchFields({ type: 'REPLACE_STATE', state: initialState });
}
}, [initialState]);
useEffect(() => {
if (initialData) {
contextRef.current = { ...initContextState };
const builtState = buildInitialState(initialData);
setFormattedInitialData(builtState);
dispatchFields({ type: 'REPLACE_STATE', state: builtState });
}
}, [initialData]);
useThrottledEffect(() => {
refreshCookie();
}, 15000, [fields]);
useEffect(() => {
contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form
setModified(false);
}, [locale]);
const classes = [
className,
baseClass,
].filter(Boolean).join(' ');
if (log) {
// eslint-disable-next-line no-console
console.log(fields);
}
return (
<form
noValidate
onSubmit={contextRef.current.submit}
method={method}
action={action}
className={classes}
>
<FormContext.Provider value={contextRef.current}>
<FieldContext.Provider value={{
fields,
...contextRef.current,
}}
>
<SubmittedContext.Provider value={submitted}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
{children}
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</SubmittedContext.Provider>
</FieldContext.Provider>
</FormContext.Provider>
</form>
);
};
Form.defaultProps = {
redirect: '',
onSubmit: null,
method: 'POST',
action: '',
handleResponse: null,
onSuccess: null,
className: '',
disableSuccessStatus: false,
disabled: false,
initialState: undefined,
disableScrollOnSuccess: false,
waitForAutocomplete: false,
initialData: undefined,
log: false,
};
Form.propTypes = {
disableSuccessStatus: PropTypes.bool,
onSubmit: PropTypes.func,
method: PropTypes.oneOf(['post', 'POST', 'get', 'GET', 'put', 'PUT', 'delete', 'DELETE']),
action: PropTypes.string,
handleResponse: PropTypes.func,
onSuccess: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
className: PropTypes.string,
redirect: PropTypes.string,
disabled: PropTypes.bool,
initialState: PropTypes.shape({}),
disableScrollOnSuccess: PropTypes.bool,
waitForAutocomplete: PropTypes.bool,
initialData: PropTypes.shape({}),
log: PropTypes.bool,
};
export default Form;

View File

@@ -0,0 +1,5 @@
.form {
> * {
width: 100%;
}
}

View File

@@ -0,0 +1,15 @@
export default {
getFields: () => { },
getField: () => { },
getData: () => { },
getSiblingData: () => { },
getDataByPath: () => undefined,
getUnflattenedValues: () => { },
validateForm: () => { },
createFormData: () => { },
submit: () => { },
dispatchFields: () => { },
setModified: () => { },
initialState: {},
reset: 0,
};

View File

@@ -0,0 +1,24 @@
import { unflatten } from 'flatley';
const reduceFieldsToValues = (fields, flatten) => {
const data = {};
Object.keys(fields).forEach((key) => {
if (!fields[key].disableFormData && fields[key].value !== undefined) {
if (fields[key].stringify) {
data[key] = JSON.stringify(fields[key].value);
} else {
data[key] = fields[key].value;
}
}
});
if (flatten) {
const unflattened = unflatten(data, { safe: true });
return unflattened;
}
return data;
};
export default reduceFieldsToValues;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const Label = (props) => {
const {
label, required, htmlFor,
} = props;
if (label) {
return (
<label
htmlFor={htmlFor}
className="field-label"
>
{label}
{required && <span className="required">*</span>}
</label>
);
}
return null;
};
Label.defaultProps = {
required: false,
label: '',
};
Label.propTypes = {
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
]),
htmlFor: PropTypes.string.isRequired,
required: PropTypes.bool,
};
export default Label;

View File

@@ -0,0 +1,13 @@
@import '../../../scss/styles.scss';
label.field-label {
display: flex;
padding-bottom: base(.25);
color: $color-gray;
.required {
color: $color-red;
margin-left: base(.25);
margin-right: auto;
}
}

View File

@@ -0,0 +1,160 @@
import React, { createContext, useEffect, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import useIntersect from '../../../hooks/useIntersect';
const baseClass = 'render-fields';
const intersectionObserverOptions = {
rootMargin: '1000px',
};
const RenderedFieldContext = createContext({});
export const useRenderedFields = () => useContext(RenderedFieldContext);
const RenderFields = (props) => {
const {
fieldSchema,
fieldTypes,
filter,
permissions,
readOnly: readOnlyOverride,
operation: operationFromProps,
className,
} = props;
const [hasRendered, setHasRendered] = useState(false);
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions);
const isIntersecting = Boolean(entry?.isIntersecting);
const isAboveViewport = entry?.boundingClientRect?.top < 0;
const shouldRender = isIntersecting || isAboveViewport;
const { operation: operationFromContext } = useRenderedFields();
const operation = operationFromProps || operationFromContext;
const [contextValue, setContextValue] = useState({
operation,
});
useEffect(() => {
setContextValue({
operation,
});
}, [operation]);
useEffect(() => {
if (shouldRender && !hasRendered) {
setHasRendered(true);
}
}, [shouldRender, hasRendered]);
const classes = [
baseClass,
className,
].filter(Boolean).join(' ');
if (fieldSchema) {
return (
<div
ref={intersectionRef}
className={classes}
>
{hasRendered && (
<RenderedFieldContext.Provider value={contextValue}>
{fieldSchema.map((field, i) => {
if (!field?.hidden && field?.admin?.disabled !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.admin?.hidden ? fieldTypes.hidden : fieldTypes[field.type];
let fieldPermissions = permissions[field.name];
if (!field.name) {
fieldPermissions = permissions;
}
let { admin: { readOnly } = {} } = field;
if (readOnlyOverride) readOnly = true;
if (operation === 'create') readOnly = false;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
}
if (FieldComponent) {
return (
<RenderCustomComponent
key={i}
CustomComponent={field?.admin?.components?.Field}
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
fieldTypes,
admin: {
...(field.admin || {}),
readOnly,
},
permissions: fieldPermissions,
}}
/>
);
}
return (
<div
className="missing-field"
key={i}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
</div>
);
}
}
return null;
}
return null;
})}
</RenderedFieldContext.Provider>
)}
</div>
);
}
return null;
};
RenderFields.defaultProps = {
filter: null,
readOnly: false,
permissions: {},
operation: undefined,
className: undefined,
};
RenderFields.propTypes = {
fieldSchema: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
fieldTypes: PropTypes.shape({
hidden: PropTypes.function,
}).isRequired,
filter: PropTypes.func,
permissions: PropTypes.shape({}),
readOnly: PropTypes.bool,
operation: PropTypes.string,
className: PropTypes.string,
};
export default RenderFields;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useForm, useFormProcessing } from '../Form/context';
import Button from '../../elements/Button';
import './index.scss';
const baseClass = 'form-submit';
const FormSubmit = ({ children }) => {
const processing = useFormProcessing();
const { disabled } = useForm();
return (
<div className={baseClass}>
<Button
type="submit"
disabled={processing || disabled ? true : undefined}
>
{children}
</Button>
</div>
);
};
FormSubmit.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
export default FormSubmit;

View File

@@ -0,0 +1,5 @@
form > .form-submit {
.btn {
width: 100%;
}
}

View File

@@ -0,0 +1,320 @@
import React, { useEffect, useReducer, useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import withCondition from '../../withCondition';
import Button from '../../../elements/Button';
import DraggableSection from '../../DraggableSection';
import reducer from '../rowReducer';
import { useForm } from '../../Form/context';
import buildStateFromSchema from '../../Form/buildStateFromSchema';
import useFieldType from '../../useFieldType';
import Error from '../../Error';
import { array } from '../../../../../fields/validations';
import getDataByPath from '../../Form/getDataByPath';
import Banner from '../../../elements/Banner';
import './index.scss';
const baseClass = 'field-type array';
const ArrayFieldType = (props) => {
const {
label,
name,
path: pathFromProps,
fields,
fieldTypes,
validate,
required,
maxRows,
minRows,
labels,
permissions,
admin: {
readOnly,
},
} = props;
const [rows, dispatchRows] = useReducer(reducer, []);
const { initialState, dispatchFields } = useForm();
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { minRows, maxRows, required });
return validationResult;
}, [validate, maxRows, minRows, required]);
const [disableFormData, setDisableFormData] = useState(false);
const {
showError,
errorMessage,
value,
setValue,
} = useFieldType({
path,
validate: memoizedValidate,
disableFormData,
ignoreWhileFlattening: true,
required,
});
const addRow = useCallback(async (rowIndex) => {
const subFieldState = await buildStateFromSchema(fields);
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
dispatchRows({ type: 'ADD', rowIndex });
setValue(value + 1);
}, [dispatchRows, dispatchFields, fields, path, setValue, value]);
const removeRow = useCallback((rowIndex) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
}, [dispatchRows, dispatchFields, path]);
const moveRow = useCallback((moveFromIndex, moveToIndex) => {
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]);
useEffect(() => {
const data = getDataByPath(initialState, path);
dispatchRows({ type: 'SET_ALL', data: data || [] });
}, [initialState, path]);
useEffect(() => {
setValue(rows?.length || 0);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
return (
<RenderArray
onDragEnd={onDragEnd}
label={label}
showError={showError}
errorMessage={errorMessage}
rows={rows}
labels={labels}
addRow={addRow}
removeRow={removeRow}
moveRow={moveRow}
path={path}
name={name}
fieldTypes={fieldTypes}
fields={fields}
permissions={permissions}
value={value}
readOnly={readOnly}
minRows={minRows}
maxRows={maxRows}
/>
);
};
ArrayFieldType.defaultProps = {
label: undefined,
validate: array,
required: false,
maxRows: undefined,
minRows: undefined,
labels: {
singular: 'Row',
plural: 'Rows',
},
permissions: {},
admin: {},
};
ArrayFieldType.propTypes = {
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
plural: PropTypes.string,
}),
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
}),
};
const RenderArray = React.memo((props) => {
const {
onDragEnd,
label,
showError,
errorMessage,
rows,
labels,
addRow,
removeRow,
moveRow,
path,
fieldTypes,
fields,
permissions,
value,
readOnly,
style,
width,
minRows,
maxRows,
} = props;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
className={baseClass}
style={{
...style,
width,
}}
>
<header className={`${baseClass}__header`}>
<h3>{label}</h3>
<Error
showError={showError}
message={errorMessage}
/>
</header>
<Droppable droppableId="array-drop">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => (
<DraggableSection
readOnly={readOnly}
key={row.key}
id={row.key}
blockType="array"
label={label}
isOpen={row.open}
rowCount={rows.length}
rowIndex={i}
addRow={() => addRow(i)}
removeRow={() => removeRow(i)}
moveRow={moveRow}
parentPath={path}
initNull={row.initNull}
fieldTypes={fieldTypes}
fieldSchema={fields}
permissions={permissions.fields}
/>
))}
{rows.length < minRows && (
<Banner type="error">
This field requires at least
{' '}
{minRows}
{' '}
{labels.plural}
.
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
This field has no
{' '}
{labels.plural}
.
</Banner>
)}
{provided.placeholder}
</div>
)}
</Droppable>
{(!readOnly && (rows.length < maxRows || maxRows === undefined)) && (
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={() => addRow(value)}
buttonStyle="icon-label"
icon="plus"
iconStyle="with-border"
iconPosition="left"
>
{`Add ${label}`}
</Button>
</div>
)}
</div>
</DragDropContext>
);
});
RenderArray.defaultProps = {
label: undefined,
showError: false,
errorMessage: undefined,
rows: [],
labels: {
singular: 'Row',
plural: 'Rows',
},
path: '',
value: undefined,
readOnly: false,
style: {},
width: undefined,
maxRows: undefined,
minRows: undefined,
};
RenderArray.propTypes = {
label: PropTypes.string,
showError: PropTypes.bool,
errorMessage: PropTypes.string,
rows: PropTypes.arrayOf(
PropTypes.shape({}),
),
labels: PropTypes.shape({
singular: PropTypes.string,
plural: PropTypes.string,
}),
path: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.number,
onDragEnd: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
moveRow: PropTypes.func.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}).isRequired,
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
maxRows: PropTypes.number,
minRows: PropTypes.number,
};
export default withCondition(ArrayFieldType);

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
const ArrayField = lazy(() => import('./Array'));
export default (props) => (
<Suspense fallback={<Loading />}>
<ArrayField {...props} />
</Suspense>
);

View File

@@ -0,0 +1,47 @@
@import '../../../../scss/styles.scss';
.field-type.array {
background: white;
margin: base(2) 0;
&__add-button-wrap {
margin-left: base(0);
.btn {
color: $color-gray;
margin: 0;
&:hover {
color: $color-dark-gray;
}
}
}
.section .section {
margin-top: 0;
}
.section__content {
> div > div {
width: 100%;
}
}
.render-fields {
.row {
&:last-child {
margin-bottom: 0;
}
}
}
}
.field-type.group,
.field-type.array,
.field-type.blocks {
.field-type.array {
.field-type.array__add-button-wrap {
margin-left: base(3);
}
}
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import SearchIcon from '../../../../../graphics/Search';
import './index.scss';
const baseClass = 'block-search';
const BlockSearch = (props) => {
const { setSearchTerm } = props;
const handleChange = (e) => {
setSearchTerm(e.target.value);
};
return (
<div className={baseClass}>
<input
className={`${baseClass}__input`}
placeholder="Search for a block"
onChange={handleChange}
/>
<SearchIcon />
</div>
);
};
BlockSearch.propTypes = {
setSearchTerm: PropTypes.func.isRequired,
};
export default BlockSearch;

View File

@@ -0,0 +1,30 @@
@import '../../../../../../scss/styles.scss';
$icon-width: base(1);
$icon-margin: base(.25);
.block-search {
position: sticky;
top: 0;
display: flex;
align-items: center;
z-index: 1;
&__input {
@include formInput;
padding-right: calc(#{$icon-width} + #{$icon-margin} * 2);
}
.search {
position: absolute;
right: 0;
width: $icon-width;
margin: 0 $icon-margin;
}
@include mid-break {
&__input {
margin-bottom: 0;
}
}
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import DefaultBlockImage from '../../../../../graphics/DefaultBlockImage';
import './index.scss';
const baseClass = 'block-selection';
const BlockSelection = (props) => {
const {
addRow, addRowIndex, block, close,
} = props;
const {
labels, slug, blockImage, blockImageAltText,
} = block;
const handleBlockSelection = () => {
close();
addRow(addRowIndex, slug);
};
return (
<button
className={baseClass}
tabIndex={0}
type="button"
onClick={handleBlockSelection}
>
<div className={`${baseClass}__image`}>
{blockImage
? (
<img
src={blockImage}
alt={blockImageAltText}
/>
)
: <DefaultBlockImage />}
</div>
<div className={`${baseClass}__label`}>{labels.singular}</div>
</button>
);
};
BlockSelection.defaultProps = {
addRow: undefined,
addRowIndex: 0,
};
BlockSelection.propTypes = {
addRow: PropTypes.func,
addRowIndex: PropTypes.number,
block: PropTypes.shape({
labels: PropTypes.shape({
singular: PropTypes.string,
}),
slug: PropTypes.string,
blockImage: PropTypes.string,
blockImageAltText: PropTypes.string,
}).isRequired,
close: PropTypes.func.isRequired,
};
export default BlockSelection;

View File

@@ -0,0 +1,37 @@
@import '../../../../../../scss/styles';
.block-selection {
display: inline-flex;
flex-direction: column;
flex-wrap: wrap;
width: 33%;
padding: base(.75) base(.5);
cursor: pointer;
align-items: center;
background: none;
border-radius: 0;
box-shadow: 0;
border: 0;
&:hover {
background-color: $color-background-gray;
}
&__image {
svg,
img {
max-width: 100%;
}
}
&__label {
margin-top: base(.25);
font-weight: 600;
text-align: center;
white-space: initial;
}
@include mid-break {
width: unset;
}
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import BlockSelection from '../BlockSelection';
import './index.scss';
const baseClass = 'blocks-container';
const BlocksContainer = (props) => {
const { blocks, ...remainingProps } = props;
return (
<div className={baseClass}>
{blocks?.map((block, index) => {
return (
<BlockSelection
key={index}
block={block}
{...remainingProps}
/>
);
})}
</div>
);
};
BlocksContainer.defaultProps = {
blocks: [],
};
BlocksContainer.propTypes = {
blocks: PropTypes.arrayOf(PropTypes.shape({})),
};
export default BlocksContainer;

View File

@@ -0,0 +1,14 @@
@import '../../../../../../scss/styles';
.blocks-container {
width: 100%;
margin-top: base(1);
margin-bottom: base(.5);
display: flex;
flex-wrap: wrap;
align-items: center;
min-width: 450px;
max-width: 80vw;
max-height: 300px;
position: relative;
}

View File

@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import BlockSearch from './BlockSearch';
import BlocksContainer from './BlocksContainer';
const baseClass = 'block-selector';
const BlockSelector = (props) => {
const {
blocks, close, parentIsHovered, watchParentHover, ...remainingProps
} = props;
const [searchTerm, setSearchTerm] = useState('');
const [filteredBlocks, setFilteredBlocks] = useState(blocks);
const [isBlockSelectorHovered, setBlockSelectorHovered] = useState(false);
useEffect(() => {
const matchingBlocks = blocks.reduce((matchedBlocks, block) => {
if (block.slug.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1) matchedBlocks.push(block);
return matchedBlocks;
}, []);
setFilteredBlocks(matchingBlocks);
}, [searchTerm, blocks]);
useEffect(() => {
if (!parentIsHovered && !isBlockSelectorHovered && close && watchParentHover) close();
}, [isBlockSelectorHovered, parentIsHovered, close, watchParentHover]);
return (
<div
className={baseClass}
onMouseEnter={() => setBlockSelectorHovered(true)}
onMouseLeave={() => setBlockSelectorHovered(false)}
>
<BlockSearch setSearchTerm={setSearchTerm} />
<BlocksContainer
blocks={filteredBlocks}
close={close}
{...remainingProps}
/>
</div>
);
};
BlockSelector.defaultProps = {
close: null,
parentIsHovered: false,
watchParentHover: false,
};
BlockSelector.propTypes = {
blocks: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
close: PropTypes.func,
watchParentHover: PropTypes.bool,
parentIsHovered: PropTypes.bool,
};
export default BlockSelector;

View File

@@ -0,0 +1,8 @@
@import '../../../../../scss/styles.scss';
.block-selector {
@include mid-break {
min-width: 80vw;
}
}

View File

@@ -0,0 +1,383 @@
import React, {
useEffect, useReducer, useCallback, useState,
} from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import withCondition from '../../withCondition';
import Button from '../../../elements/Button';
import reducer from '../rowReducer';
import { useForm } from '../../Form/context';
import buildStateFromSchema from '../../Form/buildStateFromSchema';
import DraggableSection from '../../DraggableSection';
import Error from '../../Error';
import useFieldType from '../../useFieldType';
import Popup from '../../../elements/Popup';
import BlockSelector from './BlockSelector';
import { blocks as blocksValidator } from '../../../../../fields/validations';
import getDataByPath from '../../Form/getDataByPath';
import Banner from '../../../elements/Banner';
import './index.scss';
const baseClass = 'field-type blocks';
const Blocks = (props) => {
const {
label,
name,
path: pathFromProps,
blocks,
labels,
fieldTypes,
maxRows,
minRows,
required,
validate,
permissions,
admin: {
readOnly,
style,
width,
},
} = props;
const path = pathFromProps || name;
const [rows, dispatchRows] = useReducer(reducer, []);
const { initialState, dispatchFields } = useForm();
const memoizedValidate = useCallback((value) => {
const validationResult = validate(
value,
{
minRows, maxRows, labels, blocks, required,
},
);
return validationResult;
}, [validate, maxRows, minRows, labels, blocks, required]);
const [disableFormData, setDisableFormData] = useState(false);
const {
showError,
errorMessage,
value,
setValue,
} = useFieldType({
path,
validate: memoizedValidate,
disableFormData,
ignoreWhileFlattening: true,
required,
});
const addRow = useCallback(async (rowIndex, blockType) => {
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
const subFieldState = await buildStateFromSchema(block.fields);
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
dispatchRows({ type: 'ADD', rowIndex, blockType });
setValue(value + 1);
}, [path, setValue, value, blocks, dispatchFields]);
const removeRow = useCallback((rowIndex) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
setValue(value - 1);
}, [path, setValue, value, dispatchFields]);
const moveRow = useCallback((moveFromIndex, moveToIndex) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]);
const toggleCollapse = useCallback((rowIndex) => {
dispatchRows({ type: 'TOGGLE_COLLAPSE', rowIndex });
}, []);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
}, [moveRow]);
useEffect(() => {
const data = getDataByPath(initialState, path);
dispatchRows({ type: 'SET_ALL', data: data || [] });
}, [initialState, path]);
useEffect(() => {
setValue(rows?.length || 0);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
return (
<RenderBlocks
onDragEnd={onDragEnd}
label={label}
showError={showError}
errorMessage={errorMessage}
rows={rows}
labels={labels}
addRow={addRow}
removeRow={removeRow}
moveRow={moveRow}
path={path}
name={name}
fieldTypes={fieldTypes}
toggleCollapse={toggleCollapse}
permissions={permissions}
value={value}
blocks={blocks}
readOnly={readOnly}
style={style}
width={width}
minRows={minRows}
/>
);
};
Blocks.defaultProps = {
label: '',
labels: {
singular: 'Block',
plural: 'Blocks',
},
validate: blocksValidator,
required: false,
maxRows: undefined,
minRows: undefined,
permissions: {},
admin: {},
};
Blocks.propTypes = {
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
plural: PropTypes.string,
}),
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
};
const RenderBlocks = React.memo((props) => {
const {
onDragEnd,
label,
showError,
errorMessage,
rows,
labels,
addRow,
removeRow,
moveRow,
path,
fieldTypes,
permissions,
value,
toggleCollapse,
blocks,
readOnly,
style,
width,
minRows,
maxRows,
} = props;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div
className={baseClass}
style={{
...style,
width,
}}
>
<header className={`${baseClass}__header`}>
<h3>{label}</h3>
<Error
showError={showError}
message={errorMessage}
/>
</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);
if (blockToRender) {
return (
<DraggableSection
readOnly={readOnly}
key={row.key}
id={row.key}
blockType="blocks"
blocks={blocks}
label={blockToRender?.labels?.singular}
isOpen={row.open}
rowCount={rows.length}
rowIndex={i}
addRow={addRow}
removeRow={() => removeRow(i)}
moveRow={moveRow}
toggleRowCollapse={() => toggleCollapse(i)}
parentPath={path}
fieldTypes={fieldTypes}
permissions={permissions.fields}
fieldSchema={[
...blockToRender.fields,
{
name: 'blockType',
type: 'text',
admin: {
hidden: true,
},
},
]}
/>
);
}
return null;
})}
{rows.length < minRows && (
<Banner type="error">
This field requires at least
{' '}
{minRows}
{' '}
{labels.plural}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
This field has no
{' '}
{labels.plural}
.
</Banner>
)}
{provided.placeholder}
</div>
)}
</Droppable>
{(!readOnly && (rows.length < maxRows || maxRows === undefined)) && (
<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>
);
});
RenderBlocks.defaultProps = {
label: undefined,
showError: false,
errorMessage: undefined,
rows: [],
labels: {
singular: 'Block',
plural: 'Blocks',
},
path: '',
value: undefined,
readOnly: false,
style: {},
width: undefined,
maxRows: undefined,
minRows: undefined,
};
RenderBlocks.propTypes = {
label: PropTypes.string,
showError: PropTypes.bool,
errorMessage: PropTypes.string,
rows: PropTypes.arrayOf(
PropTypes.shape({}),
),
labels: PropTypes.shape({
singular: PropTypes.string,
plural: PropTypes.string,
}),
path: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.number,
onDragEnd: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
moveRow: PropTypes.func.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}).isRequired,
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
toggleCollapse: PropTypes.func.isRequired,
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
maxRows: PropTypes.number,
minRows: PropTypes.number,
};
export default withCondition(Blocks);

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
const Blocks = lazy(() => import('./Blocks'));
export default (props) => (
<Suspense fallback={<Loading />}>
<Blocks {...props} />
</Suspense>
);

View File

@@ -0,0 +1,37 @@
@import '../../../../scss/styles.scss';
.field-type.blocks {
margin: base(2) 0;
&__add-button-wrap {
.btn {
color: $color-gray;
margin: 0;
&:hover {
color: $color-dark-gray;
}
}
}
.section .section {
margin-top: 0;
}
.section__content {
> div > div {
width: 100%;
}
}
}
.field-type.group,
.field-type.array,
.field-type.blocks {
.field-type.blocks {
.field-type.blocks__add-button-wrap {
margin-left: base(3);
}
}
}

View File

@@ -0,0 +1,116 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
import Error from '../../Error';
import { checkbox } from '../../../../../fields/validations';
import Check from '../../../icons/Check';
import './index.scss';
const baseClass = 'checkbox';
const Checkbox = (props) => {
const {
name,
path: pathFromProps,
required,
validate,
label,
onChange,
disableFormData,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const {
value,
showError,
errorMessage,
setValue,
} = useFieldType({
path,
required,
validate: memoizedValidate,
disableFormData,
});
return (
<div
className={[
'field-type',
baseClass,
showError && 'error',
value && `${baseClass}--checked`,
readOnly && `${baseClass}--read-only`,
].filter(Boolean).join(' ')}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<input
type="checkbox"
name={path}
id={path}
checked={value ? 'on' : false}
readOnly
/>
<button
type="button"
onClick={readOnly ? undefined : () => {
setValue(!value);
if (typeof onChange === 'function') onChange(!value);
}}
>
<span className={`${baseClass}__input`}>
<Check />
</span>
<span className={`${baseClass}__label`}>
{label}
</span>
</button>
</div>
);
};
Checkbox.defaultProps = {
label: null,
required: false,
admin: {},
validate: checkbox,
path: '',
onChange: undefined,
disableFormData: false,
};
Checkbox.propTypes = {
path: PropTypes.string,
name: PropTypes.string.isRequired,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
required: PropTypes.bool,
validate: PropTypes.func,
label: PropTypes.string,
onChange: PropTypes.func,
disableFormData: PropTypes.bool,
};
export default withCondition(Checkbox);

View File

@@ -0,0 +1,69 @@
@import '../../../../scss/styles.scss';
.checkbox {
position: relative;
.tooltip {
right: auto;
}
button {
@extend %btn-reset;
display: flex;
align-items: center;
cursor: pointer;
&:focus,
&:active {
outline: none;
}
&:hover {
svg {
opacity: .2;
}
}
}
&__input {
@include formInput;
padding: 0;
line-height: 0;
position: relative;
width: $baseline;
height: $baseline;
margin-right: base(.5);
svg {
opacity: 0;
}
}
input {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
&--checked {
button {
.checkbox__input {
svg {
opacity: 1;
}
}
}
}
&--read-only {
.checkbox__label {
color: $color-gray;
}
.checkbox__input {
background-color: lighten($color-light-gray, 5%);
}
}
}

View File

@@ -0,0 +1,125 @@
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
import Label from '../../Label';
import Error from '../../Error';
import { code } from '../../../../../fields/validations';
import './index.scss';
const Code = (props) => {
const {
path: pathFromProps,
name,
required,
validate,
admin: {
readOnly,
style,
width,
language,
} = {},
label,
minLength,
maxLength,
} = props;
const [highlighter] = useState(() => {
if (languages[language]) {
return (content) => highlight(content, languages[language]);
}
return (content) => content;
});
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { minLength, maxLength, required });
return validationResult;
}, [validate, maxLength, minLength, required]);
const {
value,
showError,
setValue,
errorMessage,
} = useFieldType({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const classes = [
'field-type',
'code',
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<Editor
value={value || ''}
onValueChange={readOnly ? () => { } : setValue}
highlight={highlighter}
padding={25}
style={{
backgroundColor: '#333333',
color: 'white',
fontFamily: '"Consolas", "Monaco", monospace',
fontSize: 12,
pointerEvents: readOnly ? 'none' : 'auto',
}}
/>
</div>
);
};
Code.defaultProps = {
required: false,
label: null,
validate: code,
path: '',
admin: {},
minLength: undefined,
maxLength: undefined,
};
Code.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
validate: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
language: PropTypes.oneOf(['js', 'json']),
}),
label: PropTypes.string,
minLength: PropTypes.number,
maxLength: PropTypes.number,
};
export default withCondition(Code);

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
const CodeField = lazy(() => import('./Code'));
export default (props) => (
<Suspense fallback={<Loading />}>
<CodeField {...props} />
</Suspense>
);

View File

@@ -0,0 +1,155 @@
@import '../../../../scss/styles.scss';
.field-type.code {
position: relative;
&.error {
textarea {
border: 1px solid $color-red !important;
}
}
}
/**
* atom-dark theme for `prism.js`
* Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax
* @author Joe Gibson (@gibsjose)
*/
code[class*="language-"],
pre[class*="language-"] {
color: #c5c8c6;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #1d1f21;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #7c7c7c;
}
.token.punctuation {
color: #c5c8c6;
}
.namespace {
opacity: 0.7;
}
.token.property,
.token.keyword,
.token.tag {
color: #96cbfe;
}
.token.class-name {
color: #ffffb6;
text-decoration: underline;
}
.token.boolean,
.token.constant {
color: #99cc99;
}
.token.symbol,
.token.deleted {
color: #f92672;
}
.token.number {
color: #ff73fd;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #a8ff60;
}
.token.variable {
color: #c6c5fe;
}
.token.operator {
color: #ededed;
}
.token.entity {
color: #ffffb6;
cursor: help;
}
.token.url {
color: #96cbfe;
}
.language-css .token.string,
.style .token.string {
color: #87c38a;
}
.token.atrule,
.token.attr-value {
color: #f9ee98;
}
.token.function {
color: #dad085;
}
.token.regex {
color: #e9c062;
}
.token.important {
color: #fd971f;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}

View File

@@ -0,0 +1,63 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import { useFormFields } from '../../Form/context';
import './index.scss';
const ConfirmPassword = () => {
const { getField } = useFormFields();
const password = getField('password');
const validate = useCallback((value) => {
if (value === password?.value) {
return true;
}
return 'Passwords do not match.';
}, [password]);
const {
value,
showError,
setValue,
errorMessage,
} = useFieldType({
path: 'confirm-password',
disableFormData: true,
required: true,
validate,
enableDebouncedValue: true,
});
const classes = [
'field-type',
'confirm-password',
showError && 'error',
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor="confirm-password"
label="Confirm Password"
required
/>
<input
value={value || ''}
onChange={setValue}
type="password"
autoComplete="off"
id="confirm-password"
name="confirm-password"
/>
</div>
);
};
export default ConfirmPassword;

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.field-type.confirm-password {
position: relative;
input {
@include formInput;
}
&.error {
input {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,107 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import DatePicker from '../../../elements/DatePicker';
import withCondition from '../../withCondition';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import { date } from '../../../../../fields/validations';
import './index.scss';
const defaultError = 'Please fill in the field with a valid date';
const baseClass = 'date-time-field';
const DateTime = (props) => {
const {
path: pathFromProps,
name,
required,
validate,
errorMessage,
label,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const {
value,
showError,
setValue,
} = useFieldType({
path,
required,
validate: memoizedValidate,
});
const classes = [
'field-type',
baseClass,
showError && `${baseClass}--has-error`,
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<div className={`${baseClass}__input-wrapper`}>
<DatePicker
{...props}
onChange={readOnly ? undefined : setValue}
value={value}
/>
</div>
</div>
);
};
DateTime.defaultProps = {
label: null,
required: false,
validate: date,
errorMessage: defaultError,
admin: {},
path: '',
};
DateTime.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
label: PropTypes.string,
required: PropTypes.bool,
validate: PropTypes.func,
errorMessage: PropTypes.string,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
};
export default withCondition(DateTime);

View File

@@ -0,0 +1,9 @@
@import '../../../../scss/styles';
.date-time-field {
&--has-error {
.react-datepicker__input-container input {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,110 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import withCondition from '../../withCondition';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import { email } from '../../../../../fields/validations';
import './index.scss';
const Email = (props) => {
const {
name,
path: pathFromProps,
required,
validate,
admin: {
readOnly,
style,
width,
} = {},
label,
placeholder,
autoComplete,
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const {
value,
showError,
setValue,
errorMessage,
} = fieldType;
const classes = [
'field-type',
'email',
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<input
value={value || ''}
onChange={setValue}
disabled={readOnly ? 'disabled' : undefined}
placeholder={placeholder}
type="email"
id={path}
name={path}
autoComplete={autoComplete}
/>
</div>
);
};
Email.defaultProps = {
label: null,
required: false,
placeholder: undefined,
admin: {},
autoComplete: undefined,
validate: email,
path: '',
};
Email.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
placeholder: PropTypes.string,
validate: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
autoComplete: PropTypes.string,
};
export default withCondition(Email);

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.field-type.email {
position: relative;
input {
@include formInput;
}
&.error {
input {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import RenderFields from '../../RenderFields';
import withCondition from '../../withCondition';
import FieldTypeGutter from '../../FieldTypeGutter';
import { NegativeFieldGutterProvider } from '../../FieldTypeGutter/context';
import './index.scss';
const baseClass = 'group';
const Group = (props) => {
const {
label,
fields,
name,
path: pathFromProps,
fieldTypes,
admin: {
readOnly,
style,
width,
},
} = props;
const path = pathFromProps || name;
return (
<div
className="field-type group"
style={{
...style,
width,
}}
>
<FieldTypeGutter />
<div className={`${baseClass}__content-wrapper`}>
{label && (
<h2 className={`${baseClass}__title`}>{label}</h2>
)}
<div className={`${baseClass}__fields-wrapper`}>
<NegativeFieldGutterProvider allow={false}>
<RenderFields
readOnly={readOnly}
fieldTypes={fieldTypes}
fieldSchema={fields.map((subField) => ({
...subField,
path: `${path}${subField.name ? `.${subField.name}` : ''}`,
}))}
/>
</NegativeFieldGutterProvider>
</div>
</div>
</div>
);
};
Group.defaultProps = {
label: '',
path: '',
admin: {},
};
Group.propTypes = {
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
name: PropTypes.string.isRequired,
path: PropTypes.string,
fieldTypes: PropTypes.shape({}).isRequired,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
};
export default withCondition(Group);

View File

@@ -0,0 +1,37 @@
@import '../../../../scss/styles.scss';
.group {
margin-top: base(2);
margin-bottom: base(2);
display: flex;
&:hover {
.field-type-gutter__content-container {
box-shadow: #{$style-stroke-width-m} 0px 0px 0px $color-dark-gray;
}
}
&__content-wrapper {
width: 100%;
}
&__fields-wrapper {
position: relative;
}
.render-fields {
width: 100%;
.row {
&:last-child {
margin-bottom: 0;
}
}
}
@include mid-break {
&__fields-wrapper {
display: flex;
}
}
}

View File

@@ -0,0 +1,46 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
const HiddenInput = (props) => {
const {
name,
path: pathFromProps,
value: valueFromProps,
} = props;
const path = pathFromProps || name;
const { value, setValue } = useFieldType({
path,
});
useEffect(() => {
if (valueFromProps !== undefined) {
setValue(valueFromProps);
}
}, [valueFromProps, setValue]);
return (
<input
type="hidden"
value={value || ''}
onChange={setValue}
name={path}
/>
);
};
HiddenInput.defaultProps = {
path: '',
value: undefined,
};
HiddenInput.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
value: PropTypes.string,
};
export default withCondition(HiddenInput);

View File

@@ -0,0 +1,119 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import withCondition from '../../withCondition';
import { number } from '../../../../../fields/validations';
import './index.scss';
const NumberField = (props) => {
const {
name,
path: pathFromProps,
required,
validate,
label,
placeholder,
max,
min,
admin: {
readOnly,
style,
width,
step,
} = {},
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { min, max, required });
return validationResult;
}, [validate, max, min, required]);
const {
value,
showError,
setValue,
errorMessage,
} = useFieldType({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const handleChange = useCallback((e) => {
let val = parseFloat(e.target.value);
if (Number.isNaN(val)) val = '';
setValue(val);
}, [setValue]);
const classes = [
'field-type',
'number',
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<input
value={typeof value === 'number' ? value : ''}
onChange={handleChange}
disabled={readOnly ? 'disabled' : undefined}
placeholder={placeholder}
type="number"
id={path}
name={path}
step={step}
/>
</div>
);
};
NumberField.defaultProps = {
label: null,
path: undefined,
required: false,
placeholder: undefined,
max: undefined,
min: undefined,
validate: number,
admin: {},
};
NumberField.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
placeholder: PropTypes.string,
validate: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
step: PropTypes.number,
}),
label: PropTypes.string,
max: PropTypes.number,
min: PropTypes.number,
};
export default withCondition(NumberField);

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.field-type.number {
position: relative;
input {
@include formInput;
}
&.error {
input {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,99 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import withCondition from '../../withCondition';
import { password } from '../../../../../fields/validations';
import './index.scss';
const Password = (props) => {
const {
path: pathFromProps,
name,
required,
validate,
style,
width,
autoComplete,
label,
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const {
value,
showError,
processing,
setValue,
errorMessage,
} = useFieldType({
path,
required,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const classes = [
'field-type',
'password',
showError && 'error',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<input
value={value || ''}
onChange={setValue}
disabled={processing ? 'disabled' : undefined}
type="password"
autoComplete={autoComplete}
id={path}
name={path}
/>
</div>
);
};
Password.defaultProps = {
required: false,
validate: password,
width: undefined,
style: {},
path: '',
autoComplete: 'off',
};
Password.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
width: PropTypes.string,
style: PropTypes.shape({}),
label: PropTypes.string.isRequired,
validate: PropTypes.func,
autoComplete: PropTypes.string,
};
export default withCondition(Password);

View File

@@ -0,0 +1,15 @@
@import '../../../../scss/styles.scss';
.field-type.password {
position: relative;
input {
@include formInput;
}
&.error {
input {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'radio-input';
const RadioInput = (props) => {
const { isSelected, option, onChange, path } = props;
const classes = [
baseClass,
isSelected && `${baseClass}--is-selected`,
].filter(Boolean).join(' ');
const id = `${path}-${option.value}`;
return (
<label
htmlFor={id}
>
<div className={classes}>
<input
id={id}
type="radio"
checked={isSelected}
onChange={() => (typeof onChange === 'function' ? onChange(option.value) : null)}
/>
<span className={`${baseClass}__styled-radio`} />
<span className={`${baseClass}__label`}>{option.label}</span>
</div>
</label>
);
};
RadioInput.defaultProps = {
isSelected: false,
onChange: undefined,
};
RadioInput.propTypes = {
isSelected: PropTypes.bool,
option: PropTypes.shape({
label: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
}).isRequired,
onChange: PropTypes.func,
path: PropTypes.string.isRequired,
};
export default RadioInput;

View File

@@ -0,0 +1,86 @@
@import '../../../../../scss/styles';
.radio-input {
display: flex;
align-items: center;
cursor: pointer;
margin: base(.10) 0;
input[type=radio] {
display: none;
}
&__styled-radio {
@include formInput;
width: $baseline;
height: $baseline;
position: relative;
padding: 0;
display: inline-block;
border-radius: 50%;
&:before {
content: ' ';
display: block;
border-radius: 100%;
background-color: $color-dark-gray;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 base(.1875) white;
opacity: 0;
}
}
&__label {
margin-left: base(.5);
}
&--is-selected {
.radio-input {
&__styled-radio {
&:before {
opacity: 1;
}
}
}
}
&:not(&--is-selected) {
&:hover {
.radio-input {
&__styled-radio {
&:before {
opacity: .2;
}
}
}
}
}
}
.radio-group--read-only {
.radio-input {
&__label {
color: $color-gray;
}
&--is-selected {
.radio-input__styled-radio {
&:before {
background-color: $color-gray;
}
}
}
&:not(&--is-selected) {
&:hover {
.radio-input__styled-radio {
&:before {
opacity: 0;
}
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
import Error from '../../Error';
import Label from '../../Label';
import RadioInput from './RadioInput';
import { radio } from '../../../../../fields/validations';
import './index.scss';
const baseClass = 'radio-group';
const RadioGroup = (props) => {
const {
name,
path: pathFromProps,
required,
validate,
label,
admin: {
readOnly,
layout = 'horizontal',
style,
width,
} = {},
options,
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required, options });
return validationResult;
}, [validate, required, options]);
const {
value,
showError,
errorMessage,
setValue,
} = useFieldType({
path,
required,
validate: memoizedValidate,
});
const classes = [
'field-type',
baseClass,
`${baseClass}--layout-${layout}`,
showError && 'error',
readOnly && `${baseClass}--read-only`,
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<ul className={`${baseClass}--group`}>
{options?.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<li key={`${path} - ${option.value}`}>
<RadioInput
path={path}
isSelected={isSelected}
option={option}
onChange={readOnly ? undefined : setValue}
/>
</li>
);
})}
</ul>
</div>
);
};
RadioGroup.defaultProps = {
label: null,
required: false,
validate: radio,
admin: {},
path: '',
};
RadioGroup.propTypes = {
path: PropTypes.string,
name: PropTypes.string.isRequired,
required: PropTypes.bool,
validate: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
}),
).isRequired,
};
export default withCondition(RadioGroup);

View File

@@ -0,0 +1,21 @@
@import '../../../../scss/styles.scss';
.radio-group {
&--layout-horizontal {
ul {
display: flex;
flex-wrap: wrap;
}
li {
padding-right: $baseline;
flex-shrink: 0;
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
}

View File

@@ -0,0 +1,483 @@
import React, {
Component, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import some from 'async-some';
import { useConfig } from '../../../providers/Config';
import withCondition from '../../withCondition';
import ReactSelect from '../../../elements/ReactSelect';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import { relationship } from '../../../../../fields/validations';
import './index.scss';
const maxResultsPerRequest = 10;
const baseClass = 'relationship';
class Relationship extends Component {
constructor(props) {
super(props);
const { relationTo, hasMultipleRelations, required } = this.props;
const relations = hasMultipleRelations ? relationTo : [relationTo];
this.initialOptions = required ? [] : [{ value: 'null', label: 'None' }];
this.state = {
relations,
lastFullyLoadedRelation: -1,
lastLoadedPage: 1,
errorLoading: false,
loadedIDs: [],
options: this.initialOptions,
};
}
componentDidMount() {
this.getNextOptions();
}
componentDidUpdate(_, prevState) {
const { search, options } = this.state;
if (search !== prevState.search) {
this.getNextOptions({ clear: true });
}
if (options !== prevState.options) {
this.ensureValueHasOption();
}
}
getNextOptions = (params = {}) => {
const { config: { serverURL, routes: { api }, collections } } = this.props;
const { errorLoading } = this.state;
const { clear } = params;
if (clear) {
this.setState({
options: this.initialOptions,
loadedIDs: [],
lastFullyLoadedRelation: -1,
});
}
if (!errorLoading) {
const {
relations, lastFullyLoadedRelation, lastLoadedPage, search,
} = this.state;
const relationsToSearch = lastFullyLoadedRelation === -1 ? relations : relations.slice(lastFullyLoadedRelation + 1);
if (relationsToSearch.length > 0) {
some(relationsToSearch, async (relation, callback) => {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = search ? `&where[${fieldToSearch}][like]=${search}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPage}${searchParam}`);
const data = await response.json();
if (response.ok) {
if (data.hasNextPage) {
return callback(false, {
data,
relation,
});
}
return callback({ relation, data });
}
let error = 'There was a problem loading options for this field.';
if (response.status === 403) {
error = 'You do not have permission to load options for this field.';
}
return this.setState({
errorLoading: error,
});
}, (lastPage, nextPage) => {
if (nextPage) {
const { data, relation } = nextPage;
this.addOptions(data, relation);
this.setState({
lastLoadedPage: lastLoadedPage + 1,
});
} else {
const { data, relation } = lastPage;
this.addOptions(data, relation);
this.setState({
lastFullyLoadedRelation: relations.indexOf(relation),
lastLoadedPage: 1,
});
}
});
}
}
}
// This is needed to reduce the selected option to only its value
// Essentially, remove the label
formatSelectedValue = (selectedValue) => {
const { hasMany } = this.props;
if (hasMany && Array.isArray(selectedValue)) {
return selectedValue.map((val) => val.value);
}
return selectedValue ? selectedValue.value : selectedValue;
}
// When ReactSelect prepopulates a selected option,
// if there are multiple relations, we need to find a nested option to match from
findValueInOptions = (options, value) => {
const { hasMultipleRelations, hasMany } = this.props;
let foundValue = false;
if (hasMultipleRelations) {
options.forEach((option) => {
const potentialValue = option.options && option.options.find((subOption) => {
if (subOption?.value?.value && value?.value) {
return subOption.value.value === value.value;
}
return false;
});
if (potentialValue) foundValue = potentialValue;
});
} else if (value) {
if (hasMany && Array.isArray(value)) {
foundValue = value.map((val) => options.find((option) => option.value === val));
} else {
foundValue = options.find((option) => option.value === value);
}
}
return foundValue || undefined; // TODO - should set as None
}
addOptions = (data, relation) => {
const { hasMultipleRelations, config: { collections } } = this.props;
const { options, loadedIDs } = this.state;
const collection = collections.find((coll) => coll.slug === relation);
const newlyLoadedIDs = [];
let newOptions = [];
if (!hasMultipleRelations) {
newOptions = [
...options,
...data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
newlyLoadedIDs.push(doc.id);
return [
...docs,
{
label: doc[collection?.admin?.useAsTitle || 'id'],
value: doc.id,
},
];
}
return docs;
}, []),
];
} else {
newOptions = [...options];
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const newSubOptions = data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
newlyLoadedIDs.push(doc.id);
return [
...docs,
{
label: doc[collection?.admin?.useAsTitle || 'id'],
value: {
relationTo: collection.slug,
value: doc.id,
},
},
];
}
return docs;
}, []);
if (optionsToAddTo) {
optionsToAddTo.options = [
...optionsToAddTo.options,
...newSubOptions,
];
} else {
newOptions.push({
label: collection.labels.plural,
options: newSubOptions,
});
}
}
this.setState({
options: newOptions,
loadedIDs: [
...loadedIDs,
...newlyLoadedIDs,
],
});
}
ensureValueHasOption = async () => {
const { relationTo, hasMany, value } = this.props;
const { options } = this.state;
const locatedValue = this.findValueInOptions(options, value);
const hasMultipleRelations = Array.isArray(relationTo);
if (hasMany && value?.length > 0) {
locatedValue.forEach((val, i) => {
if (!val && value[i]) {
if (hasMultipleRelations) {
this.addOptionByID(value[i].value, value[i].relationTo);
} else {
this.addOptionByID(value[i], relationTo);
}
}
});
} else if (!locatedValue && value) {
if (hasMultipleRelations) {
this.addOptionByID(value.value, value.relationTo);
} else {
this.addOptionByID(value, relationTo);
}
}
}
addOptionByID = async (id, relation) => {
const { config: { serverURL, routes: { api } } } = this.props;
const { errorLoading } = this.state;
if (!errorLoading) {
const response = await fetch(`${serverURL}${api}/${relation}/${id}`);
if (response.ok) {
const data = await response.json();
this.addOptions({ docs: [data] }, relation);
} else {
console.error(`There was a problem loading the document with ID of ${id}.`);
}
}
}
handleInputChange = (search) => {
const { search: existingSearch } = this.state;
if (search !== existingSearch) {
this.setState({
search,
lastFullyLoadedRelation: -1,
lastLoadedPage: 1,
});
}
}
handleMenuScrollToBottom = () => {
this.getNextOptions();
}
render() {
const { options, errorLoading } = this.state;
const {
path,
required,
errorMessage,
label,
hasMany,
value,
showError,
formProcessing,
setValue,
admin: {
readOnly,
style,
width,
} = {},
} = this.props;
const classes = [
'field-type',
baseClass,
showError && 'error',
errorLoading && 'error-loading',
readOnly && `${baseClass}--read-only`,
].filter(Boolean).join(' ');
const valueToRender = this.findValueInOptions(options, value) || value;
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
{!errorLoading && (
<ReactSelect
isDisabled={readOnly}
onInputChange={this.handleInputChange}
onChange={!readOnly ? setValue : undefined}
formatValue={this.formatSelectedValue}
onMenuScrollToBottom={this.handleMenuScrollToBottom}
findValueInOptions={this.findValueInOptions}
value={valueToRender}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
/>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>
{errorLoading}
</div>
)}
</div>
);
}
}
Relationship.defaultProps = {
required: false,
errorMessage: '',
hasMany: false,
showError: false,
value: undefined,
path: '',
formProcessing: false,
admin: {},
};
Relationship.propTypes = {
relationTo: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(
PropTypes.string,
),
]).isRequired,
required: PropTypes.bool,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
errorMessage: PropTypes.string,
showError: PropTypes.bool,
label: PropTypes.string.isRequired,
path: PropTypes.string,
formProcessing: PropTypes.bool,
hasMany: PropTypes.bool,
setValue: PropTypes.func.isRequired,
hasMultipleRelations: PropTypes.bool.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
PropTypes.shape({}),
]),
config: PropTypes.shape({
serverURL: PropTypes.string,
routes: PropTypes.shape({
admin: PropTypes.string,
api: PropTypes.string,
}),
collections: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
plural: PropTypes.string,
}),
}),
),
}).isRequired,
};
const RelationshipFieldType = (props) => {
const {
relationTo, validate, path, name, required,
} = props;
const config = useConfig();
const hasMultipleRelations = Array.isArray(relationTo);
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
path: path || name,
validate: memoizedValidate,
required,
});
return (
<Relationship
config={config}
{...props}
{...fieldType}
hasMultipleRelations={hasMultipleRelations}
/>
);
};
RelationshipFieldType.defaultProps = {
initialData: undefined,
defaultValue: undefined,
validate: relationship,
path: '',
hasMany: false,
required: false,
};
RelationshipFieldType.propTypes = {
required: PropTypes.bool,
defaultValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
PropTypes.shape({}),
]),
initialData: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
PropTypes.shape({}),
]),
relationTo: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(
PropTypes.string,
),
]).isRequired,
hasMany: PropTypes.bool,
validate: PropTypes.func,
name: PropTypes.string.isRequired,
path: PropTypes.string,
};
export default withCondition(RelationshipFieldType);

View File

@@ -0,0 +1,21 @@
@import '../../../../scss/styles.scss';
.field-type.relationship {
position: relative;
}
.relationship__error-loading {
border: 1px solid $color-red;
min-height: base(2);
padding: base(.5) base(.75);
background-color: $color-red;
color: white;
}
.relationship--read-only {
div.react-select {
div.rs__control {
background: lighten($color-light-gray, 5%);
}
}
}

View File

@@ -0,0 +1,276 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import isHotkey from 'is-hotkey';
import { Editable, withReact, Slate } from 'slate-react';
import { createEditor } from 'slate';
import { withHistory } from 'slate-history';
import { richText } from '../../../../../fields/validations';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
import Label from '../../Label';
import Error from '../../Error';
import leafTypes from './leaves';
import elementTypes from './elements';
import toggleLeaf from './leaves/toggle';
import hotkeys from './hotkeys';
import enablePlugins from './enablePlugins';
import defaultValue from '../../../../../fields/richText/defaultValue';
import FieldTypeGutter from '../../FieldTypeGutter';
import withHTML from './plugins/withHTML';
import mergeCustomFunctions from './mergeCustomFunctions';
import './index.scss';
const defaultElements = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'link', 'relationship'];
const defaultLeaves = ['bold', 'italic', 'underline', 'strikethrough', 'code'];
const baseClass = 'rich-text';
const RichText = (props) => {
const {
path: pathFromProps,
name,
required,
validate,
label,
placeholder,
admin,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const elements = admin?.elements || defaultElements;
const leaves = admin?.leaves || defaultLeaves;
const path = pathFromProps || name;
const [loaded, setLoaded] = useState(false);
const [enabledElements, setEnabledElements] = useState({});
const [enabledLeaves, setEnabledLeaves] = useState({});
const renderElement = useCallback(({ attributes, children, element }) => {
const matchedElement = enabledElements[element?.type];
const Element = matchedElement?.Element;
if (Element) {
return (
<Element
attributes={attributes}
element={element}
>
{children}
</Element>
);
}
return <div {...attributes}>{children}</div>;
}, [enabledElements]);
const renderLeaf = useCallback(({ attributes, children, leaf }) => {
const matchedLeafName = Object.keys(enabledLeaves).find((leafName) => leaf[leafName]);
if (enabledLeaves[matchedLeafName]?.Leaf) {
const { Leaf } = enabledLeaves[matchedLeafName];
return (
<Leaf
attributes={attributes}
leaf={leaf}
>
{children}
</Leaf>
);
}
return (
<span {...attributes}>{children}</span>
);
}, [enabledLeaves]);
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
path,
required,
validate: memoizedValidate,
stringify: true,
});
const {
value,
showError,
setValue,
errorMessage,
} = fieldType;
const classes = [
baseClass,
'field-type',
showError && 'error',
readOnly && `${baseClass}--read-only`,
].filter(Boolean).join(' ');
const editor = useMemo(() => {
let CreatedEditor = withHTML(withHistory(withReact(createEditor())));
CreatedEditor = enablePlugins(CreatedEditor, elements);
CreatedEditor = enablePlugins(CreatedEditor, leaves);
return CreatedEditor;
}, [elements, leaves]);
useEffect(() => {
if (!loaded) {
const mergedElements = mergeCustomFunctions(elements, elementTypes);
const mergedLeaves = mergeCustomFunctions(leaves, leafTypes);
setEnabledElements(mergedElements);
setEnabledLeaves(mergedLeaves);
setLoaded(true);
}
}, [loaded, elements, leaves]);
if (!loaded) {
return null;
}
let valueToRender = value;
if (typeof valueToRender === 'string') valueToRender = JSON.parse(valueToRender);
if (!valueToRender) valueToRender = defaultValue;
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<FieldTypeGutter />
<div className={`${baseClass}__wrap`}>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={path}
label={label}
required={required}
/>
<Slate
editor={editor}
value={valueToRender}
onChange={(val) => {
if (val !== defaultValue && val !== value) {
setValue(val);
}
}}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__toolbar`}>
{elements.map((element, i) => {
const elementName = element?.name || element;
const elementType = enabledElements[elementName];
const Button = elementType?.Button;
if (Button) {
return (
<Button
key={i}
path={path}
/>
);
}
return null;
})}
{leaves.map((leaf, i) => {
const leafName = leaf?.name || leaf;
const leafType = enabledLeaves[leafName];
const Button = leafType?.Button;
if (Button) {
return (
<Button
key={i}
path={path}
/>
);
}
return null;
})}
</div>
<Editable
className={`${baseClass}__editor`}
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
spellCheck
readOnly={readOnly}
onKeyDown={(event) => {
Object.keys(hotkeys).forEach((hotkey) => {
if (isHotkey(hotkey, event)) {
event.preventDefault();
const mark = hotkeys[hotkey];
toggleLeaf(editor, mark);
}
});
}}
/>
</div>
</Slate>
</div>
</div>
);
};
RichText.defaultProps = {
label: null,
required: false,
admin: {},
validate: richText,
path: '',
placeholder: undefined,
};
RichText.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
validate: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
elements: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
name: PropTypes.string,
}),
]),
),
leaves: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
name: PropTypes.string,
}),
]),
),
}),
label: PropTypes.string,
placeholder: PropTypes.string,
};
export default withCondition(RichText);

View File

@@ -0,0 +1,8 @@
@import '../../../../scss/styles.scss';
.rich-text__button {
svg {
width: base(.75);
height: base(.75);
}
}

View File

@@ -0,0 +1,47 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSlate } from 'slate-react';
import isElementActive from './isActive';
import toggleElement from './toggle';
import '../buttons.scss';
const baseClass = 'rich-text__button';
const ElementButton = ({ format, children, onClick, className }) => {
const editor = useSlate();
const defaultOnClick = useCallback((event) => {
event.preventDefault();
toggleElement(editor, format);
}, [editor, format]);
return (
<button
type="button"
className={[
baseClass,
className,
isElementActive(editor, format) && `${baseClass}__button--active`,
].filter(Boolean).join(' ')}
onClick={onClick || defaultOnClick}
>
{children}
</button>
);
};
ElementButton.defaultProps = {
children: null,
onClick: undefined,
className: undefined,
};
ElementButton.propTypes = {
children: PropTypes.node,
format: PropTypes.string.isRequired,
onClick: PropTypes.func,
className: PropTypes.string,
};
export default ElementButton;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H1Icon from '../../../../../icons/headings/H1';
const H1 = ({ attributes, children }) => (
<h1 {...attributes}>{children}</h1>
);
H1.defaultProps = {
attributes: {},
children: null,
};
H1.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h1 = {
Button: () => (
<ElementButton
format="h1"
>
<H1Icon />
</ElementButton>
),
Element: H1,
};
export default h1;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H2Icon from '../../../../../icons/headings/H2';
const H2 = ({ attributes, children }) => (
<h2 {...attributes}>{children}</h2>
);
H2.defaultProps = {
attributes: {},
children: null,
};
H2.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h2 = {
Button: () => (
<ElementButton format="h2">
<H2Icon />
</ElementButton>
),
Element: H2,
};
export default h2;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H3Icon from '../../../../../icons/headings/H3';
const H3 = ({ attributes, children }) => (
<h3 {...attributes}>{children}</h3>
);
H3.defaultProps = {
attributes: {},
children: null,
};
H3.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h3 = {
Button: () => (
<ElementButton format="h3">
<H3Icon />
</ElementButton>
),
Element: H3,
};
export default h3;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H4Icon from '../../../../../icons/headings/H4';
const H4 = ({ attributes, children }) => (
<h4 {...attributes}>{children}</h4>
);
H4.defaultProps = {
attributes: {},
children: null,
};
H4.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h4 = {
Button: () => (
<ElementButton format="h4">
<H4Icon />
</ElementButton>
),
Element: H4,
};
export default h4;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H5Icon from '../../../../../icons/headings/H5';
const H5 = ({ attributes, children }) => (
<h5 {...attributes}>{children}</h5>
);
H5.defaultProps = {
attributes: {},
children: null,
};
H5.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h5 = {
Button: () => (
<ElementButton format="h5">
<H5Icon />
</ElementButton>
),
Element: H5,
};
export default h5;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import H6Icon from '../../../../../icons/headings/H6';
const H6 = ({ attributes, children }) => (
<h6 {...attributes}>{children}</h6>
);
H6.defaultProps = {
attributes: {},
children: null,
};
H6.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const h6 = {
Button: () => (
<ElementButton format="h6">
<H6Icon />
</ElementButton>
),
Element: H6,
};
export default h6;

View File

@@ -0,0 +1,25 @@
import h1 from './h1';
import h2 from './h2';
import h3 from './h3';
import h4 from './h4';
import h5 from './h5';
import h6 from './h6';
import link from './link';
import ol from './ol';
import ul from './ul';
import li from './li';
import relationship from './relationship';
export default {
h1,
h2,
h3,
h4,
h5,
h6,
link,
ol,
ul,
li,
relationship,
};

View File

@@ -0,0 +1,11 @@
import { Editor } from 'slate';
const isElementActive = (editor, format) => {
const [match] = Editor.nodes(editor, {
match: (n) => n.type === format,
});
return !!match;
};
export default isElementActive;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
const LI = ({ attributes, children }) => (
<li {...attributes}>{children}</li>
);
LI.defaultProps = {
attributes: {},
children: null,
};
LI.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
export default {
Element: LI,
};

View File

@@ -0,0 +1,181 @@
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { ReactEditor, useSlate } from 'slate-react';
import { useWindowInfo } from '@faceless-ui/window-info';
import { Transforms } from 'slate';
import ElementButton from '../Button';
import { withLinks, wrapLink } from './utilities';
import LinkIcon from '../../../../../icons/Link';
import Portal from '../../../../../utilities/Portal';
import Popup from '../../../../../elements/Popup';
import Button from '../../../../../elements/Button';
import Check from '../../../../../icons/Check';
import Error from '../../../../Error';
import getOffsetTop from '../../../../../../utilities/getOffsetTop';
import './index.scss';
const baseClass = 'rich-text-link';
const Link = ({ attributes, children, element }) => {
const editor = useSlate();
const linkRef = useRef();
const { height: windowHeight, width: windowWidth } = useWindowInfo();
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [error, setError] = useState(false);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [url, setURL] = useState(element.url);
const [newTab, setNewTab] = useState(Boolean(element.newTab));
const calculatePosition = useCallback(() => {
if (linkRef?.current) {
const rect = linkRef.current.getBoundingClientRect();
const offsetTop = getOffsetTop(linkRef.current);
setTop(offsetTop);
setLeft(rect.left);
setWidth(rect.width);
setHeight(rect.height);
}
}, []);
useEffect(() => {
calculatePosition();
}, [children, calculatePosition, windowHeight, windowWidth]);
useEffect(() => {
const path = ReactEditor.findPath(editor, element);
Transforms.setNodes(
editor,
{ url, newTab },
{ at: path },
);
}, [url, newTab, editor, element]);
return (
<span
className={baseClass}
{...attributes}
>
<span ref={linkRef}>
<Portal>
<div
className={`${baseClass}__popup-wrap`}
style={{
width,
height,
top,
left,
}}
>
<Popup
initActive={url === undefined}
className={`${baseClass}__popup`}
buttonType="custom"
button={<span className={`${baseClass}__button`} />}
size="small"
color="dark"
horizontalAlign="center"
onToggleOpen={calculatePosition}
render={({ close }) => (
<Fragment>
<div className={`${baseClass}__url-wrap`}>
<input
value={url || ''}
className={`${baseClass}__url`}
placeholder="Enter a URL"
onChange={(e) => {
const { value } = e.target;
if (value && error) {
setError(false);
}
setURL(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
close();
}
}}
/>
<Button
className={`${baseClass}__confirm`}
buttonStyle="none"
icon="chevron"
onToggleOpen={calculatePosition}
onClick={(e) => {
e.preventDefault();
if (url) {
close();
} else {
setError(true);
}
}}
/>
{error && (
<Error
showError={error}
message="Please enter a valid URL."
/>
)}
</div>
<Button
className={[`${baseClass}__new-tab`, newTab && `${baseClass}__new-tab--checked`].filter(Boolean).join(' ')}
buttonStyle="none"
onClick={() => setNewTab(!newTab)}
>
<Check />
Open link in new tab
</Button>
</Fragment>
)}
/>
</div>
</Portal>
{children}
</span>
</span>
);
};
Link.defaultProps = {
attributes: {},
children: null,
};
Link.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
element: PropTypes.shape({
url: PropTypes.string,
newTab: PropTypes.bool,
}).isRequired,
};
const LinkButton = () => {
const editor = useSlate();
return (
<ElementButton
format="link"
onClick={() => wrapLink(editor)}
>
<LinkIcon />
</ElementButton>
);
};
const link = {
Button: LinkButton,
Element: Link,
plugins: [
withLinks,
],
};
export default link;

View File

@@ -0,0 +1,89 @@
@import '../../../../../../scss/styles.scss';
.rich-text-link {
position: relative;
text-decoration: underline;
.popup {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
.rich-text-link__popup-wrap {
position: absolute;
z-index: $z-page-content;
cursor: pointer;
.tooltip {
bottom: 80%;
}
}
.rich-text-link__button {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.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(white);
transform: rotate(-90deg);
}
}
.rich-text-link__url {
@include formInput;
min-width: base(12);
width: 100%;
background: rgba($color-background-gray, .1);
border: 0;
color: white;
}
.rich-text-link__new-tab {
svg {
@include color-svg(white);
background-color: rgba(white, .1);
margin-right: base(.5);
}
path {
opacity: 0;
}
&:hover {
path {
opacity: .2;
}
}
&--checked {
path {
opacity: 1;
}
&:hover {
path {
opacity: .8;
}
}
}
}

View File

@@ -0,0 +1,47 @@
import { Editor, Transforms, Range } from 'slate';
export const isLinkActive = (editor) => {
const [link] = Editor.nodes(editor, { match: (n) => n.type === 'link' });
return !!link;
};
export const unwrapLink = (editor) => {
Transforms.unwrapNodes(editor, { match: (n) => n.type === 'link' });
};
export const wrapLink = (editor, url, newTab) => {
if (isLinkActive(editor)) {
unwrapLink(editor);
}
const { selection } = editor;
const isCollapsed = selection && Range.isCollapsed(selection);
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' });
}
};
export const withLinks = (incomingEditor) => {
const editor = incomingEditor;
const { isInline } = editor;
editor.isInline = (element) => {
if (element.type === 'link') {
return true;
}
return isInline(element);
};
return editor;
};

View File

@@ -0,0 +1 @@
export default ['ol', 'ul'];

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import OLIcon from '../../../../../icons/OrderedList';
const OL = ({ attributes, children }) => (
<ol {...attributes}>{children}</ol>
);
OL.defaultProps = {
attributes: {},
children: null,
};
OL.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const ol = {
Button: () => (
<ElementButton format="ol">
<OLIcon />
</ElementButton>
),
Element: OL,
};
export default ol;

View File

@@ -0,0 +1,63 @@
import React, { Fragment, useState, useEffect } from 'react';
import { useFormFields } from '../../../../../../Form/context';
import Relationship from '../../../../../Relationship';
import Number from '../../../../../Number';
import Select from '../../../../../Select';
import { useConfig } from '../../../../../../../providers/Config';
import { useAuthentication } from '../../../../../../../providers/Authentication';
const createOptions = (collections, permissions) => collections.reduce((options, collection) => {
if (permissions[collection.slug]?.read?.permission && collection?.admin?.enableRichTextRelationship) {
return [
...options,
{
label: collection.labels.plural,
value: collection.slug,
},
];
}
return options;
}, []);
const RelationshipFields = () => {
const { collections, maxDepth } = useConfig();
const { permissions } = useAuthentication();
const [options, setOptions] = useState(() => createOptions(collections, permissions));
const { getData } = useFormFields();
const { relationTo } = getData();
useEffect(() => {
setOptions(createOptions(collections, permissions));
}, [collections, permissions]);
return (
<Fragment>
<Select
required
label="Relation To"
name="relationTo"
options={options}
/>
{relationTo && (
<Relationship
label="Related Document"
name="value"
relationTo={relationTo}
required
/>
)}
<Number
required
name="depth"
label="Depth"
min={0}
max={maxDepth}
/>
</Fragment>
);
};
export default RelationshipFields;

View File

@@ -0,0 +1,107 @@
import React, { Fragment, useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, useModal } from '@faceless-ui/modal';
import { Transforms } from 'slate';
import { useSlate } from 'slate-react';
import ElementButton from '../../Button';
import RelationshipIcon from '../../../../../../icons/Relationship';
import Form from '../../../../../Form';
import MinimalTemplate from '../../../../../../templates/Minimal';
import { useConfig } from '../../../../../../providers/Config';
import Button from '../../../../../../elements/Button';
import Submit from '../../../../../Submit';
import X from '../../../../../../icons/X';
import Fields from './Fields';
import { requests } from '../../../../../../../api';
import './index.scss';
const initialFormData = {
depth: 0,
};
const baseClass = 'relationship-rich-text-button';
const insertRelationship = (editor, { value, relationTo, depth }) => {
const text = { text: ' ' };
const relationship = {
type: 'relationship',
value,
depth,
relationTo,
children: [
text,
],
};
const nodes = [relationship, { children: [{ text: '' }] }];
Transforms.insertNodes(editor, nodes);
};
const RelationshipButton = ({ path }) => {
const { open, closeAll } = useModal();
const editor = useSlate();
const { serverURL, routes: { api }, collections } = useConfig();
const [loading, setLoading] = useState(false);
const [hasEnabledCollections] = useState(() => collections.find(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship));
const handleAddRelationship = useCallback(async (_, { relationTo, value, depth }) => {
setLoading(true);
const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=${depth}`);
const json = await res.json();
insertRelationship(editor, { value: json, depth, relationTo });
closeAll();
}, [editor, closeAll, api, serverURL]);
const modalSlug = `${path}-add-relationship`;
if (!hasEnabledCollections) return null;
return (
<Fragment>
<ElementButton
className={baseClass}
format="relationship"
onClick={() => open(modalSlug)}
>
<RelationshipIcon />
</ElementButton>
<Modal
slug={modalSlug}
className={`${baseClass}__modal`}
>
<MinimalTemplate>
<header className={`${baseClass}__header`}>
<h3>Add Relationship</h3>
<Button
buttonStyle="none"
onClick={closeAll}
>
<X />
</Button>
</header>
<Form
onSubmit={handleAddRelationship}
initialData={initialFormData}
disabled={loading}
>
<Fields />
<Submit>
Add relationship
</Submit>
</Form>
</MinimalTemplate>
</Modal>
</Fragment>
);
};
RelationshipButton.propTypes = {
path: PropTypes.string.isRequired,
};
export default RelationshipButton;

View File

@@ -0,0 +1,33 @@
@import '../../../../../../../scss/styles.scss';
.relationship-rich-text-button {
.btn {
margin-right: base(1);
}
&__modal {
display: flex;
align-items: center;
height: 100%;
&.payload__modal-item--enterDone {
@include blur-bg;
}
}
&__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,49 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '../../../../../../providers/Config';
import RelationshipIcon from '../../../../../../icons/Relationship';
import './index.scss';
const baseClass = 'rich-text-relationship';
const Element = ({ attributes, children, element }) => {
const { relationTo, value } = element;
const { collections } = useConfig();
const [relatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo));
return (
<div
className={baseClass}
contentEditable={false}
{...attributes}
>
<RelationshipIcon />
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__label`}>
{relatedCollection.labels.singular}
{' '}
Relationship
</div>
<h5>{value[relatedCollection?.admin?.useAsTitle || 'id']}</h5>
</div>
{children}
</div>
);
};
Element.defaultProps = {
attributes: {},
children: null,
};
Element.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
element: PropTypes.shape({
value: PropTypes.shape({}),
relationTo: PropTypes.string,
}).isRequired,
};
export default Element;

View File

@@ -0,0 +1,22 @@
@import '../../../../../../../scss/styles.scss';
.rich-text-relationship {
padding: base(.5);
display: flex;
align-items: flex-start;
background: $color-background-gray;
max-width: base(15);
margin-bottom: $baseline;
svg {
width: base(1.25);
height: base(1.25);
margin-right: base(.5);
}
h5 {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}

View File

@@ -0,0 +1,11 @@
import plugin from './plugin';
import Element from './Element';
import Button from './Button';
export default {
Button,
Element,
plugins: [
plugin,
],
};

View File

@@ -0,0 +1,10 @@
const withRelationship = (incomingEditor) => {
const editor = incomingEditor;
const { isVoid } = editor;
editor.isVoid = (element) => (element.type === 'relationship' ? true : isVoid(element));
return editor;
};
export default withRelationship;

View File

@@ -0,0 +1,30 @@
import { Transforms } from 'slate';
import isElementActive from './isActive';
import listTypes from './listTypes';
const toggleElement = (editor, format) => {
const isActive = isElementActive(editor, format);
const isList = listTypes.includes(format);
Transforms.unwrapNodes(editor, {
match: (n) => listTypes.includes(n.type),
split: true,
});
let type = format;
if (isActive) {
type = 'p';
} else if (isList) {
type = 'li';
}
Transforms.setNodes(editor, { type });
if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};
export default toggleElement;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ElementButton from '../Button';
import ULIcon from '../../../../../icons/UnorderedList';
const UL = ({ attributes, children }) => (
<ul {...attributes}>{children}</ul>
);
UL.defaultProps = {
attributes: {},
children: null,
};
UL.propTypes = {
attributes: PropTypes.shape({}),
children: PropTypes.node,
};
const ul = {
Button: () => (
<ElementButton format="ul">
<ULIcon />
</ElementButton>
),
Element: UL,
};
export default ul;

View File

@@ -0,0 +1,27 @@
import elementTypes from './elements';
import leafTypes from './leaves';
const addPluginReducer = (EditorWithPlugins, plugin) => {
if (typeof plugin === 'function') return plugin(EditorWithPlugins);
return EditorWithPlugins;
};
const enablePlugins = (CreatedEditor, functions) => functions.reduce((CreatedEditorWithPlugins, func) => {
if (typeof func === 'object' && Array.isArray(func.plugins)) {
return func.plugins.reduce(addPluginReducer, CreatedEditorWithPlugins);
}
if (typeof func === 'string') {
if (elementTypes[func] && elementTypes[func].plugins) {
return elementTypes[func].plugins.reduce(addPluginReducer, CreatedEditorWithPlugins);
}
if (leafTypes[func] && leafTypes[func].plugins) {
return leafTypes[func].plugins.reduce(addPluginReducer, CreatedEditorWithPlugins);
}
}
return CreatedEditorWithPlugins;
}, CreatedEditor);
export default enablePlugins;

View File

@@ -0,0 +1,6 @@
export default {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
'mod+`': 'code',
};

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
const RichText = lazy(() => import('./RichText'));
export default (props) => (
<Suspense fallback={<Loading />}>
<RichText {...props} />
</Suspense>
);

Some files were not shown because too many files have changed in this diff Show More