renames client to admin, sets up component library
This commit is contained in:
125
src/admin/components/forms/DraggableSection/ActionPanel/index.js
Normal file
125
src/admin/components/forms/DraggableSection/ActionPanel/index.js
Normal 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
|
||||
{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
|
||||
{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;
|
||||
@@ -0,0 +1,12 @@
|
||||
@import '../../../../scss/styles';
|
||||
|
||||
.action-panel {
|
||||
|
||||
&__remove-row {
|
||||
margin: 0 0 base(.3);
|
||||
}
|
||||
|
||||
&__add-row {
|
||||
margin: base(.3) 0 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../../../scss/styles';
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
173
src/admin/components/forms/DraggableSection/index.js
Normal file
173
src/admin/components/forms/DraggableSection/index.js
Normal 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;
|
||||
111
src/admin/components/forms/DraggableSection/index.scss
Normal file
111
src/admin/components/forms/DraggableSection/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/admin/components/forms/Error/index.js
Normal file
31
src/admin/components/forms/Error/index.js
Normal 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;
|
||||
13
src/admin/components/forms/Error/index.scss
Normal file
13
src/admin/components/forms/Error/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/admin/components/forms/FieldTypeGutter/context.js
Normal file
27
src/admin/components/forms/FieldTypeGutter/context.js
Normal 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,
|
||||
};
|
||||
53
src/admin/components/forms/FieldTypeGutter/index.js
Normal file
53
src/admin/components/forms/FieldTypeGutter/index.js
Normal 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;
|
||||
85
src/admin/components/forms/FieldTypeGutter/index.scss
Normal file
85
src/admin/components/forms/FieldTypeGutter/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/admin/components/forms/Form/buildInitialState.js
Normal file
16
src/admin/components/forms/Form/buildInitialState.js
Normal 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;
|
||||
120
src/admin/components/forms/Form/buildStateFromSchema.js
Normal file
120
src/admin/components/forms/Form/buildStateFromSchema.js
Normal 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;
|
||||
17
src/admin/components/forms/Form/buildStateFromSchema.spec.js
Normal file
17
src/admin/components/forms/Form/buildStateFromSchema.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
26
src/admin/components/forms/Form/context.js
Normal file
26
src/admin/components/forms/Form/context.js
Normal 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,
|
||||
};
|
||||
3
src/admin/components/forms/Form/errorMessages.js
Normal file
3
src/admin/components/forms/Form/errorMessages.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
413: 'Your request was too large to submit successfully.',
|
||||
};
|
||||
131
src/admin/components/forms/Form/fieldReducer.js
Normal file
131
src/admin/components/forms/Form/fieldReducer.js
Normal 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;
|
||||
10
src/admin/components/forms/Form/flattenFilters.js
Normal file
10
src/admin/components/forms/Form/flattenFilters.js
Normal 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;
|
||||
24
src/admin/components/forms/Form/getDataByPath.js
Normal file
24
src/admin/components/forms/Form/getDataByPath.js
Normal 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;
|
||||
25
src/admin/components/forms/Form/getSiblingData.js
Normal file
25
src/admin/components/forms/Form/getSiblingData.js
Normal 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;
|
||||
398
src/admin/components/forms/Form/index.js
Normal file
398
src/admin/components/forms/Form/index.js
Normal 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;
|
||||
5
src/admin/components/forms/Form/index.scss
Normal file
5
src/admin/components/forms/Form/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.form {
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
15
src/admin/components/forms/Form/initContextState.js
Normal file
15
src/admin/components/forms/Form/initContextState.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
getFields: () => { },
|
||||
getField: () => { },
|
||||
getData: () => { },
|
||||
getSiblingData: () => { },
|
||||
getDataByPath: () => undefined,
|
||||
getUnflattenedValues: () => { },
|
||||
validateForm: () => { },
|
||||
createFormData: () => { },
|
||||
submit: () => { },
|
||||
dispatchFields: () => { },
|
||||
setModified: () => { },
|
||||
initialState: {},
|
||||
reset: 0,
|
||||
};
|
||||
24
src/admin/components/forms/Form/reduceFieldsToValues.js
Normal file
24
src/admin/components/forms/Form/reduceFieldsToValues.js
Normal 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;
|
||||
40
src/admin/components/forms/Label/index.js
Normal file
40
src/admin/components/forms/Label/index.js
Normal 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;
|
||||
13
src/admin/components/forms/Label/index.scss
Normal file
13
src/admin/components/forms/Label/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
160
src/admin/components/forms/RenderFields/index.js
Normal file
160
src/admin/components/forms/RenderFields/index.js
Normal 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
|
||||
{' '}
|
||||
"
|
||||
{field.label}
|
||||
"
|
||||
</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;
|
||||
33
src/admin/components/forms/Submit/index.js
Normal file
33
src/admin/components/forms/Submit/index.js
Normal 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;
|
||||
5
src/admin/components/forms/Submit/index.scss
Normal file
5
src/admin/components/forms/Submit/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
form > .form-submit {
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
320
src/admin/components/forms/field-types/Array/Array.js
Normal file
320
src/admin/components/forms/field-types/Array/Array.js
Normal 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);
|
||||
10
src/admin/components/forms/field-types/Array/index.js
Normal file
10
src/admin/components/forms/field-types/Array/index.js
Normal 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>
|
||||
);
|
||||
47
src/admin/components/forms/field-types/Array/index.scss
Normal file
47
src/admin/components/forms/field-types/Array/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.block-selector {
|
||||
|
||||
@include mid-break {
|
||||
min-width: 80vw;
|
||||
}
|
||||
}
|
||||
383
src/admin/components/forms/field-types/Blocks/Blocks.js
Normal file
383
src/admin/components/forms/field-types/Blocks/Blocks.js
Normal 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);
|
||||
10
src/admin/components/forms/field-types/Blocks/index.js
Normal file
10
src/admin/components/forms/field-types/Blocks/index.js
Normal 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>
|
||||
);
|
||||
37
src/admin/components/forms/field-types/Blocks/index.scss
Normal file
37
src/admin/components/forms/field-types/Blocks/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/admin/components/forms/field-types/Checkbox/index.js
Normal file
116
src/admin/components/forms/field-types/Checkbox/index.js
Normal 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);
|
||||
69
src/admin/components/forms/field-types/Checkbox/index.scss
Normal file
69
src/admin/components/forms/field-types/Checkbox/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/admin/components/forms/field-types/Code/Code.js
Normal file
125
src/admin/components/forms/field-types/Code/Code.js
Normal 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);
|
||||
10
src/admin/components/forms/field-types/Code/index.js
Normal file
10
src/admin/components/forms/field-types/Code/index.js
Normal 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>
|
||||
);
|
||||
155
src/admin/components/forms/field-types/Code/index.scss
Normal file
155
src/admin/components/forms/field-types/Code/index.scss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/admin/components/forms/field-types/DateTime/index.js
Normal file
107
src/admin/components/forms/field-types/DateTime/index.js
Normal 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);
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../../scss/styles';
|
||||
|
||||
.date-time-field {
|
||||
&--has-error {
|
||||
.react-datepicker__input-container input {
|
||||
background-color: lighten($color-red, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/admin/components/forms/field-types/Email/index.js
Normal file
110
src/admin/components/forms/field-types/Email/index.js
Normal 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);
|
||||
15
src/admin/components/forms/field-types/Email/index.scss
Normal file
15
src/admin/components/forms/field-types/Email/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/admin/components/forms/field-types/Group/index.js
Normal file
80
src/admin/components/forms/field-types/Group/index.js
Normal 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);
|
||||
37
src/admin/components/forms/field-types/Group/index.scss
Normal file
37
src/admin/components/forms/field-types/Group/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/admin/components/forms/field-types/HiddenInput/index.js
Normal file
46
src/admin/components/forms/field-types/HiddenInput/index.js
Normal 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);
|
||||
119
src/admin/components/forms/field-types/Number/index.js
Normal file
119
src/admin/components/forms/field-types/Number/index.js
Normal 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);
|
||||
15
src/admin/components/forms/field-types/Number/index.scss
Normal file
15
src/admin/components/forms/field-types/Number/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/admin/components/forms/field-types/Password/index.js
Normal file
99
src/admin/components/forms/field-types/Password/index.js
Normal 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);
|
||||
15
src/admin/components/forms/field-types/Password/index.scss
Normal file
15
src/admin/components/forms/field-types/Password/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/admin/components/forms/field-types/RadioGroup/index.js
Normal file
124
src/admin/components/forms/field-types/RadioGroup/index.js
Normal 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);
|
||||
21
src/admin/components/forms/field-types/RadioGroup/index.scss
Normal file
21
src/admin/components/forms/field-types/RadioGroup/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
483
src/admin/components/forms/field-types/Relationship/index.js
Normal file
483
src/admin/components/forms/field-types/Relationship/index.js
Normal 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);
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
276
src/admin/components/forms/field-types/RichText/RichText.js
Normal file
276
src/admin/components/forms/field-types/RichText/RichText.js
Normal 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);
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.rich-text__button {
|
||||
svg {
|
||||
width: base(.75);
|
||||
height: base(.75);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export default ['ol', 'ul'];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import plugin from './plugin';
|
||||
import Element from './Element';
|
||||
import Button from './Button';
|
||||
|
||||
export default {
|
||||
Button,
|
||||
Element,
|
||||
plugins: [
|
||||
plugin,
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'mod+b': 'bold',
|
||||
'mod+i': 'italic',
|
||||
'mod+u': 'underline',
|
||||
'mod+`': 'code',
|
||||
};
|
||||
10
src/admin/components/forms/field-types/RichText/index.js
Normal file
10
src/admin/components/forms/field-types/RichText/index.js
Normal 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
Reference in New Issue
Block a user