feat: retrofits array to new collapsible component

This commit is contained in:
James
2022-07-13 20:09:50 -07:00
parent 270dd22f08
commit aa89251a3b
32 changed files with 696 additions and 184 deletions

View File

@@ -88,7 +88,6 @@
"@babel/preset-typescript": "^7.12.1", "@babel/preset-typescript": "^7.12.1",
"@babel/register": "^7.11.5", "@babel/register": "^7.11.5",
"@date-io/date-fns": "^2.10.6", "@date-io/date-fns": "^2.10.6",
"@faceless-ui/collapsibles": "^1.0.0",
"@faceless-ui/modal": "^1.1.7", "@faceless-ui/modal": "^1.1.7",
"@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/scroll-info": "^1.2.3",
"@faceless-ui/window-info": "^2.0.2", "@faceless-ui/window-info": "^2.0.2",

View File

@@ -0,0 +1,54 @@
@import '../../../scss/styles.scss';
.array-actions {
&__button {
@extend %btn-reset;
cursor: pointer;
border-radius: 100px;
&:hover {
background: var(--theme-elevation-0);
}
}
&.popup--active .array-actions__button {
background: var(--theme-elevation-0);
}
&__button,
&__action {
@extend %btn-reset;
cursor: pointer;
}
&__actions {
list-style: none;
margin: 0;
padding: 0;
}
&__action {
@extend %btn-reset;
display: block;
svg {
position: relative;
top: -1px;
margin-right: base(.25);
.stroke {
stroke-width: 1px;
}
}
&:hover {
opacity: .7;
}
}
&__move-up {
svg {
transform: rotate(180deg);
}
}
}

View File

@@ -0,0 +1,95 @@
import React from 'react';
import Popup from '../Popup';
import More from '../../icons/More';
import Chevron from '../../icons/Chevron';
import { Props } from './types';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
import Copy from '../../icons/Copy';
import './index.scss';
const baseClass = 'array-actions';
export const ArrayAction: React.FC<Props> = ({
moveRow,
index,
rowCount,
addRow,
duplicateRow,
removeRow,
}) => {
return (
<Popup
horizontalAlign="center"
className={baseClass}
buttonClassName={`${baseClass}__button`}
button={<More />}
render={({ close }) => {
return (
<React.Fragment>
{index !== 0 && (
<button
className={`${baseClass}__action ${baseClass}__move-up`}
type="button"
onClick={() => {
moveRow(index, index - 1);
close();
}}
>
<Chevron />
Move Up
</button>
)}
{index < rowCount - 1 && (
<button
className={`${baseClass}__action ${baseClass}__move-down`}
type="button"
onClick={() => {
moveRow(index, index + 1);
close();
}}
>
<Chevron />
Move Down
</button>
)}
<button
className={`${baseClass}__action ${baseClass}__add`}
type="button"
onClick={() => {
addRow(index);
close();
}}
>
<Plus />
Add Below
</button>
<button
className={`${baseClass}__action ${baseClass}__duplicate`}
type="button"
onClick={() => {
duplicateRow(index);
close();
}}
>
<Copy />
Duplicate
</button>
<button
className={`${baseClass}__action ${baseClass}__remove`}
type="button"
onClick={() => {
removeRow(index);
close();
}}
>
<X />
Remove
</button>
</React.Fragment>
);
}}
/>
);
};

View File

@@ -0,0 +1,8 @@
export type Props = {
addRow: (current: number) => void
duplicateRow: (current: number) => void
removeRow: (index: number) => void
moveRow: (from: number, to: number) => void
index: number
rowCount: number
}

View File

@@ -1,6 +1,9 @@
@import '../../../scss/styles.scss'; @import '../../../scss/styles.scss';
.collapsible { .collapsible {
--toggle-pad-h: #{base(.75)};
--toggle-pad-v: #{base(.5)};
border: 1px solid var(--theme-elevation-200); border: 1px solid var(--theme-elevation-200);
border-radius: $style-radius-m; border-radius: $style-radius-m;
@@ -8,20 +11,42 @@
border: 1px solid var(--theme-elevation-300); border: 1px solid var(--theme-elevation-300);
} }
&__toggle-wrap {
position: relative;
}
&--hovered {
.collapsible__drag {
opacity: 1;
}
.collapsible__toggle {
background: var(--theme-elevation-100);
}
}
&__drag {
opacity: .5;
position: absolute;
z-index: 1;
top: var(--toggle-pad-v);
left: base(.5);
}
&__toggle { &__toggle {
@extend %btn-reset; @extend %btn-reset;
@extend %body;
text-align: left;
cursor: pointer; cursor: pointer;
background: var(--theme-elevation-50); background: var(--theme-elevation-50);
border-top-right-radius: $style-radius-s; border-top-right-radius: $style-radius-s;
border-top-left-radius: $style-radius-s; border-top-left-radius: $style-radius-s;
width: 100%; width: 100%;
display: flex; padding: var(--toggle-pad-v) var(--toggle-pad-h);
align-items: center; }
padding: base(.5) base(1);
&:hover { &__toggle--has-drag-handle {
background: var(--theme-elevation-100); padding-left: base(1.5);
}
} }
&--collapsed { &--collapsed {
@@ -35,12 +60,26 @@
} }
} }
&__actions-wrap {
position: absolute;
right: var(--toggle-pad-h);
top: var(--toggle-pad-v);
pointer-events: none;
display: flex;
}
&__actions {
pointer-events: all;
}
&__indicator { &__indicator {
margin: 0 0 0 auto;
transform: rotate(.5turn); transform: rotate(.5turn);
} }
&__content { &__content {
background-color: var(--theme-elevation-0);
border-bottom-left-radius: $style-radius-s;
border-bottom-right-radius: $style-radius-s;
padding: $baseline; padding: $baseline;
} }

View File

@@ -3,39 +3,75 @@ import AnimateHeight from 'react-animate-height';
import { Props } from './types'; import { Props } from './types';
import { CollapsibleProvider, useCollapsible } from './provider'; import { CollapsibleProvider, useCollapsible } from './provider';
import Chevron from '../../icons/Chevron'; import Chevron from '../../icons/Chevron';
import DragHandle from '../../icons/Drag';
import './index.scss'; import './index.scss';
const baseClass = 'collapsible'; const baseClass = 'collapsible';
export const Collapsible: React.FC<Props> = ({ children, onToggle, className, header, initCollapsed }) => { export const Collapsible: React.FC<Props> = ({
const [collapsed, setCollapsed] = useState(Boolean(initCollapsed)); children,
collapsed: collapsedFromProps,
onToggle,
className,
header,
initCollapsed,
dragHandleProps,
actions,
}) => {
const [collapsedLocal, setCollapsedLocal] = useState(Boolean(initCollapsed));
const [hovered, setHovered] = useState(false);
const isNested = useCollapsible(); const isNested = useCollapsible();
const collapsed = typeof collapsedFromProps === 'boolean' ? collapsedFromProps : collapsedLocal;
return ( return (
<div className={[ <div className={[
baseClass, baseClass,
className, className,
dragHandleProps && `${baseClass}--has-drag-handle`,
collapsed && `${baseClass}--collapsed`, collapsed && `${baseClass}--collapsed`,
isNested && `${baseClass}--nested`, isNested && `${baseClass}--nested`,
hovered && `${baseClass}--hovered`,
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
> >
<CollapsibleProvider> <CollapsibleProvider>
<button <div
type="button" className={`${baseClass}__toggle-wrap`}
className={`${baseClass}__toggle ${baseClass}__toggle--${collapsed ? 'collapsed' : 'open'}`} onMouseEnter={() => setHovered(true)}
onClick={() => { onMouseLeave={() => setHovered(false)}
if (typeof onToggle === 'function') onToggle(!collapsed);
setCollapsed(!collapsed);
}}
> >
{header && ( {dragHandleProps && (
<div className={`${baseClass}__header-wrap`}> <div
{header} className={`${baseClass}__drag`}
{...dragHandleProps}
>
<DragHandle />
</div> </div>
)} )}
<Chevron className={`${baseClass}__indicator`} /> <button
</button> type="button"
className={[
`${baseClass}__toggle`,
`${baseClass}__toggle--${collapsed ? 'collapsed' : 'open'}`,
dragHandleProps && `${baseClass}__toggle--has-drag-handle`,
].filter(Boolean).join(' ')}
onClick={() => {
if (typeof onToggle === 'function') onToggle(!collapsed);
setCollapsedLocal(!collapsed);
}}
>
{header && header}
</button>
<div className={`${baseClass}__actions-wrap`}>
{actions && (
<div className={`${baseClass}__actions`}>
{actions}
</div>
)}
<Chevron className={`${baseClass}__indicator`} />
</div>
</div>
<AnimateHeight <AnimateHeight
height={collapsed ? 0 : 'auto'} height={collapsed ? 0 : 'auto'}
duration={200} duration={200}

View File

@@ -1,9 +1,13 @@
import React from 'react'; import React from 'react';
import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd';
export type Props = { export type Props = {
collapsed?: boolean
className?: string className?: string
header?: React.ReactNode header?: React.ReactNode
actions?: React.ReactNode
children: React.ReactNode children: React.ReactNode
onToggle?: (collapsed: boolean) => void onToggle?: (collapsed: boolean) => void
initCollapsed?: boolean initCollapsed?: boolean
dragHandleProps?: DraggableProvidedDragHandleProps
} }

View File

@@ -7,6 +7,7 @@ const baseClass = 'popup-button';
const PopupButton: React.FC<Props> = (props) => { const PopupButton: React.FC<Props> = (props) => {
const { const {
className,
buttonType, buttonType,
button, button,
setActive, setActive,
@@ -15,6 +16,7 @@ const PopupButton: React.FC<Props> = (props) => {
const classes = [ const classes = [
baseClass, baseClass,
className,
`${baseClass}--${buttonType}`, `${baseClass}--${buttonType}`,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');

View File

@@ -1,4 +1,5 @@
export type Props = { export type Props = {
className?: string
buttonType: 'custom' | 'default' | 'none', buttonType: 'custom' | 'default' | 'none',
button: React.ReactNode, button: React.ReactNode,
setActive: (active: boolean) => void, setActive: (active: boolean) => void,

View File

@@ -43,7 +43,7 @@
&--size-small { &--size-small {
.popup__content { .popup__content {
@include shadow-sm; @include shadow-m;
} }
&.popup--h-align-left { &.popup--h-align-left {
@@ -69,7 +69,7 @@
&--size-wide { &--size-wide {
.popup__content { .popup__content {
@include shadow-sm; @include shadow-m;
&:after { &:after {
border: 12px solid transparent; border: 12px solid transparent;
@@ -109,11 +109,11 @@
&--h-align-center { &--h-align-center {
.popup__content { .popup__content {
left: 50%; left: 50%;
transform: translateX(-0%); transform: translateX(-50%);
&:after { &:after {
left: 50%; left: 50%;
transform: translateX(-0%); transform: translateX(-50%);
} }
} }
} }

View File

@@ -13,6 +13,7 @@ const baseClass = 'popup';
const Popup: React.FC<Props> = (props) => { const Popup: React.FC<Props> = (props) => {
const { const {
className, className,
buttonClassName,
render, render,
size = 'small', size = 'small',
color = 'light', color = 'light',
@@ -129,21 +130,11 @@ const Popup: React.FC<Props> = (props) => {
onMouseEnter={() => setActive(true)} onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)} onMouseLeave={() => setActive(false)}
> >
<PopupButton <PopupButton {...{ className: buttonClassName, buttonType, button, setActive, active }} />
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
</div> </div>
) )
: ( : (
<PopupButton <PopupButton {...{ className: buttonClassName, buttonType, button, setActive, active }} />
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
)} )}
</div> </div>

View File

@@ -2,6 +2,7 @@ import { CSSProperties } from 'react';
export type Props = { export type Props = {
className?: string className?: string
buttonClassName?: string
render?: (any) => React.ReactNode, render?: (any) => React.ReactNode,
children?: React.ReactNode, children?: React.ReactNode,
verticalAlign?: 'top' | 'bottom' verticalAlign?: 'top' | 'bottom'

View File

@@ -76,28 +76,6 @@ const DraggableSection: React.FC<Props> = (props) => {
<div className={`${baseClass}__render-fields-wrapper`}> <div className={`${baseClass}__render-fields-wrapper`}>
{blockType === 'blocks' && (
<div className={`${baseClass}__section-header`}>
<HiddenInput
name={`${parentPath}.${rowIndex}.id`}
value={id}
/>
<SectionTitle
label={label}
path={`${parentPath}.${rowIndex}.blockName`}
readOnly={readOnly}
/>
<Button
icon="chevron"
onClick={() => setRowCollapse(id, !isCollapsed)}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isCollapsed ? 'collapsed' : 'open'}`}
round
/>
</div>
)}
<AnimateHeight <AnimateHeight
height={isCollapsed ? 0 : 'auto'} height={isCollapsed ? 0 : 'auto'}
duration={200} duration={200}

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
export type DescriptionFunction = (value: unknown) => string export type DescriptionFunction = (value?: unknown) => string
export type DescriptionComponent = React.ComponentType<{ value: unknown }> export type DescriptionComponent = React.ComponentType<{ value: unknown }>
@@ -8,7 +8,7 @@ export type Description = string | DescriptionFunction | DescriptionComponent
export type Props = { export type Props = {
description?: Description description?: Description
value: unknown; value?: unknown;
} }
export function isComponent(description: Description): description is DescriptionComponent { export function isComponent(description: Description): description is DescriptionComponent {

View File

@@ -1,4 +1,5 @@
import equal from 'deep-equal'; import equal from 'deep-equal';
import ObjectID from 'bson-objectid';
import { unflatten, flatten } from 'flatley'; import { unflatten, flatten } from 'flatley';
import flattenFilters from './flattenFilters'; import flattenFilters from './flattenFilters';
import getSiblingData from './getSiblingData'; import getSiblingData from './getSiblingData';
@@ -109,6 +110,32 @@ function fieldReducer(state: Fields, action): Fields {
return newState; return newState;
} }
case 'DUPLICATE_ROW': {
const {
rowIndex, path,
} = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
const duplicate = {
...unflattenedRows[rowIndex],
id: new ObjectID().toHexString(),
};
// If there are subfields
if (Object.keys(duplicate).length > 0) {
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, duplicate);
}
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
};
return newState;
}
case 'MOVE_ROW': { case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action; const { moveFromIndex, moveToIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path); const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);

View File

@@ -1,10 +1,9 @@
import React, { useCallback, useEffect, useReducer, useState } from 'react'; import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { useAuth } from '../../../utilities/Auth'; import { useAuth } from '../../../utilities/Auth';
import withCondition from '../../withCondition'; import withCondition from '../../withCondition';
import Button from '../../../elements/Button'; import Button from '../../../elements/Button';
import DraggableSection from '../../DraggableSection'; import reducer, { Row } from '../rowReducer';
import reducer from '../rowReducer';
import { useForm } from '../../Form/context'; import { useForm } from '../../Form/context';
import buildStateFromSchema from '../../Form/buildStateFromSchema'; import buildStateFromSchema from '../../Form/buildStateFromSchema';
import useField from '../../useField'; import useField from '../../useField';
@@ -13,14 +12,18 @@ import Error from '../../Error';
import { array } from '../../../../../fields/validations'; import { array } from '../../../../../fields/validations';
import Banner from '../../../elements/Banner'; import Banner from '../../../elements/Banner';
import FieldDescription from '../../FieldDescription'; import FieldDescription from '../../FieldDescription';
import { Props } from './types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { useOperation } from '../../../utilities/OperationProvider'; import { useOperation } from '../../../utilities/OperationProvider';
import { Collapsible } from '../../../elements/Collapsible';
import RenderFields from '../../RenderFields';
import { fieldAffectsData } from '../../../../../fields/config/types';
import { Props } from './types';
import { usePreferences } from '../../../utilities/Preferences';
import { ArrayAction } from '../../../elements/ArrayAction';
import './index.scss'; import './index.scss';
const baseClass = 'field-type array'; const baseClass = 'array-field';
const ArrayFieldType: React.FC<Props> = (props) => { const ArrayFieldType: React.FC<Props> = (props) => {
const { const {
@@ -52,6 +55,8 @@ const ArrayFieldType: React.FC<Props> = (props) => {
// eslint-disable-next-line react/destructuring-assignment // eslint-disable-next-line react/destructuring-assignment
const label = props?.label ?? props?.labels?.singular; const label = props?.label ?? props?.labels?.singular;
const { preferencesKey, preferences } = useDocumentInfo();
const { setPreference } = usePreferences();
const [rows, dispatchRows] = useReducer(reducer, []); const [rows, dispatchRows] = useReducer(reducer, []);
const formContext = useForm(); const formContext = useForm();
const { user } = useAuth(); const { user } = useAuth();
@@ -81,20 +86,26 @@ const ArrayFieldType: React.FC<Props> = (props) => {
condition, condition,
}); });
const addRow = useCallback(async (rowIndex) => { const addRow = useCallback(async (rowIndex: number) => {
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale }); const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path }); dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
dispatchRows({ type: 'ADD', rowIndex }); dispatchRows({ type: 'ADD', rowIndex });
setValue(value as number + 1); setValue(value as number + 1);
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]); }, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
const removeRow = useCallback((rowIndex) => { const duplicateRow = useCallback(async (rowIndex: number) => {
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
dispatchRows({ type: 'ADD', rowIndex });
setValue(value as number + 1);
}, [dispatchRows, dispatchFields, path, setValue, value]);
const removeRow = useCallback((rowIndex: number) => {
dispatchRows({ type: 'REMOVE', rowIndex }); dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path }); dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
setValue(value as number - 1); setValue(value as number - 1);
}, [dispatchRows, dispatchFields, path, value, setValue]); }, [dispatchRows, dispatchFields, path, value, setValue]);
const moveRow = useCallback((moveFromIndex, moveToIndex) => { const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex }); dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]); }, [dispatchRows, dispatchFields, path]);
@@ -106,10 +117,57 @@ const ArrayFieldType: React.FC<Props> = (props) => {
moveRow(sourceIndex, destinationIndex); moveRow(sourceIndex, destinationIndex);
}, [moveRow]); }, [moveRow]);
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
if (preferencesKey) {
const preferencesToSet = preferences || { fields: {} };
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|| [];
if (!collapsed) {
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
} else {
newCollapsedState.push(rowID);
}
setPreference(preferencesKey, {
...preferencesToSet,
fields: {
...preferencesToSet?.fields || {},
[path]: {
...preferencesToSet?.fields?.[path],
collapsed: newCollapsedState,
},
},
});
}
}, [preferencesKey, preferences, path, setPreference, rows]);
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
if (preferencesKey) {
const preferencesToSet = preferences || { fields: {} };
setPreference(preferencesKey, {
...preferencesToSet,
fields: {
...preferencesToSet?.fields || {},
[path]: {
...preferencesToSet?.fields?.[path],
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
},
},
});
}
}, [path, preferences, preferencesKey, rows, setPreference]);
useEffect(() => { useEffect(() => {
const data = formContext.getDataByPath(path); const data = formContext.getDataByPath<Row[]>(path);
dispatchRows({ type: 'SET_ALL', data: data || [] }); dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
}, [formContext, path]); }, [formContext, path, preferences]);
useEffect(() => { useEffect(() => {
setValue(rows?.length || 0, true); setValue(rows?.length || 0, true);
@@ -124,6 +182,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
const hasMaxRows = maxRows && rows.length >= maxRows; const hasMaxRows = maxRows && rows.length >= maxRows;
const classes = [ const classes = [
'field-type',
baseClass, baseClass,
className, className,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
@@ -140,7 +199,29 @@ const ArrayFieldType: React.FC<Props> = (props) => {
/> />
</div> </div>
<header className={`${baseClass}__header`}> <header className={`${baseClass}__header`}>
<h3>{label}</h3> <div className={`${baseClass}__header-wrap`}>
<h3>{label}</h3>
<ul className={`${baseClass}__header-actions`}>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(true)}
className={`${baseClass}__header-action`}
>
Collapse All
</button>
</li>
<li>
<button
type="button"
onClick={() => toggleCollapseAll(false)}
className={`${baseClass}__header-action`}
>
Show All
</button>
</li>
</ul>
</div>
<FieldDescription <FieldDescription
value={value} value={value}
description={description} description={description}
@@ -153,23 +234,50 @@ const ArrayFieldType: React.FC<Props> = (props) => {
{...provided.droppableProps} {...provided.droppableProps}
> >
{rows.length > 0 && rows.map((row, i) => ( {rows.length > 0 && rows.map((row, i) => (
<DraggableSection <Draggable
readOnly={readOnly}
key={row.id} key={row.id}
id={row.id} draggableId={row.id}
blockType="array" index={i}
label={labels.singular} isDragDisabled={readOnly}
rowCount={rows.length} >
rowIndex={i} {(providedDrag) => (
addRow={addRow} <div
removeRow={removeRow} ref={providedDrag.innerRef}
moveRow={moveRow} {...providedDrag.draggableProps}
parentPath={path} >
fieldTypes={fieldTypes} <Collapsible
fieldSchema={fields} collapsed={row.collapsed}
permissions={permissions} onToggle={(collapsed) => setCollapse(row.id, collapsed)}
hasMaxRows={hasMaxRows} className={`${baseClass}__row`}
/> key={row.id}
dragHandleProps={providedDrag.dragHandleProps}
header={`${labels.singular} ${i + 1}`}
actions={!readOnly ? (
<ArrayAction
rowCount={rows.length}
duplicateRow={duplicateRow}
addRow={addRow}
moveRow={moveRow}
removeRow={removeRow}
index={i}
/>
) : undefined}
>
<RenderFields
forceRender
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions.fields}
fieldSchema={fields.map((field) => ({
...field,
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
}))}
/>
</Collapsible>
</div>
)}
</Draggable>
))} ))}
{(rows.length < minRows || (required && rows.length === 0)) && ( {(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error"> <Banner type="error">
@@ -195,7 +303,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
{(!readOnly && (!hasMaxRows)) && ( {(!readOnly && (!hasMaxRows)) && (
<div className={`${baseClass}__add-button-wrap`}> <div className={`${baseClass}__add-button-wrap`}>
<Button <Button
onClick={() => addRow(value)} onClick={() => addRow(value as number)}
buttonStyle="icon-label" buttonStyle="icon-label"
icon="plus" icon="plus"
iconStyle="with-border" iconStyle="with-border"

View File

@@ -1,8 +1,7 @@
@import '../../../../scss/styles.scss'; @import '../../../../scss/styles.scss';
.field-type.array { .array-field {
margin: base(2) 0; margin: base(2) 0;
min-width: base(15);
&__header { &__header {
h3 { h3 {
@@ -12,12 +11,40 @@
margin-bottom: base(1); margin-bottom: base(1);
} }
&__header-wrap {
display: flex;
align-items: flex-end;
width: 100%;
}
&__header-actions {
list-style: none;
margin: 0 0 0 auto;
padding: 0;
display: flex;
}
&__header-action {
@extend %btn-reset;
cursor: pointer;
margin-left: base(.5);
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
&__row {
margin-bottom: base(.5);
}
&__error-wrap { &__error-wrap {
position: relative; position: relative;
} }
&__add-button-wrap { &__add-button-wrap {
margin-left: base(0); margin-top: base(1);
.btn { .btn {
color: var(--theme-elevation-400); color: var(--theme-elevation-400);
@@ -29,35 +56,7 @@
} }
} }
.section .section { .field-type:last-child {
margin-top: 0; margin-bottom: 0;
}
.section__content {
>div>div {
width: 100%;
}
}
.render-fields {
.row {
&:last-child {
margin-bottom: 0;
}
}
}
@include mid-break {
min-width: calc(100vw - #{base(2)});
}
}
.field-type.group,
.field-type.array,
.field-type.blocks {
.field-type.array {
.field-type.array__add-button-wrap {
margin-left: base(3);
}
} }
} }

View File

@@ -54,8 +54,7 @@ const Blocks: React.FC<Props> = (props) => {
const path = pathFromProps || name; const path = pathFromProps || name;
const { preferencesKey, preferences } = useDocumentInfo(); const { preferencesKey, preferences } = useDocumentInfo();
const { setPreference } = usePreferences();
const { getPreference, setPreference } = usePreferences();
const [rows, dispatchRows] = useReducer(reducer, []); const [rows, dispatchRows] = useReducer(reducer, []);
const formContext = useForm(); const formContext = useForm();
const { user } = useAuth(); const { user } = useAuth();

View File

@@ -8,6 +8,7 @@ import toKebabCase from '../../../../../utilities/toKebabCase';
import { usePreferences } from '../../../utilities/Preferences'; import { usePreferences } from '../../../utilities/Preferences';
import { DocumentPreferences } from '../../../../../preferences/types'; import { DocumentPreferences } from '../../../../../preferences/types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import FieldDescription from '../../FieldDescription';
import './index.scss'; import './index.scss';
@@ -23,6 +24,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
admin: { admin: {
readOnly, readOnly,
className, className,
description,
}, },
} = props; } = props;
@@ -45,32 +47,33 @@ const CollapsibleField: React.FC<Props> = (props) => {
}); });
}, [preferencesKey, fieldPreferencesKey, getPreference, setPreference]); }, [preferencesKey, fieldPreferencesKey, getPreference, setPreference]);
// Do not render until preferences are retrieved
// So we can properly render the field as open or closed by default
if (!preferences) return null;
return ( return (
<Collapsible <React.Fragment>
initCollapsed={Boolean(preferences.fields[fieldPreferencesKey]?.collapsed)} <Collapsible
className={[ initCollapsed={Boolean(preferences.fields[fieldPreferencesKey]?.collapsed)}
'field-type', className={[
baseClass, 'field-type',
className, baseClass,
].filter(Boolean).join(' ')} className,
header={<div className={`${baseClass}__label`}>{label}</div>} ].filter(Boolean).join(' ')}
onToggle={onToggle} header={<div className={`${baseClass}__label`}>{label}</div>}
> onToggle={onToggle}
<RenderFields >
forceRender <RenderFields
readOnly={readOnly} forceRender
permissions={permissions?.fields} readOnly={readOnly}
fieldTypes={fieldTypes} permissions={permissions?.fields}
fieldSchema={fields.map((field) => ({ fieldTypes={fieldTypes}
...field, fieldSchema={fields.map((field) => ({
path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`, ...field,
}))} path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
}))}
/>
</Collapsible>
<FieldDescription
description={description}
/> />
</Collapsible> </React.Fragment>
); );
}; };

View File

@@ -1,14 +1,55 @@
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
const reducer = (currentState, action) => { export type Row = {
const { id: string
type, rowIndex, moveFromIndex, moveToIndex, data, blockType, collapsedState, collapsed, id, collapsed?: boolean
} = action; blockType?: string
}
type SET_ALL = {
type: 'SET_ALL'
data: { id?: string, blockType?: string }[]
collapsedState?: string[]
blockType?: string
}
type SET_COLLAPSE = {
type: 'SET_COLLAPSE'
id: string
collapsed: boolean
}
type SET_ALL_COLLAPSED = {
type: 'SET_ALL_COLLAPSED'
collapse: boolean
}
type ADD = {
type: 'ADD'
rowIndex: number
blockType?: string
}
type REMOVE = {
type: 'REMOVE'
rowIndex: number
}
type MOVE = {
type: 'MOVE'
moveFromIndex: number
moveToIndex: number
}
type Action = SET_ALL | SET_COLLAPSE | SET_ALL_COLLAPSED | ADD | REMOVE | MOVE;
const reducer = (currentState: Row[], action: Action): Row[] => {
const stateCopy = [...currentState]; const stateCopy = [...currentState];
switch (type) { switch (action.type) {
case 'SET_ALL': { case 'SET_ALL': {
const { data, collapsedState } = action;
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map((dataRow, i) => { return data.map((dataRow, i) => {
const row = { const row = {
@@ -25,6 +66,8 @@ const reducer = (currentState, action) => {
} }
case 'SET_COLLAPSE': { case 'SET_COLLAPSE': {
const { collapsed, id } = action;
const matchedRowIndex = stateCopy.findIndex(({ id: rowID }) => rowID === id); const matchedRowIndex = stateCopy.findIndex(({ id: rowID }) => rowID === id);
if (matchedRowIndex > -1 && stateCopy[matchedRowIndex]) { if (matchedRowIndex > -1 && stateCopy[matchedRowIndex]) {
@@ -34,10 +77,25 @@ const reducer = (currentState, action) => {
return stateCopy; return stateCopy;
} }
case 'SET_ALL_COLLAPSED': {
const { collapse } = action;
const newState = stateCopy.map((row) => ({
...row,
collapsed: collapse,
}));
console.log(newState);
return newState;
}
case 'ADD': { case 'ADD': {
const { rowIndex, blockType } = action;
const newRow = { const newRow = {
id: new ObjectID().toHexString(), id: new ObjectID().toHexString(),
open: true, collapsed: false,
blockType: undefined, blockType: undefined,
}; };
@@ -48,11 +106,14 @@ const reducer = (currentState, action) => {
return stateCopy; return stateCopy;
} }
case 'REMOVE': case 'REMOVE': {
const { rowIndex } = action;
stateCopy.splice(rowIndex, 1); stateCopy.splice(rowIndex, 1);
return stateCopy; return stateCopy;
}
case 'MOVE': { case 'MOVE': {
const { moveFromIndex, moveToIndex } = action;
const movingRowState = { ...stateCopy[moveFromIndex] }; const movingRowState = { ...stateCopy[moveFromIndex] };
stateCopy.splice(moveFromIndex, 1); stateCopy.splice(moveFromIndex, 1);
stateCopy.splice(moveToIndex, 0, movingRowState); stateCopy.splice(moveToIndex, 0, movingRowState);

View File

@@ -0,0 +1,10 @@
@import '../../../scss/styles';
.icon--drag-handle {
height: $baseline;
width: $baseline;
.fill {
fill: var(--theme-elevation-800);
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import './index.scss';
const DragHandle: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={[
'icon icon--drag-handle',
className,
].filter(Boolean).join(' ')}
viewBox="0 0 25 25"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="10.468"
cy="14.5"
r="1"
className="fill"
/>
<circle
cx="14.532"
cy="14.5"
r="1"
className="fill"
/>
<circle
cx="10.468"
cy="11.35"
r="1"
className="fill"
/>
<circle
cx="14.532"
cy="11.35"
r="1"
className="fill"
/>
<circle
cx="10.468"
cy="8.3"
r="1"
className="fill"
/>
<circle
cx="14.532"
cy="8.3"
r="1"
className="fill"
/>
</svg>
);
export default DragHandle;

View File

@@ -0,0 +1,10 @@
@import '../../../scss/styles';
.icon--more {
height: $baseline;
width: $baseline;
.fill {
fill: var(--theme-elevation-800);
}
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import './index.scss';
const DragHandle: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={[
'icon icon--more',
className,
].filter(Boolean).join(' ')}
viewBox="0 0 25 25"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="16.3872"
cy="12.5"
r="1"
className="fill"
/>
<circle
cx="12.3872"
cy="12.5"
r="1"
className="fill"
/>
<circle
cx="8.61279"
cy="12.5"
r="1"
className="fill"
/>
</svg>
);
export default DragHandle;

View File

@@ -18,7 +18,7 @@ const AccountView: React.FC = () => {
const { setStepNav } = useStepNav(); const { setStepNav } = useStepNav();
const { user, permissions } = useAuth(); const { user, permissions } = useAuth();
const [initialState, setInitialState] = useState({}); const [initialState, setInitialState] = useState({});
const { id } = useDocumentInfo(); const { id, preferences } = useDocumentInfo();
const { const {
serverURL, serverURL,
@@ -44,7 +44,7 @@ const AccountView: React.FC = () => {
const collectionPermissions = permissions?.collections?.[adminUser]; const collectionPermissions = permissions?.collections?.[adminUser];
const [{ data, isLoading }] = usePayloadAPI( const [{ data, isLoading: isLoadingDocument }] = usePayloadAPI(
`${serverURL}${api}/${collection?.slug}/${user?.id}?depth=0`, `${serverURL}${api}/${collection?.slug}/${user?.id}?depth=0`,
{ initialParams: { 'fallback-locale': 'null' } }, { initialParams: { 'fallback-locale': 'null' } },
); );
@@ -85,7 +85,7 @@ const AccountView: React.FC = () => {
hasSavePermission, hasSavePermission,
initialState, initialState,
apiURL, apiURL,
isLoading, isLoading: isLoadingDocument || !preferences,
}} }}
/> />
</NegativeFieldGutterProvider> </NegativeFieldGutterProvider>

View File

@@ -20,7 +20,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const { setStepNav } = useStepNav(); const { setStepNav } = useStepNav();
const { permissions, user } = useAuth(); const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState({}); const [initialState, setInitialState] = useState({});
const { getVersions } = useDocumentInfo(); const { getVersions, preferences } = useDocumentInfo();
const { const {
serverURL, serverURL,
@@ -50,7 +50,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
setInitialState(state); setInitialState(state);
}, [getVersions, fields, user, locale]); }, [getVersions, fields, user, locale]);
const [{ data, isLoading }] = usePayloadAPI( const [{ data, isLoading: isLoadingDocument }] = usePayloadAPI(
`${serverURL}${api}/globals/${slug}`, `${serverURL}${api}/globals/${slug}`,
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
); );
@@ -82,7 +82,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
DefaultComponent={DefaultGlobal} DefaultComponent={DefaultGlobal}
CustomComponent={CustomEdit} CustomComponent={CustomEdit}
componentProps={{ componentProps={{
isLoading, isLoading: isLoadingDocument || !preferences,
data: dataToRender, data: dataToRender,
permissions: globalPermissions, permissions: globalPermissions,
initialState, initialState,

View File

@@ -44,7 +44,7 @@ const EditView: React.FC<IndexProps> = (props) => {
const { setStepNav } = useStepNav(); const { setStepNav } = useStepNav();
const [initialState, setInitialState] = useState({}); const [initialState, setInitialState] = useState({});
const { permissions, user } = useAuth(); const { permissions, user } = useAuth();
const { getVersions } = useDocumentInfo(); const { getVersions, preferences } = useDocumentInfo();
const onSave = useCallback(async (json: any) => { const onSave = useCallback(async (json: any) => {
getVersions(); getVersions();
@@ -56,7 +56,7 @@ const EditView: React.FC<IndexProps> = (props) => {
} }
}, [admin, collection, history, isEditing, getVersions, user, id, locale]); }, [admin, collection, history, isEditing, getVersions, user, id, locale]);
const [{ data, isLoading, isError }] = usePayloadAPI( const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null), (isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
); );
@@ -97,7 +97,7 @@ const EditView: React.FC<IndexProps> = (props) => {
}, [setStepNav, isEditing, pluralLabel, dataToRender, slug, useAsTitle, admin]); }, [setStepNav, isEditing, pluralLabel, dataToRender, slug, useAsTitle, admin]);
useEffect(() => { useEffect(() => {
if (isLoading) { if (isLoadingDocument) {
return; return;
} }
const awaitInitialState = async () => { const awaitInitialState = async () => {
@@ -106,7 +106,7 @@ const EditView: React.FC<IndexProps> = (props) => {
}; };
awaitInitialState(); awaitInitialState();
}, [dataToRender, fields, isEditing, id, user, locale, isLoading]); }, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument]);
if (isError) { if (isError) {
return ( return (
@@ -125,7 +125,7 @@ const EditView: React.FC<IndexProps> = (props) => {
DefaultComponent={DefaultEdit} DefaultComponent={DefaultEdit}
CustomComponent={CustomEdit} CustomComponent={CustomEdit}
componentProps={{ componentProps={{
isLoading, isLoading: isLoadingDocument || !preferences,
data: dataToRender, data: dataToRender,
collection, collection,
permissions: collectionPermissions, permissions: collectionPermissions,

View File

@@ -25,16 +25,16 @@
} }
.Toastify__toast--success { .Toastify__toast--success {
color: var(--color-success-900); color: var(--color-success-100);
background: var(--color-success-500); background: var(--color-success-500);
.Toastify__progress-bar { .Toastify__progress-bar {
background-color: var(--color-success-900); background-color: var(--color-success-100);
} }
} }
.Toastify__close-button--success { .Toastify__close-button--success {
color: var(--color-success-900); color: var(--color-success-100);
} }
.Toastify__toast--error { .Toastify__toast--error {

View File

@@ -70,7 +70,11 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--color-success-500);
////////////////////////////// //////////////////////////////
@mixin shadow-sm { @mixin shadow-sm {
box-shadow: 0 2px 3px 0 rgba(0, 2, 4, 0.1), 0 6px 4px -4px rgba(0, 2, 4, 0.02); box-shadow: 0 2px 3px 0 rgba(0, 2, 4, 0.05), 0 10px 4px -8px rgba(0, 2, 4, 0.02);
}
@mixin shadow-m {
box-shadow: 0 0 30px 0 rgb(0 2 4 / 12%), 0 30px 25px -8px rgb(0 2 4 / 10%);
} }
@mixin shadow-lg { @mixin shadow-lg {

View File

@@ -8,7 +8,7 @@ export default buildConfig({
slug: 'array-fields', slug: 'array-fields',
fields: [ fields: [
{ {
name: 'array', name: 'items',
type: 'array', type: 'array',
required: true, required: true,
fields: [ fields: [
@@ -59,6 +59,9 @@ export default buildConfig({
{ {
label: 'Collapsible Field', label: 'Collapsible Field',
type: 'collapsible', type: 'collapsible',
admin: {
description: 'This is a collapsible field.',
},
fields: [ fields: [
{ {
name: 'text', name: 'text',

View File

@@ -1,5 +1,5 @@
export const arrayDoc = { export const arrayDoc = {
array: [ items: [
{ {
text: 'first row', text: 'first row',
}, },

View File

@@ -1265,14 +1265,6 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
"@faceless-ui/collapsibles@^1.0.0":
version "1.1.1"
resolved "https://registry.npmjs.org/@faceless-ui/collapsibles/-/collapsibles-1.1.1.tgz#89b86e11eceae55ed87caa4de9d395f7b082a938"
integrity sha512-7tU5CjXUnW7bKthpMgNKs5TirTb+DQVFASnstqlpe+gXl8W76Rq4Z9m6nS3PnnG2B5h9aqtUVaNgroLc1J+i/w==
dependencies:
react-animate-height "^2.0.16"
react-transition-group "^4.4.2"
"@faceless-ui/modal@^1.1.7": "@faceless-ui/modal@^1.1.7":
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-1.2.0.tgz#0ca43e480f83d307dcd84c033fbc82c0619f5d8c" resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-1.2.0.tgz#0ca43e480f83d307dcd84c033fbc82c0619f5d8c"
@@ -10322,7 +10314,7 @@ rc@1.2.8, rc@^1.2.7, rc@^1.2.8:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-animate-height@^2.0.16, react-animate-height@^2.0.20: react-animate-height@^2.0.20:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/react-animate-height/-/react-animate-height-2.1.2.tgz#9b450fc64d46f10f5e07da8d0d5e2c47b9f15030" resolved "https://registry.npmjs.org/react-animate-height/-/react-animate-height-2.1.2.tgz#9b450fc64d46f10f5e07da8d0d5e2c47b9f15030"
integrity sha512-A9jfz/4CTdsIsE7WCQtO9UkOpMBcBRh8LxyHl2eoZz1ki02jpyUL5xt58gabd0CyeLQ8fRyQ+s2lyV2Ufu8Owg== integrity sha512-A9jfz/4CTdsIsE7WCQtO9UkOpMBcBRh8LxyHl2eoZz1ki02jpyUL5xt58gabd0CyeLQ8fRyQ+s2lyV2Ufu8Owg==