Merge remote-tracking branch 'origin/master' into pr/2500

This commit is contained in:
Alessio Gravili
2023-08-14 15:23:19 +02:00
1492 changed files with 158637 additions and 20523 deletions

View File

@@ -1,3 +1,5 @@
'use client';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - need to do this because this file doesn't actually exist
import config from 'payload-config';

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -1,7 +1,5 @@
import React, { Suspense, lazy, useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect,
} from 'react-router-dom';
import React, { Fragment, lazy, Suspense, useEffect, useState } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from './utilities/Auth';
import { useConfig } from './utilities/Config';
@@ -28,7 +26,7 @@ const ResetPassword = lazy(() => import('./views/ResetPassword'));
const Unauthorized = lazy(() => import('./views/Unauthorized'));
const Account = lazy(() => import('./views/Account'));
const Routes = () => {
const Routes: React.FC = () => {
const [initialized, setInitialized] = useState(null);
const { user, permissions, refreshCookie } = useAuth();
const { i18n } = useTranslation();
@@ -37,6 +35,7 @@ const Routes = () => {
const canAccessAdmin = permissions?.canAccessAdmin;
const config = useConfig();
const {
admin: {
user: userSlug,
@@ -117,7 +116,6 @@ const Routes = () => {
/>
</Route>
))}
<Route path={`${match.url}/login`}>
<Login />
</Route>
@@ -127,7 +125,6 @@ const Routes = () => {
<Route path={`${match.url}${logoutInactivityRoute}`}>
<Logout inactivity />
</Route>
{!userCollection.auth.disableLocalStrategy && (
<Route path={`${match.url}/forgot`}>
<ForgotPassword />
@@ -155,93 +152,73 @@ const Routes = () => {
return null;
})}
<Route
render={() => {
if (user) {
if (canAccessAdmin) {
return (
<DefaultTemplate>
<Switch>
<Route
path={`${match.url}/`}
exact
<Route>
{user ? (
<Fragment>
{canAccessAdmin && (
<DefaultTemplate>
<Switch>
<Route
path={`${match.url}/`}
exact
>
<Dashboard />
</Route>
<Route path={`${match.url}/account`}>
<DocumentInfoProvider
collection={collections.find(({ slug }) => slug === userSlug)}
id={user.id}
>
<Dashboard />
</Route>
<Route path={`${match.url}/account`}>
<DocumentInfoProvider
collection={collections.find(({ slug }) => slug === userSlug)}
id={user.id}
>
<Account />
</DocumentInfoProvider>
</Route>
{collections.reduce((collectionRoutes, collection) => {
<Account />
</DocumentInfoProvider>
</Route>
{collections
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.reduce((collectionRoutes, collection) => {
const routesToReturn = [
...collectionRoutes,
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.collections?.[collection.slug]?.read?.permission
? <List collection={collection} />
: <Unauthorized />}
</Route>,
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.create?.permission) {
return (
<DocumentInfoProvider collection={collection}>
<Edit
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.collections?.[collection.slug]?.create?.permission ? (
<DocumentInfoProvider
collection={collection}
idFromParams
>
<Edit collection={collection} />
</DocumentInfoProvider>
) : (
<Unauthorized />
)}
</Route>,
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
const { match: { params: { id } } } = routeProps;
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<DocumentInfoProvider
key={`${collection.slug}-edit-${id}-${locale}`}
collection={collection}
id={id}
>
<Edit
isEditing
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.collections?.[collection.slug]?.read?.permission ? (
<DocumentInfoProvider
collection={collection}
idFromParams
>
<Edit
isEditing
collection={collection}
/>
</DocumentInfoProvider>
) : <Unauthorized />}
</Route>,
];
if (collection.versions) {
@@ -250,19 +227,11 @@ const Routes = () => {
key={`${collection.slug}-versions`}
path={`${match.url}/collections/${collection.slug}/:id/versions`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.collections?.[collection.slug]?.readVersions?.permission ? (
<Versions collection={collection} />
) : <Unauthorized />}
</Route>,
);
routesToReturn.push(
@@ -270,55 +239,41 @@ const Routes = () => {
key={`${collection.slug}-view-version`}
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return (
<DocumentInfoProvider
collection={collection}
id={routeProps.match.params.id}
>
<Version
{...routeProps}
collection={collection}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.collections?.[collection.slug]?.readVersions?.permission ? (
<DocumentInfoProvider
collection={collection}
idFromParams
>
<Version collection={collection} />
</DocumentInfoProvider>
) : <Unauthorized />}
</Route>,
);
}
return routesToReturn;
}, [])}
{globals && globals.reduce((globalRoutes, global) => {
{globals && globals
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.reduce((globalRoutes, global) => {
const routesToReturn = [
...globalRoutes,
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.read?.permission) {
return (
<DocumentInfoProvider
global={global}
key={`${global.slug}-${locale}`}
>
<EditGlobal
{...routeProps}
global={global}
/>
</DocumentInfoProvider>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.globals?.[global.slug]?.read?.permission ? (
<DocumentInfoProvider
global={global}
key={`${global.slug}-${locale}`}
idFromParams
>
<EditGlobal global={global} />
</DocumentInfoProvider>
) : <Unauthorized />}
</Route>,
];
if (global.versions) {
@@ -327,64 +282,41 @@ const Routes = () => {
key={`${global.slug}-versions`}
path={`${match.url}/globals/${global.slug}/versions`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Versions
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.globals?.[global.slug]?.readVersions?.permission
? <Versions global={global} />
: <Unauthorized />}
</Route>,
);
routesToReturn.push(
<Route
key={`${global.slug}-view-version`}
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
exact
render={(routeProps) => {
if (permissions?.globals?.[global.slug]?.readVersions?.permission) {
return (
<Version
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>,
>
{permissions?.globals?.[global.slug]?.readVersions?.permission ? (
<Version global={global} />
) : <Unauthorized />}
</Route>,
);
}
return routesToReturn;
}, [])}
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
</DefaultTemplate>
);
}
if (canAccessAdmin === false) {
return <Unauthorized />;
}
return (
// user without admin panel access
<div />
);
}
return <Redirect to={`${match.url}/login`} />;
}}
/>
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
</DefaultTemplate>
)}
{canAccessAdmin === false && (
<Unauthorized />
)}
</Fragment>
) : <Redirect to={`${match.url}/login?redirect=${encodeURIComponent(window.location.pathname)}`} />}
</Route>
<Route path={`${match.url}*`}>
<NotFound />
</Route>
@@ -400,4 +332,4 @@ const Routes = () => {
);
};
export default withRouter(Routes);
export default Routes;

View File

@@ -19,6 +19,7 @@ export const ArrayAction: React.FC<Props> = ({
addRow,
duplicateRow,
removeRow,
hasMaxRows,
}) => {
const { t } = useTranslation('general');
return (
@@ -56,28 +57,32 @@ export const ArrayAction: React.FC<Props> = ({
{t('moveDown')}
</button>
)}
<button
className={`${baseClass}__action ${baseClass}__add`}
type="button"
onClick={() => {
addRow(index);
close();
}}
>
<Plus />
{t('addBelow')}
</button>
<button
className={`${baseClass}__action ${baseClass}__duplicate`}
type="button"
onClick={() => {
duplicateRow(index);
close();
}}
>
<Copy />
{t('duplicate')}
</button>
{!hasMaxRows && (
<React.Fragment>
<button
className={`${baseClass}__action ${baseClass}__add`}
type="button"
onClick={() => {
addRow(index + 1);
close();
}}
>
<Plus />
{t('addBelow')}
</button>
<button
className={`${baseClass}__action ${baseClass}__duplicate`}
type="button"
onClick={() => {
duplicateRow(index);
close();
}}
>
<Copy />
{t('duplicate')}
</button>
</React.Fragment>
)}
<button
className={`${baseClass}__action ${baseClass}__remove`}
type="button"

View File

@@ -5,4 +5,5 @@ export type Props = {
moveRow: (from: number, to: number) => void
index: number
rowCount: number
hasMaxRows: boolean
}

View File

@@ -32,6 +32,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
const debouncedFields = useDebounce(fields, interval);
const fieldRef = useRef(fields);
const modifiedRef = useRef(modified);
const localeRef = useRef(locale);
// Store fields in ref so the autosave func
// can always retrieve the most to date copies
@@ -85,12 +86,12 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
let method: string;
if (collection && id) {
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${locale}`;
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`;
method = 'PATCH';
}
if (global) {
url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true&locale=${locale}`;
url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true&locale=${localeRef.current}`;
method = 'POST';
}
@@ -125,7 +126,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
};
autosave();
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, locale, modifiedRef]);
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, localeRef, modifiedRef]);
useEffect(() => {
if (versions?.docs?.[0]) {

View File

@@ -1,5 +1,9 @@
@import '../../../scss/styles.scss';
a.btn {
display: inline-block;
}
.btn {
background: transparent;
line-height: base(1);
@@ -62,7 +66,7 @@
}
&:not(.btn--disabled) {
&:hover {
&:hover, &:focus-visible {
background: var(--theme-elevation-750);
}
@@ -71,7 +75,7 @@
}
}
&:focus {
&:focus:not(:focus-visible) {
box-shadow: $focus-box-shadow;
outline: none;
}
@@ -85,8 +89,9 @@
box-shadow: $base-box-shadow;
color: var(--theme-elevation-800);
background: none;
backdrop-filter: blur(5px);
&:hover {
&:hover, &:focus-visible {
background: var(--theme-elevation-100);
box-shadow: $hover-box-shadow;
}
@@ -101,7 +106,7 @@
box-shadow: inset 0 0 0 $style-stroke-width var(--theme-elevation-400);
}
&:focus {
&:focus:not(:focus-visible) {
outline: none;
box-shadow: $hover-box-shadow, $focus-box-shadow;
}
@@ -174,14 +179,14 @@
cursor: default;
}
&:hover {
&:hover, &:focus-visible {
.btn__icon {
@include color-svg(var(--theme-elevation-0));
background: var(--theme-elevation-800);
}
}
&:focus {
&:focus:not(:focus-visible) {
.btn__icon {
@include color-svg(var(--theme-elevation-800));
background: var(--theme-elevation-150);
@@ -196,4 +201,9 @@
background: var(--theme-elevation-700);
}
}
&:focus-visible {
outline: var(--accessibility-outline);
outline-offset: var(--accessibility-outline-offset);
}
}

View File

@@ -28,12 +28,14 @@ const ButtonContents = ({ children, icon, tooltip, showTooltip }) => {
return (
<Fragment>
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{tooltip}
</Tooltip>
{tooltip && (
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{tooltip}
</Tooltip>
)}
<span className={`${baseClass}__content`}>
{children && (
<span className={`${baseClass}__label`}>

View File

@@ -4,13 +4,8 @@
--toggle-pad-h: #{base(.75)};
--toggle-pad-v: #{base(.5)};
border: 1px solid var(--theme-elevation-200);
border-radius: $style-radius-m;
&:hover {
border: 1px solid var(--theme-elevation-300);
}
&__toggle-wrap {
position: relative;
}
@@ -19,16 +14,6 @@
margin-bottom: $baseline !important;
}
&--hovered {
>.collapsible__toggle-wrap>.collapsible__drag {
opacity: 1;
}
>.collapsible__toggle-wrap>.collapsible__toggle {
background: var(--theme-elevation-100);
}
}
&__drag {
opacity: .5;
position: absolute;
@@ -42,7 +27,6 @@
@extend %body;
text-align: left;
cursor: pointer;
background: var(--theme-elevation-50);
border-top-right-radius: $style-radius-s;
border-top-left-radius: $style-radius-s;
width: 100%;
@@ -53,6 +37,27 @@
}
}
&--style-default {
border: 1px solid var(--theme-elevation-200);
&:hover {
border: 1px solid var(--theme-elevation-300);
}
>.collapsible__toggle-wrap {
.row-label {
color: var(--theme-text);
}
.collapsible__toggle {
background: var(--theme-elevation-50);
}
}
&.collapsible--hovered {
>.collapsible__toggle-wrap .collapsible__toggle {
background: var(--theme-elevation-100);
}
}
}
&__toggle,
&__header-wrap {
padding: var(--toggle-pad-v) var(--toggle-pad-h);
@@ -115,3 +120,58 @@
}
}
}
html[data-theme=dark] {
.collapsible {
&--style-error {
border: 1px solid var(--theme-error-400);
&:hover {
border: 1px solid var(--theme-error-500);
}
>.collapsible__toggle-wrap {
.row-label {
color: var(--theme-error-500);
}
.collapsible__toggle {
background: var(--theme-error-100);
}
}
&.collapsible--hovered {
>.collapsible__toggle-wrap .collapsible__toggle {
background: var(--theme-error-150);
}
}
}
}
}
html[data-theme=light] {
.collapsible {
&--style-error {
border: 1px solid var(--theme-error-500);
&:hover {
border: 1px solid var(--theme-error-600);
}
>.collapsible__toggle-wrap {
.row-label {
color: var(--theme-error-750);
}
.collapsible__toggle {
background: var(--theme-error-50);
}
}
&.collapsible--hovered {
>.collapsible__toggle-wrap .collapsible__toggle {
background: var(--theme-error-100);
}
}
&.error {
& input {
border-color: var(--theme-error-500);
}
}
}
}
}

View File

@@ -19,9 +19,10 @@ export const Collapsible: React.FC<Props> = ({
initCollapsed,
dragHandleProps,
actions,
collapsibleStyle = 'default',
}) => {
const [collapsedLocal, setCollapsedLocal] = useState(Boolean(initCollapsed));
const [hovered, setHovered] = useState(false);
const [hoveringToggle, setHoveringToggle] = useState(false);
const isNested = useCollapsible();
const { t } = useTranslation('fields');
@@ -34,14 +35,15 @@ export const Collapsible: React.FC<Props> = ({
dragHandleProps && `${baseClass}--has-drag-handle`,
collapsed && `${baseClass}--collapsed`,
isNested && `${baseClass}--nested`,
hovered && `${baseClass}--hovered`,
hoveringToggle && `${baseClass}--hovered`,
`${baseClass}--style-${collapsibleStyle}`,
].filter(Boolean).join(' ')}
>
<CollapsibleProvider>
<div
className={`${baseClass}__toggle-wrap`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseEnter={() => setHoveringToggle(true)}
onMouseLeave={() => setHoveringToggle(false)}
>
{dragHandleProps && (
<div

View File

@@ -10,4 +10,5 @@ export type Props = {
onToggle?: (collapsed: boolean) => void
initCollapsed?: boolean
dragHandleProps?: DragHandleProps
collapsibleStyle?: 'default' | 'error'
}

View File

@@ -5,6 +5,7 @@
position: relative;
cursor: pointer;
vertical-align: middle;
border-radius: 100%;
textarea {
position: absolute;
@@ -18,4 +19,8 @@
&:active {
outline: none;
}
&:focus-visible {
outline: var(--accessibility-outline);
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Copy from '../../icons/Copy';
import Tooltip from '../Tooltip';
@@ -50,6 +50,7 @@ const CopyToClipboard: React.FC<Props> = ({
</Tooltip>
<textarea
readOnly
tabIndex={-1}
value={value}
ref={ref}
/>

View File

@@ -17,7 +17,7 @@ const DateTime: React.FC<Props> = (props) => {
value,
onChange,
displayFormat,
pickerAppearance,
pickerAppearance = "dayOnly",
minDate,
maxDate,
monthsToShow = 1,
@@ -98,6 +98,9 @@ const DateTime: React.FC<Props> = (props) => {
enabled: true,
},
]}
showMonthDropdown
showYearDropdown
dropdownMode="select"
/>
</div>
</div>

View File

@@ -99,9 +99,9 @@ $cal-icon-width: 18px;
.react-datepicker {
@include shadow-lg;
border: 1px solid var(--theme-elevation-100);
background: var(--theme-input-bg);
display: inline-flex;
border: none;
font-family: var(--font-body);
font-weight: 100;
border-radius: 0;
@@ -169,8 +169,12 @@ $cal-icon-width: 18px;
}
&__current-month {
display: none;
}
&__header__dropdown, &-year-header {
padding: 10px 0;
font-weight: 600;
font-weight: bold;
}
&__month-container {
@@ -185,6 +189,20 @@ $cal-icon-width: 18px;
border-left: none;
}
&__month-text {
&:hover {
background: var(--theme-elevation-100);
}
}
&__month-select,
&__year-select {
min-width: 70px;
border: none;
background: none;
outline: none;
cursor: pointer;
}
&__day-names {
background-color: var(--theme-elevation-100);
@@ -239,6 +257,10 @@ $cal-icon-width: 18px;
border: none;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list {
max-height: 100%;
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected {
@@ -281,7 +303,40 @@ $cal-icon-width: 18px;
font-size: base(.5);
}
&__appearance--dayOnly,
&__appearance--monthOnly {
.react-datepicker {
&__month-container {
border-right: none;
}
}
}
@include small-break {
.react-datepicker {
flex-direction: column;
}
.react-datepicker__month-container {
border-right: 0;
}
.react-datepicker__time-container {
width: auto;
}
.react-datepicker__header--time {
background-color: var(--theme-elevation-100);
padding: 8px 0;
border-bottom: none;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box {
height: 120px;
width: unset;
> ul {
height: 120px;
}
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 0px;
}
&__input-wrapper {
.icon {
top: calc(50% - #{base(.25)});

View File

@@ -16,6 +16,12 @@
}
.btn {
margin-right: $baseline;
margin: 0;
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: $baseline;
}
}

View File

@@ -79,18 +79,17 @@ const DeleteDocument: React.FC<Props> = (props) => {
if (id) {
return (
<React.Fragment>
<button
type="button"
<Button
buttonStyle="none"
id={buttonId}
className={`${baseClass}__toggle`}
onClick={(e) => {
e.preventDefault();
onClick={() => {
setDeleting(false);
toggleModal(modalSlug);
}}
>
{t('delete')}
</button>
</Button>
<Modal
slug={modalSlug}
className={baseClass}
@@ -109,20 +108,22 @@ const DeleteDocument: React.FC<Props> = (props) => {
</strong>
</Trans>
</p>
<Button
id="confirm-cancel"
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
>
{t('cancel')}
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
id="confirm-delete"
>
{deleting ? t('deleting') : t('confirm')}
</Button>
<div className={`${baseClass}__actions`}>
<Button
id="confirm-cancel"
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
>
{t('cancel')}
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
id="confirm-delete"
>
{deleting ? t('deleting') : t('confirm')}
</Button>
</div>
</MinimalTemplate>
</Modal>
</React.Fragment>

View File

@@ -12,20 +12,20 @@ import Button from '../Button';
import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import { DocumentInfoProvider, useDocumentInfo } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../../views/collections/Edit/formatFields';
import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections';
import IDLabel from '../IDLabel';
import { baseClass } from '.';
import { CollectionPermission } from '../../../../auth';
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const Content: React.FC<DocumentDrawerProps> = ({
collectionSlug,
id,
drawerSlug,
onSave: onSaveFromProps,
customHeader,
onSave,
}) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal, modalState, closeModal } = useModal();
@@ -36,26 +36,31 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
const hasInitializedState = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [collectionConfig] = useRelatedCollections(collectionSlug);
const { docPermissions, id, getDocPreferences } = useDocumentInfo();
const [fields, setFields] = useState(() => formatFields(collectionConfig, true));
// no need to an additional requests when creating new documents
const initialID = useRef(id);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(initialID.current ? `${serverURL}${api}/${collectionSlug}/${initialID.current}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
useEffect(() => {
setFields(formatFields(collectionConfig, true));
}, [collectionSlug, collectionConfig]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(id ? `${serverURL}${api}/${collectionSlug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
useEffect(() => {
if (isLoadingDocument) {
if (isLoadingDocument || hasInitializedState.current) {
return;
}
const awaitInitialState = async () => {
const preferences = await getDocPreferences();
const state = await buildStateFromSchema({
fieldSchema: fields,
preferences,
data,
user,
operation: id ? 'update' : 'create',
@@ -68,7 +73,7 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
awaitInitialState();
hasInitializedState.current = true;
}, [data, fields, id, user, locale, isLoadingDocument, t]);
}, [data, fields, id, user, locale, isLoadingDocument, t, getDocPreferences]);
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
@@ -81,62 +86,87 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
}
}, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]);
if (isError) return null;
const isEditing = Boolean(id);
const apiURL = id ? `${serverURL}${api}/${collectionSlug}/${id}?locale=${locale}` : null;
const action = `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`;
const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission);
const isLoading = !internalState || !docPermissions || isLoadingDocument;
return (
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
componentProps={{
isLoading,
data,
id,
collection: collectionConfig,
permissions: permissions.collections[collectionConfig.slug],
isEditing,
apiURL,
onSave,
internalState,
hasSavePermission,
action,
disableEyebrow: true,
disableActions: true,
me: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(drawerSlug)}
aria-label={t('general:close')}
>
<X />
</Button>
</div>
{id && (
<IDLabel id={id.toString()} />
)}
</div>
),
}}
/>
);
};
// First provide the document context using `DocumentInfoProvider`
// this is so we can utilize the `useDocumentInfo` hook in the `Content` component
// this drawer is used for both creating and editing documents
// this means that the `id` may be unknown until the document is created
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = (props) => {
const { collectionSlug, id: idFromProps, onSave: onSaveFromProps } = props;
const [collectionConfig] = useRelatedCollections(collectionSlug);
const [id, setId] = useState<string | null>(idFromProps);
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
setId(args.doc.id);
if (typeof onSaveFromProps === 'function') {
onSaveFromProps({
...args,
collectionConfig,
});
}
}, [collectionConfig, onSaveFromProps]);
if (isError) return null;
}, [onSaveFromProps, collectionConfig]);
return (
<DocumentInfoProvider
collection={collectionConfig}
id={id}
>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
componentProps={{
isLoading: !internalState,
data,
id,
collection: collectionConfig,
permissions: permissions.collections[collectionConfig.slug],
isEditing: Boolean(id),
apiURL: id ? `${serverURL}${api}/${collectionSlug}/${id}` : null,
onSave,
internalState,
hasSavePermission: true,
action: `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`,
disableEyebrow: true,
disableActions: true,
me: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(drawerSlug)}
aria-label={t('general:close')}
>
<X />
</Button>
</div>
{id && (
<IDLabel id={id} />
)}
</div>
),
}}
<Content
{...props}
onSave={onSave}
/>
</DocumentInfoProvider>
);

View File

@@ -1,14 +1,10 @@
import React, { HTMLAttributes } from 'react';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Props as EditViewProps } from '../../views/collections/Edit/types';
export type DocumentDrawerProps = {
collectionSlug: string
id?: string
onSave?: (json: {
doc: Record<string, any>
message: string
collectionConfig: SanitizedCollectionConfig
}) => void
onSave?: EditViewProps['onSave']
customHeader?: React.ReactNode
drawerSlug?: string
}

View File

@@ -30,8 +30,7 @@ const DraggableSortable: React.FC<Props> = (props) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 100,
tolerance: 5,
distance: 5,
},
}),
useSensor(KeyboardSensor, {

View File

@@ -23,6 +23,6 @@ export const useDraggableSortable = (props: UseDraggableArguments): UseDraggable
isDragging,
listeners,
setNodeRef,
transform: transform && `translate3d(${transform.x}px, ${transform.y}px, 0)`,
transform: transform && `translate3d(${transform.x}px, ${transform.y}px, 0)`, // translate3d is faster than translate in most browsers
};
};

View File

@@ -0,0 +1,43 @@
@import '../../../scss/styles.scss';
.error-pill {
align-self: center;
align-items: center;
border: 0;
padding: 0 base(.25);
flex-shrink: 0;
border-radius: var(--style-radius-l);
line-height: 18px;
font-size: 11px;
text-align: center;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
&--fixed-width {
width: 18px;
height: 18px;
border-radius: 50%;
position: relative;
}
&__count {
letter-spacing: .5px;
margin-left: .5px;
}
}
html[data-theme=light] {
.error-pill {
background: var(--theme-error-250);
color: var(--theme-error-750)
}
}
html[data-theme=dark] {
.error-pill {
background: var(--theme-error-500);
color: var(--color-base-1000)
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Props } from './types';
import './index.scss';
const baseClass = 'error-pill';
export const ErrorPill: React.FC<Props> = (props) => {
const { className, count, withMessage } = props;
const lessThan3Chars = !withMessage && count < 99;
const { t } = useTranslation();
const classes = [
baseClass,
lessThan3Chars && `${baseClass}--fixed-width`,
className && className,
].filter(Boolean).join(' ');
if (count === 0) return null;
return (
<div className={classes}>
<div className={`${baseClass}__content`}>
<span className={`${baseClass}__count`}>{count}</span>
{withMessage && ` ${count > 1 ? t('general:errors') : t('general:error')}`}
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export type Props = {
count: number,
className?: string,
withMessage?: boolean,
}

View File

@@ -8,7 +8,7 @@
font-weight: 600;
text-decoration: none;
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
}
@@ -20,4 +20,8 @@
text-overflow: ellipsis;
white-space: nowrap;
}
&__edit {
position: relative;
}
}

View File

@@ -1,8 +1,11 @@
import React from 'react';
import React, { useState } from 'react';
import { useConfig } from '../../../utilities/Config';
import CopyToClipboard from '../../CopyToClipboard';
import formatFilesize from '../../../../../uploads/formatFilesize';
import { Props } from './types';
import { useDocumentDrawer } from '../../DocumentDrawer';
import Edit from '../../../icons/Edit';
import Tooltip from '../../Tooltip';
import './index.scss';
@@ -10,9 +13,19 @@ const baseClass = 'file-meta';
const Meta: React.FC<Props> = (props) => {
const {
filename, filesize, width, height, mimeType, staticURL, url,
filename, filesize, width, height, mimeType, staticURL, url, id, collection,
} = props;
const [hovered, setHovered] = useState(false);
const openInDrawer = Boolean(id && collection);
const [
DocumentDrawer,
DocumentDrawerToggler,
] = useDocumentDrawer({
id, collectionSlug: collection,
});
const { serverURL } = useConfig();
const fileURL = url || `${serverURL}${staticURL}/${filename}`;
@@ -20,6 +33,7 @@ const Meta: React.FC<Props> = (props) => {
return (
<div className={baseClass}>
<div className={`${baseClass}__url`}>
{openInDrawer && <DocumentDrawer />}
<a
href={fileURL}
target="_blank"
@@ -31,6 +45,21 @@ const Meta: React.FC<Props> = (props) => {
value={fileURL}
defaultMessage="Copy URL"
/>
{openInDrawer
&& (
<DocumentDrawerToggler
className={`${baseClass}__edit`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Edit />
<Tooltip
show={hovered}
>
Edit
</Tooltip>
</DocumentDrawerToggler>
)}
</div>
<div className={`${baseClass}__size-type`}>
{formatFilesize(filesize)}

View File

@@ -6,5 +6,7 @@ export type Props = {
width?: number,
height?: number,
sizes?: unknown,
url?: string
url?: string,
id?: string,
collection?: string
}

View File

@@ -27,7 +27,7 @@
font-weight: 600;
text-decoration: none;
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
}

View File

@@ -39,6 +39,7 @@ const FileDetails: React.FC<Props> = (props) => {
staticURL,
imageSizes,
},
slug: collectionSlug,
} = collection;
const {
@@ -49,6 +50,7 @@ const FileDetails: React.FC<Props> = (props) => {
mimeType,
sizes,
url,
id,
} = doc;
const [orderedSizes, setOrderedSizes] = useState<FileSizes>(() => sortSizes(sizes, imageSizes));
@@ -78,6 +80,8 @@ const FileDetails: React.FC<Props> = (props) => {
height={height as number}
mimeType={mimeType as string}
url={url as string}
id={id as string}
collection={collectionSlug as string}
/>
{hasSizes && (
<Button

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import AnimateHeight from 'react-animate-height';
import { useTranslation } from 'react-i18next';
import { useWindowInfo } from '@faceless-ui/window-info';
@@ -20,11 +20,24 @@ import EditMany from '../EditMany';
import DeleteMany from '../DeleteMany';
import PublishMany from '../PublishMany';
import UnpublishMany from '../UnpublishMany';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import './index.scss';
const baseClass = 'list-controls';
const getUseAsTitle = (collection: SanitizedCollectionConfig) => {
const {
admin: {
useAsTitle,
},
fields,
} = collection;
const topLevelFields = flattenFields(fields);
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
};
const ListControls: React.FC<Props> = (props) => {
const {
collection,
@@ -37,7 +50,6 @@ const ListControls: React.FC<Props> = (props) => {
collection: {
fields,
admin: {
useAsTitle,
listSearchableFields,
},
},
@@ -46,10 +58,11 @@ const ListControls: React.FC<Props> = (props) => {
const params = useSearchParams();
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
const [titleField] = useState(() => {
const topLevelFields = flattenFields(fields);
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
});
const [titleField, setTitleField] = useState(getUseAsTitle(collection));
useEffect(() => {
setTitleField(getUseAsTitle(collection));
}, [collection]);
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
const { t, i18n } = useTranslation('general');
@@ -89,7 +102,7 @@ const ListControls: React.FC<Props> = (props) => {
)}
{enableColumns && (
<Pill
pillStyle="dark"
pillStyle="light"
className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
icon={<Chevron />}
@@ -98,7 +111,7 @@ const ListControls: React.FC<Props> = (props) => {
</Pill>
)}
<Pill
pillStyle="dark"
pillStyle="light"
className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
icon={<Chevron />}

View File

@@ -50,11 +50,11 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
const [selectedOption, setSelectedOption] = useState<{ label: string, value: string }>(() => (selectedCollectionConfig ? { label: getTranslation(selectedCollectionConfig.labels.singular, i18n), value: selectedCollectionConfig.slug } : undefined));
const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig, t));
const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig));
useEffect(() => {
setFields(formatFields(selectedCollectionConfig, t));
}, [selectedCollectionConfig, t]);
setFields(formatFields(selectedCollectionConfig));
}, [selectedCollectionConfig]);
// allow external control of selected collection, same as the initial state logic above
useEffect(() => {

View File

@@ -39,8 +39,8 @@
cursor: pointer;
color: inherit;
&:focus,
&:focus-within {
&:focus:not(:focus-visible),
&:focus-within:not(:focus-visible) {
outline: none;
}

View File

@@ -13,7 +13,7 @@
font-weight: 600;
cursor: pointer;
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
@@ -30,13 +30,23 @@
ul {
list-style: none;
padding: 0;
text-align: left;
max-height: base(8);
margin: 0;
a {
&:hover {
li a {
all: unset;
cursor: pointer;
padding-right: 0;
&:hover, &:focus-visible {
text-decoration: underline;
}
}
}
@include mid-break {
.popup__content {
width: calc(100vw - calc(var(--gutter-h) * 2));
}
}
}

View File

@@ -6,13 +6,12 @@ import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useSearchParams } from '../../utilities/SearchParams';
import Popup from '../Popup';
import { Props } from './types';
import './index.scss';
const baseClass = 'localizer';
const Localizer: React.FC<Props> = () => {
const Localizer: React.FC = () => {
const { localization } = useConfig();
const locale = useLocale();
const searchParams = useSearchParams();
@@ -24,6 +23,7 @@ const Localizer: React.FC<Props> = () => {
return (
<div className={baseClass}>
<Popup
showScrollbar
horizontalAlign="left"
button={locale}
render={({ close }) => (
@@ -36,7 +36,7 @@ const Localizer: React.FC<Props> = () => {
const localeClasses = [
baseLocaleClass,
locale === localeOption && `${baseLocaleClass}--active`,
];
].filter(Boolean).join('');
const newParams = {
...searchParams,
@@ -49,7 +49,7 @@ const Localizer: React.FC<Props> = () => {
return (
<li
key={localeOption}
className={localeClasses.join(' ')}
className={localeClasses}
>
<Link
to={{ search }}

View File

@@ -71,6 +71,10 @@
>* {
margin-top: base(1);
}
a:focus-visible {
outline: var(--accessibility-outline);
}
}
&__log-out {
@@ -88,12 +92,12 @@
display: flex;
text-decoration: none;
&:focus {
&:focus:not(:focus-visible) {
box-shadow: none;
font-weight: 600;
}
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Link, NavLink, useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
@@ -12,7 +12,7 @@ import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import NavGroup from '../NavGroup';
import Logout from '../Logout';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import { EntityToGroup, EntityType, Group, groupNavItems } from '../../../utilities/groupNavItems';
import { getTranslation } from '../../../../utilities/getTranslation';
import './index.scss';
@@ -20,7 +20,7 @@ import './index.scss';
const baseClass = 'nav';
const DefaultNav = () => {
const { permissions } = useAuth();
const { permissions, user } = useAuth();
const [menuActive, setMenuActive] = useState(false);
const [groups, setGroups] = useState<Group[]>([]);
const history = useHistory();
@@ -47,24 +47,28 @@ const DefaultNav = () => {
useEffect(() => {
setGroups(groupNavItems([
...collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
...collections
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
};
return entityToGroup;
}),
...globals.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
...globals
.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
};
return entityToGroup;
}),
return entityToGroup;
}),
], permissions, i18n));
}, [collections, globals, permissions, i18n, i18n.language]);
}, [collections, globals, permissions, i18n, i18n.language, user]);
useEffect(() => history.listen(() => {
setMenuActive(false);

View File

@@ -22,13 +22,17 @@
margin-top: base(-.2);
}
&:hover {
&:hover, &:focus-visible {
color: var(--theme-elevation-1000);
.stroke {
stroke: var(--theme-elevation-1000);
}
}
&:focus-visible {
outline: none;
}
}
&__indicator {

View File

@@ -23,6 +23,7 @@ const ClickableArrow: React.FC<Props> = (props) => {
return (
<button
className={classes}
disabled={isDisabled}
onClick={!isDisabled ? updatePage : undefined}
type="button"
>

View File

@@ -35,8 +35,14 @@
color: var(--theme-elevation-800);
line-height: base(1);
&:hover:not(.clickable-arrow--is-disabled) {
background: var(--theme-elevation-100);
&:not(.clickable-arrow--is-disabled) {
&:hover, &:focus-visible {
background: var(--theme-elevation-100);
}
}
&:focus-visible {
outline: var(--accessibility-outline);
}
}

View File

@@ -19,7 +19,7 @@
text-align: left;
width: 100%;
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
}

View File

@@ -14,12 +14,24 @@
border: 0;
padding: 0 base(.25);
align-items: center;
flex-shrink: 0;
&--rounded {
border-radius: var(--style-radius-l);
line-height: 18px;
font-size: 12px;
}
&:active,
&:focus {
&:focus:not(:focus-visible) {
outline: none;
}
&:focus-visible {
outline: var(--accessibility-outline);
outline-offset: var(--accessibility-outline-offset);
}
&--has-action {
cursor: pointer;
text-decoration: none;
@@ -102,3 +114,22 @@
}
}
}
html[data-theme=dark] {
.pill {
&--style-error {
background: var(--theme-error-500);
color: var(--color-base-1000)
}
}
}
html[data-theme=light] {
.pill {
&--style-error {
background: var(--theme-error-250);
color: var(--theme-error-750)
}
}
}

View File

@@ -44,6 +44,7 @@ const StaticPill: React.FC<Props> = (props) => {
draggable,
children,
elementProps,
rounded,
} = props;
const classes = [
@@ -55,6 +56,7 @@ const StaticPill: React.FC<Props> = (props) => {
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
draggable && `${baseClass}--draggable`,
rounded && `${baseClass}--rounded`,
].filter(Boolean).join(' ');
let Element: ElementType | React.FC<RenderedTypeProps> = 'div';

View File

@@ -7,8 +7,9 @@ export type Props = {
icon?: React.ReactNode,
alignIcon?: 'left' | 'right',
onClick?: () => void,
pillStyle?: 'white' | 'light' | 'dark' | 'light-gray' | 'warning' | 'success',
pillStyle?: 'white' | 'light' | 'dark' | 'light-gray' | 'warning' | 'success' | 'error',
draggable?: boolean,
rounded?: boolean
id?: string
elementProps?: HTMLAttributes<HTMLElement> & {
ref: React.RefCallback<HTMLElement>

View File

@@ -25,12 +25,19 @@
overflow: hidden;
}
&__scroll {
.popup__scroll {
padding: $baseline;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
overflow-y: auto;
width: calc(100% + var(--scrollbar-width));
white-space: nowrap;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
width: calc(100% + var(--scrollbar-width));
}
&--show-scrollbar {
.popup__scroll {
padding-right: 0;
width: 100%;
}
}
&:focus,

View File

@@ -26,6 +26,7 @@ const Popup: React.FC<Props> = (props) => {
padding,
forceOpen,
boundingRef,
showScrollbar = false,
} = props;
const { width: windowWidth, height: windowHeight } = useWindowInfo();
@@ -125,7 +126,8 @@ const Popup: React.FC<Props> = (props) => {
`${baseClass}--color-${color}`,
`${baseClass}--v-align-${verticalAlign}`,
`${baseClass}--h-align-${horizontalAlign}`,
(active) && `${baseClass}--active`,
active && `${baseClass}--active`,
showScrollbar && `${baseClass}--show-scrollbar`,
].filter(Boolean).join(' ');
return (

View File

@@ -1,21 +1,22 @@
import { CSSProperties } from 'react';
export type Props = {
className?: string
buttonClassName?: string
render?: (any) => React.ReactNode,
children?: React.ReactNode,
verticalAlign?: 'top' | 'bottom'
horizontalAlign?: 'left' | 'center' | 'right',
size?: 'small' | 'large' | 'wide',
color?: 'light' | 'dark',
buttonType?: 'default' | 'custom' | 'none',
button?: React.ReactNode,
forceOpen?: boolean
showOnHover?: boolean,
initActive?: boolean,
onToggleOpen?: (active: boolean) => void,
backgroundColor?: CSSProperties['backgroundColor'],
padding?: CSSProperties['padding'],
boundingRef?: React.MutableRefObject<HTMLElement>
className?: string
buttonClassName?: string
render?: (any) => React.ReactNode,
children?: React.ReactNode,
verticalAlign?: 'top' | 'bottom'
horizontalAlign?: 'left' | 'center' | 'right',
size?: 'small' | 'large' | 'wide',
color?: 'light' | 'dark',
buttonType?: 'default' | 'custom' | 'none',
button?: React.ReactNode,
forceOpen?: boolean
showOnHover?: boolean,
initActive?: boolean,
onToggleOpen?: (active: boolean) => void,
backgroundColor?: CSSProperties['backgroundColor'],
padding?: CSSProperties['padding'],
boundingRef?: React.MutableRefObject<HTMLElement>
showScrollbar?: boolean
}

View File

@@ -1 +0,0 @@
@import '../../../scss/styles.scss';

View File

@@ -1,21 +1,45 @@
import React, { useCallback, useState } from 'react';
import React, { useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { GeneratePreviewURL } from '../../../../config/types';
import { useAuth } from '../../utilities/Auth';
import Button from '../Button';
import { Props } from './types';
import { useLocale } from '../../utilities/Locale';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useConfig } from '../../utilities/Config';
import './index.scss';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
} = props;
export type CustomPreviewButtonProps = React.ComponentType<DefaultPreviewButtonProps & {
DefaultButton: React.ComponentType<DefaultPreviewButtonProps>;
}>
export type DefaultPreviewButtonProps = {
preview: () => void;
disabled: boolean;
label: string;
};
const DefaultPreviewButton: React.FC<DefaultPreviewButtonProps> = ({ preview, disabled, label }) => {
return (
<Button
className={baseClass}
buttonStyle="secondary"
onClick={preview}
disabled={disabled}
>
{label}
</Button>
);
};
type Props = {
CustomComponent?: CustomPreviewButtonProps
generatePreviewURL?: GeneratePreviewURL
}
const PreviewButton: React.FC<Props> = ({
CustomComponent,
generatePreviewURL,
}) => {
const { id, collection, global } = useDocumentInfo();
const [isLoading, setIsLoading] = useState(false);
@@ -23,30 +47,46 @@ const PreviewButton: React.FC<Props> = (props) => {
const { token } = useAuth();
const { serverURL, routes: { api } } = useConfig();
const { t } = useTranslation('version');
const isGeneratingPreviewURL = useRef(false);
const handleClick = useCallback(async () => {
setIsLoading(true);
// we need to regenerate the preview URL every time the button is clicked
// to do this we need to fetch the document data fresh from the API
// this will ensure the latest data is used when generating the preview URL
const preview = useCallback(async () => {
if (!generatePreviewURL || isGeneratingPreviewURL.current) return;
isGeneratingPreviewURL.current = true;
let url = `${serverURL}${api}`;
if (collection) url = `${url}/${collection.slug}/${id}`;
if (global) url = `${url}/globals/${global.slug}`;
try {
setIsLoading(true);
const data = await fetch(`${url}?draft=true&locale=${locale}&fallback-locale=null`).then((res) => res.json());
const previewURL = await generatePreviewURL(data, { locale, token });
setIsLoading(false);
let url = `${serverURL}${api}`;
if (collection) url = `${url}/${collection.slug}/${id}`;
if (global) url = `${url}/globals/${global.slug}`;
window.open(previewURL, '_blank');
}, [serverURL, api, collection, global, id, generatePreviewURL, locale, token]);
const data = await fetch(`${url}?draft=true&locale=${locale}&fallback-locale=null`).then((res) => res.json());
const previewURL = await generatePreviewURL(data, { locale, token });
if (!previewURL) throw new Error();
setIsLoading(false);
isGeneratingPreviewURL.current = false;
window.open(previewURL, '_blank');
} catch (err) {
setIsLoading(false);
isGeneratingPreviewURL.current = false;
toast.error(t('error:previewing'));
}
}, [serverURL, api, collection, global, id, generatePreviewURL, locale, token, t]);
return (
<Button
className={baseClass}
buttonStyle="secondary"
onClick={handleClick}
disabled={isLoading}
>
{isLoading ? t('general:loading') : t('preview')}
</Button>
<RenderCustomComponent
CustomComponent={CustomComponent}
DefaultComponent={DefaultPreviewButton}
componentProps={{
preview,
disabled: isLoading || !generatePreviewURL,
label: isLoading ? t('general:loading') : t('preview'),
DefaultButton: DefaultPreviewButton,
}}
/>
);
};

View File

@@ -1,5 +0,0 @@
import { GeneratePreviewURL } from '../../../../config/types';
export type Props = {
generatePreviewURL?: GeneratePreviewURL,
}

View File

@@ -1,11 +1,34 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FormSubmit from '../../forms/Submit';
import { Props } from './types';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useForm, useFormModified } from '../../forms/Form/context';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
const Publish: React.FC<Props> = () => {
export type CustomPublishButtonProps = React.ComponentType<DefaultPublishButtonProps & {
DefaultButton: React.ComponentType<DefaultPublishButtonProps>;
}>
export type DefaultPublishButtonProps = {
publish: () => void;
disabled: boolean;
label: string;
};
const DefaultPublishButton: React.FC<DefaultPublishButtonProps> = ({ disabled, publish, label }) => {
return (
<FormSubmit
type="button"
onClick={publish}
disabled={disabled}
>
{label}
</FormSubmit>
);
};
type Props = {
CustomComponent?: CustomPublishButtonProps
}
export const Publish: React.FC<Props> = ({ CustomComponent }) => {
const { unpublishedVersions, publishedDoc } = useDocumentInfo();
const { submit } = useForm();
const modified = useFormModified();
@@ -23,14 +46,15 @@ const Publish: React.FC<Props> = () => {
}, [submit]);
return (
<FormSubmit
type="button"
onClick={publish}
disabled={!canPublish}
>
{t('publishChanges')}
</FormSubmit>
<RenderCustomComponent
CustomComponent={CustomComponent}
DefaultComponent={DefaultPublishButton}
componentProps={{
publish,
disabled: !canPublish,
label: t('publishChanges'),
DefaultButton: DefaultPublishButton,
}}
/>
);
};
export default Publish;

View File

@@ -1 +0,0 @@
export type Props = {}

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { IndicatorProps } from 'react-select';
import { ClearIndicatorProps } from 'react-select';
import X from '../../../icons/X';
import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'clear-indicator';
export const ClearIndicator: React.FC<IndicatorProps<OptionType, true>> = (props) => {
export const ClearIndicator: React.FC<ClearIndicatorProps<OptionType, true>> = (props) => {
const {
innerProps: { ref, ...restInnerProps },
} = props;

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { components as SelectComponents, ControlProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
import type { Option } from '../types';
export const Control: React.FC<ControlProps<Option, any>> = (props) => {
const {
children,
innerProps,
selectProps: {
selectProps: {
customProps: {
disableMouseDown,
disableKeyDown,
},
},
} = {},
} = {},
} = props;
return (

View File

@@ -3,9 +3,13 @@
.multi-value {
&.rs__multi-value {
padding: 0;
background: transparent;
background: var(--theme-input-bg);
border: $style-stroke-width-s solid var(--theme-elevation-800);
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
}
&--is-dragging {
z-index: 2;
}
}

View File

@@ -4,13 +4,12 @@ import {
components as SelectComponents,
} from 'react-select';
import { useDraggableSortable } from '../../DraggableSortable/useDraggableSortable';
import { Option as OptionType } from '../types';
import type { Option } from '../types';
import './index.scss';
const baseClass = 'multi-value';
export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
export const MultiValue: React.FC<MultiValueProps<Option>> = (props) => {
const {
className,
isDisabled,
@@ -19,39 +18,41 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
value,
},
selectProps: {
selectProps,
selectProps: {
customProps: {
disableMouseDown,
},
},
} = {},
} = {},
} = props;
const classes = [
baseClass,
className,
!isDisabled && 'draggable',
].filter(Boolean).join(' ');
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggableSortable({
id: value.toString(),
});
const classes = [
baseClass,
className,
!isDisabled && 'draggable',
isDragging && `${baseClass}--is-dragging`,
].filter(Boolean).join(' ');
return (
<SelectComponents.MultiValue
{...props}
className={classes}
innerProps={{
...innerProps,
...attributes,
...listeners,
ref: setNodeRef,
onMouseDown: (e) => {
if (!disableMouseDown) {
// we need to prevent the dropdown from opening when clicking on the drag handle, but not when a modal is open (i.e. the 'Relationship' field component)
e.preventDefault();
e.stopPropagation();
}
},
@@ -59,14 +60,6 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
transform,
},
}}
selectProps={{
...selectProps,
// pass the draggable props through to the label so it alone acts as the draggable handle
draggableProps: {
...attributes,
...listeners,
},
}}
/>
);
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { components as SelectComponents, MultiValueProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
import type { Option } from '../types';
import './index.scss';
const baseClass = 'multi-value-label';
@@ -8,8 +8,10 @@ const baseClass = 'multi-value-label';
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const {
selectProps: {
draggableProps,
},
customProps: {
draggableProps,
} = {},
} = {},
} = props;
return (

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
import { MultiValueRemoveProps } from 'react-select';
import X from '../../../icons/X';
import Tooltip from '../../Tooltip';
import { Option as OptionType } from '../types';
@@ -8,23 +8,33 @@ import './index.scss';
const baseClass = 'multi-value-remove';
export const MultiValueRemove: React.FC<MultiValueRemoveProps<OptionType>> = (props) => {
export const MultiValueRemove: React.FC<MultiValueRemoveProps<OptionType> & {
innerProps: JSX.IntrinsicElements['button']
}> = (props) => {
const {
innerProps,
innerProps: {
className,
onClick,
onTouchEnd,
},
} = props;
const [showTooltip, setShowTooltip] = React.useState(false);
const { t } = useTranslation('general');
return (
<button
{...innerProps}
type="button"
className={baseClass}
className={[
baseClass,
className,
].filter(Boolean).join(' ')}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchEnd={onTouchEnd}
onClick={(e) => {
setShowTooltip(false);
innerProps.onClick(e);
onClick(e);
}}
aria-label={t('remove')}
>

View File

@@ -1,7 +0,0 @@
@import '../../../../scss/styles.scss';
.react-select--single-value {
.rs__single-value {
color: currentColor;
}
}

View File

@@ -1,20 +1,24 @@
import React from 'react';
import { components as SelectComponents, SingleValueProps } from 'react-select';
import { Option } from '../types';
import './index.scss';
const baseClass = 'react-select--single-value';
export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
const {
children,
className,
} = props;
return (
<div className={baseClass}>
<SelectComponents.SingleValue {...props}>
{children}
</SelectComponents.SingleValue>
</div>
<SelectComponents.SingleValue
{...props}
className={[
baseClass,
className,
].filter(Boolean).join(' ')}
>
{children}
</SelectComponents.SingleValue>
);
};

View File

@@ -2,6 +2,7 @@
.value-container {
flex-grow: 1;
min-width: 0;
.rs__value-container {
padding: base(.25) 0;
@@ -12,6 +13,7 @@
margin: 0;
padding-top: 0;
padding-bottom: 0;
color: currentColor;
}
&--is-multi {

View File

@@ -8,12 +8,14 @@ const baseClass = 'value-container';
export const ValueContainer: React.FC<ValueContainerProps<Option, any>> = (props) => {
const {
selectProps,
selectProps: {
customProps,
} = {},
} = props;
return (
<div
ref={selectProps.selectProps.droppableRef}
ref={customProps?.droppableRef}
className={baseClass}
>
<SelectComponents.ValueContainer {...props} />

View File

@@ -22,13 +22,13 @@
display: none;
}
.rs__input {
.rs__input-container {
color: var(--theme-elevation-1000);
}
input {
font-family: var(--font-body);
width: 10px;
}
.rs__input {
font-family: var(--font-body);
width: 10px;
}
.rs__menu {
@@ -61,7 +61,7 @@
&--error {
div.rs__control {
background-color: var(--theme-error-200);
background-color: var(--theme-error-100);
}
}

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { KeyboardEventHandler } from 'react';
import Select from 'react-select';
import CreatableSelect from 'react-select/creatable';
import { useTranslation } from 'react-i18next';
import { arrayMove } from '@dnd-kit/sortable';
import { Props } from './types';
import { Props as ReactSelectAdapterProps } from './types';
import Chevron from '../../icons/Chevron';
import { getTranslation } from '../../../../utilities/getTranslation';
import { SingleValue } from './SingleValue';
@@ -13,11 +14,19 @@ import { ClearIndicator } from './ClearIndicator';
import { MultiValueRemove } from './MultiValueRemove';
import { Control } from './Control';
import DraggableSortable from '../DraggableSortable';
import type { Option } from './types';
import './index.scss';
const SelectAdapter: React.FC<Props> = (props) => {
const createOption = (label: string) => ({
label,
value: label,
});
const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = React.useState(''); // for creatable select
const {
className,
@@ -30,9 +39,11 @@ const SelectAdapter: React.FC<Props> = (props) => {
isSearchable = true,
isClearable = true,
filterOption = undefined,
numberOnly = false,
isLoading,
onMenuOpen,
components,
isCreatable,
selectProps,
} = props;
@@ -42,15 +53,76 @@ const SelectAdapter: React.FC<Props> = (props) => {
showError && 'react-select--error',
].filter(Boolean).join(' ');
if (!isCreatable) {
return (
<Select
isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
captureMenuScroll
customProps={selectProps}
{...props}
value={value}
onChange={onChange}
isDisabled={disabled}
className={classes}
classNamePrefix="rs"
options={options}
isSearchable={isSearchable}
isClearable={isClearable}
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
components={{
ValueContainer,
SingleValue,
MultiValue,
MultiValueLabel,
MultiValueRemove,
DropdownIndicator: Chevron,
ClearIndicator,
Control,
...components,
}}
/>
);
}
const handleKeyDown: KeyboardEventHandler = (event) => {
// eslint-disable-next-line no-restricted-globals
if (numberOnly === true) {
const acceptableKeys = ['Tab', 'Escape', 'Backspace', 'Enter', 'ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'];
const isNumber = !/[^0-9]/.test(event.key);
const isActionKey = acceptableKeys.includes(event.key);
if (!isNumber && !isActionKey) {
event.preventDefault();
return;
}
}
if (!value || !inputValue || inputValue.trim() === '') return;
if (filterOption && !filterOption(null, inputValue)) {
return;
}
switch (event.key) {
case 'Enter':
case 'Tab':
onChange([...value as Option[], createOption(inputValue)]);
setInputValue('');
event.preventDefault();
break;
default:
break;
}
};
return (
<Select
<CreatableSelect
isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
captureMenuScroll
{...props}
value={value}
onChange={onChange}
disabled={disabled ? 'disabled' : undefined}
isDisabled={disabled}
className={classes}
classNamePrefix="rs"
options={options}
@@ -59,9 +131,9 @@ const SelectAdapter: React.FC<Props> = (props) => {
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
selectProps={{
...selectProps,
}}
inputValue={inputValue}
onInputChange={(newValue) => setInputValue(newValue)}
onKeyDown={handleKeyDown}
components={{
ValueContainer,
SingleValue,
@@ -77,14 +149,16 @@ const SelectAdapter: React.FC<Props> = (props) => {
);
};
const SortableSelect: React.FC<Props> = (props) => {
const SortableSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const {
onChange,
value,
} = props;
let ids: string[] = [];
if (value) ids = Array.isArray(value) ? value.map((item) => item?.value as string) : [value?.value as string]; // TODO: fix these types
if (value) ids = Array.isArray(value) ? value.map((item) => item?.id ?? `${item?.value}` as string) : [value?.id || `${value?.value}` as string];
return (
<DraggableSortable
@@ -103,7 +177,7 @@ const SortableSelect: React.FC<Props> = (props) => {
);
};
const ReactSelect: React.FC<Props> = (props) => {
const ReactSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const {
isMulti,
isSortable,

View File

@@ -1,8 +1,40 @@
import { Ref } from 'react';
import { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select';
import { DocumentDrawerProps } from '../DocumentDrawer/types';
type CustomSelectProps = {
disableMouseDown?: boolean
disableKeyDown?: boolean
droppableRef?: React.RefObject<HTMLDivElement>
setDrawerIsOpen?: (isOpen: boolean) => void
onSave?: DocumentDrawerProps['onSave']
draggableProps?: any
}
// augment the types for the `Select` component from `react-select`
// this is to include the `selectProps` prop at the top-level `Select` component
declare module 'react-select/dist/declarations/src/Select' {
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> {
customProps?: CustomSelectProps
}
}
// augment the types for the `CommonPropsAndClassName` from `react-select`
// this will include the `selectProps` prop to every `react-select` component automatically
declare module 'react-select/dist/declarations/src' {
export interface CommonPropsAndClassName<Option, IsMulti extends boolean, Group extends GroupBase<Option>> extends CommonProps<Option, IsMulti, Group> {
customProps?: ReactSelectStateManagerProps<Option, IsMulti, Group> & CustomSelectProps
}
}
export type Option = {
[key: string]: unknown
value: unknown
//* The ID is used to identify the option in the UI. If it doesn't exist and value cannot be transformed into a string, sorting won't work */
id?: string
}
export type OptionGroup = {
@@ -18,11 +50,13 @@ export type Props = {
disabled?: boolean,
showError?: boolean,
options: Option[] | OptionGroup[]
/** Allows you to specify multiple values instead of just one */
isMulti?: boolean,
/** Allows you to create own values in the UI despite them not being pre-specified */
isCreatable?: boolean,
isLoading?: boolean
isOptionSelected?: any
isSortable?: boolean,
isDisabled?: boolean
onInputChange?: (val: string) => void
onMenuScrollToBottom?: () => void
placeholder?: string
@@ -32,12 +66,14 @@ export type Props = {
filterOption?:
| (({ label, value, data }: { label: string, value: string, data: Option }, search: string) => boolean)
| undefined,
numberOnly?: boolean,
components?: {
[key: string]: React.FC<any>
}
selectProps?: {
disableMouseDown?: boolean
disableKeyDown?: boolean
[key: string]: unknown
}
customProps?: CustomSelectProps
/**
* @deprecated Since version 1.0. Will be deleted in version 2.0. Use customProps instead.
*/
selectProps?: CustomSelectProps
backspaceRemovesValue?: boolean
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FormSubmit from '../../forms/Submit';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import { useForm } from '../../forms/Form/context';
export type CustomSaveButtonProps = React.ComponentType<DefaultSaveButtonProps & {
DefaultButton: React.ComponentType<DefaultSaveButtonProps>;
}>
type DefaultSaveButtonProps = {
label: string;
save: () => void;
};
const DefaultSaveButton: React.FC<DefaultSaveButtonProps> = ({ label, save }) => {
return (
<FormSubmit
type="button"
buttonId="action-save"
onClick={save}
>
{label}
</FormSubmit>
);
};
type Props = {
CustomComponent?: CustomSaveButtonProps;
}
export const Save: React.FC<Props> = ({ CustomComponent }) => {
const { t } = useTranslation('general');
const { submit } = useForm();
return (
<RenderCustomComponent
CustomComponent={CustomComponent}
DefaultComponent={DefaultSaveButton}
componentProps={{
save: submit,
label: t('save'),
DefaultButton: DefaultSaveButton,
}}
/>
);
};

View File

@@ -5,12 +5,37 @@ import FormSubmit from '../../forms/Submit';
import { useForm, useFormModified } from '../../forms/Form/context';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useLocale } from '../../utilities/Locale';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import './index.scss';
const baseClass = 'save-draft';
const SaveDraft: React.FC = () => {
export type CustomSaveDraftButtonProps = React.ComponentType<DefaultSaveDraftButtonProps & {
DefaultButton: React.ComponentType<DefaultSaveDraftButtonProps>;
}>
export type DefaultSaveDraftButtonProps = {
saveDraft: () => void;
disabled: boolean;
label: string;
};
const DefaultSaveDraftButton: React.FC<DefaultSaveDraftButtonProps> = ({ disabled, saveDraft, label }) => {
return (
<FormSubmit
className={baseClass}
type="button"
buttonStyle="secondary"
onClick={saveDraft}
disabled={disabled}
>
{label}
</FormSubmit>
);
};
type Props = {
CustomComponent?: CustomSaveDraftButtonProps
}
export const SaveDraft: React.FC<Props> = ({ CustomComponent }) => {
const { serverURL, routes: { api } } = useConfig();
const { submit } = useForm();
const { collection, global, id } = useDocumentInfo();
@@ -45,16 +70,15 @@ const SaveDraft: React.FC = () => {
}, [submit, collection, global, serverURL, api, locale, id]);
return (
<FormSubmit
className={baseClass}
type="button"
buttonStyle="secondary"
onClick={saveDraft}
disabled={!canSaveDraft}
>
{t('saveDraft')}
</FormSubmit>
<RenderCustomComponent
CustomComponent={CustomComponent}
DefaultComponent={DefaultSaveDraftButton}
componentProps={{
saveDraft,
disabled: !canSaveDraft,
label: t('saveDraft'),
DefaultButton: DefaultSaveDraftButton,
}}
/>
);
};
export default SaveDraft;

View File

@@ -35,14 +35,13 @@ const SearchFilter: React.FC<Props> = (props) => {
useEffect(() => {
const newWhere: Where = { ...typeof params?.where === 'object' ? params.where as Where : {} };
const fieldNamesToSearch = [fieldName, ...(listSearchableFields || []).map(({ name }) => name)];
const fieldNamesToSearch = listSearchableFields?.length > 0 ? [...(listSearchableFields).map(({ name }) => name)] : [fieldName];
fieldNamesToSearch.forEach((fieldNameToSearch) => {
const hasOrQuery = Array.isArray(newWhere.or);
const existingFieldSearchIndex = hasOrQuery ? newWhere.or.findIndex((condition) => {
return (condition?.[fieldNameToSearch] as WhereField)?.like;
}) : -1;
if (debouncedSearch) {
if (!hasOrQuery) newWhere.or = [];
@@ -80,13 +79,18 @@ const SearchFilter: React.FC<Props> = (props) => {
useEffect(() => {
if (listSearchableFields?.length > 0) {
placeholder.current = listSearchableFields.reduce<string>((prev, curr, i) => {
if (i === 0) {
return `${t('searchBy', { label: getTranslation(curr.label || curr.name, i18n) })}`;
}
if (i === listSearchableFields.length - 1) {
return `${prev} ${t('or')} ${getTranslation(curr.label || curr.name, i18n)}`;
}
return `${prev}, ${getTranslation(curr.label || curr.name, i18n)}`;
}, placeholder.current);
}, '');
} else {
placeholder.current = t('searchBy', { label: getTranslation(fieldLabel, i18n) });
}
}, [t, listSearchableFields, i18n]);
}, [t, listSearchableFields, i18n, fieldLabel]);
return (
<div className={baseClass}>

View File

@@ -3,7 +3,6 @@ import { toast } from 'react-toastify';
import { Modal, useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { Props } from './types';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import Button from '../Button';
import { MinimalTemplate } from '../..';
@@ -16,7 +15,7 @@ import './index.scss';
const baseClass = 'status';
const Status: React.FC<Props> = () => {
const Status: React.FC = () => {
const {
publishedDoc,
unpublishedVersions,

View File

@@ -1,3 +0,0 @@
export type Props = {
}

View File

@@ -25,7 +25,7 @@
cursor: pointer;
}
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
}

View File

@@ -28,6 +28,11 @@
}
}
a:focus-visible {
outline: var(--accessibility-outline);
outline-offset: var(--accessibility-outline-offset);
}
@include mid-break {
th,

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useReducer, createContext, useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import React, { createContext, useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { usePreferences } from '../../utilities/Preferences';
import { ListPreferences } from '../../views/collections/List/types';
@@ -45,8 +44,7 @@ export const TableColumnsProvider: React.FC<{
const prevCollection = useRef<SanitizedCollectionConfig['slug']>();
const hasInitialized = useRef(false);
const { getPreference, setPreference } = usePreferences();
const { t } = useTranslation();
const [formattedFields] = useState<Field[]>(() => formatFields(collection, t));
const [formattedFields] = useState<Field[]>(() => formatFields(collection));
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
@@ -91,7 +89,7 @@ export const TableColumnsProvider: React.FC<{
}
return column;
}),
collection: { ...collection, fields: formatFields(collection, t) },
collection: { ...collection, fields: formatFields(collection) },
cellProps,
},
});
@@ -101,7 +99,7 @@ export const TableColumnsProvider: React.FC<{
};
sync();
}, [preferenceKey, setPreference, tableColumns, getPreference, useAsTitle, defaultColumns, collection, cellProps, formattedFields, t]);
}, [preferenceKey, setPreference, tableColumns, getPreference, useAsTitle, defaultColumns, collection, cellProps, formattedFields]);
// /////////////////////////////////////
// Set preferences on column change
@@ -131,7 +129,7 @@ export const TableColumnsProvider: React.FC<{
dispatchTableColumns({
type: 'set',
payload: {
collection: { ...collection, fields: formatFields(collection, t) },
collection: { ...collection, fields: formatFields(collection) },
columns: columns.map((column) => ({
accessor: column,
active: true,
@@ -140,7 +138,7 @@ export const TableColumnsProvider: React.FC<{
cellProps,
},
});
}, [collection, t, cellProps]);
}, [collection, cellProps]);
const moveColumn = useCallback((args: {
fromIndex: number
@@ -153,22 +151,22 @@ export const TableColumnsProvider: React.FC<{
payload: {
fromIndex,
toIndex,
collection: { ...collection, fields: formatFields(collection, t) },
collection: { ...collection, fields: formatFields(collection) },
cellProps,
},
});
}, [collection, t, cellProps]);
}, [collection, cellProps]);
const toggleColumn = useCallback((column: string) => {
dispatchTableColumns({
type: 'toggle',
payload: {
column,
collection: { ...collection, fields: formatFields(collection, t) },
collection: { ...collection, fields: formatFields(collection) },
cellProps,
},
});
}, [collection, t, cellProps]);
}, [collection, cellProps]);
return (
<TableColumnContext.Provider

View File

@@ -1,7 +1,9 @@
@import '../../../scss/styles.scss';
.thumbnail-card {
@include btn-reset;
@include shadow;
width: 100%;
background: var(--theme-input-bg);
&__label {

View File

@@ -18,7 +18,6 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
thumbnail,
label: labelFromProps,
alignLabel,
onKeyDown,
} = props;
const { t, i18n } = useTranslation('general');
@@ -43,11 +42,11 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
}
return (
<div
<button
type="button"
title={title}
className={classes}
onClick={typeof onClick === 'function' ? onClick : undefined}
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
onClick={onClick}
>
<div className={`${baseClass}__thumbnail`}>
{thumbnail && thumbnail}
@@ -62,6 +61,6 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
<div className={`${baseClass}__label`}>
{title}
</div>
</div>
</button>
);
};

View File

@@ -59,3 +59,19 @@
display: none;
}
}
html[data-theme=light] {
.tooltip {
background-color: var(--theme-error-250);
color: var(--theme-error-750);
&--position-top:after {
border-top-color: var(--theme-error-250);
}
&--position-bottom:after {
border-bottom-color: var(--theme-error-250);
}
}
}

View File

@@ -3,7 +3,7 @@
.versions-count__button {
font-weight: 600;
&:hover {
&:hover, &:focus-visible {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ReactSelect from '../../../ReactSelect';
import { getTranslation } from '../../../../../../utilities/getTranslation';
import { Props } from './types';
import { Option, OptionObject } from '../../../../../../fields/config/types';
const formatOptions = (options: Option[]): OptionObject[] => options.map((option) => {
if (typeof option === 'object' && (option.value || option.value === '')) {
return option;
}
return {
label: option,
value: option,
} as OptionObject;
});
export const Select: React.FC<Props> = ({ onChange, value, options: optionsFromProps, operator }) => {
const { i18n } = useTranslation();
const [options, setOptions] = React.useState(formatOptions(optionsFromProps));
const isMulti = ['in', 'not_in'].includes(operator);
let valueToRender;
if (isMulti && Array.isArray(value)) {
valueToRender = value.map((val) => {
const matchingOption = options.find((option) => option.value === val);
return {
label: matchingOption ? getTranslation(matchingOption.label, i18n) : val,
value: matchingOption?.value ?? val,
};
});
} else if (value) {
const matchingOption = options.find((option) => option.value === value);
valueToRender = {
label: matchingOption ? getTranslation(matchingOption.label, i18n) : value,
value: matchingOption?.value ?? value,
};
}
const onSelect = React.useCallback((selectedOption) => {
let newValue;
if (!selectedOption) {
newValue = null;
} else if (isMulti) {
if (Array.isArray(selectedOption)) {
newValue = selectedOption.map((option) => option.value);
} else {
newValue = [];
}
} else {
newValue = selectedOption.value;
}
onChange(newValue);
}, [
isMulti,
onChange,
]);
React.useEffect(() => {
setOptions(formatOptions(optionsFromProps));
}, [optionsFromProps]);
React.useEffect(() => {
if (!isMulti && Array.isArray(value)) {
onChange(value[0]);
}
}, [isMulti, onChange, value]);
return (
<ReactSelect
onChange={onSelect}
value={valueToRender}
options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))}
isMulti={isMulti}
/>
);
};

View File

@@ -0,0 +1,9 @@
import { Operator } from '../../../../../../types';
import { Option } from '../../../../../../fields/config/types';
export type Props = {
onChange: (val: string) => void,
value: string,
options: Option[]
operator: Operator
}

View File

@@ -7,6 +7,7 @@ import Date from './Date';
import Number from './Number';
import Text from './Text';
import Relationship from './Relationship';
import { Select } from './Select';
import useDebounce from '../../../../hooks/useDebounce';
import { FieldCondition } from '../types';
@@ -17,6 +18,7 @@ const valueFields = {
Number,
Text,
Relationship,
Select,
};
const baseClass = 'condition';
@@ -56,7 +58,15 @@ const Condition: React.FC<Props> = (props) => {
});
}, [debouncedValue, dispatch, orIndex, andIndex]);
const ValueComponent = valueFields[activeField?.component] || valueFields.Text;
const booleanSelect = ['exists'].includes(operatorValue) || activeField.props.type === 'checkbox';
const ValueComponent = booleanSelect ? Select : (valueFields[activeField?.component] || valueFields.Text);
let selectOptions;
if (booleanSelect) {
selectOptions = ['true', 'false'];
} else if ('options' in activeField?.props) {
selectOptions = activeField.props.options;
}
return (
<div className={baseClass}>
@@ -95,6 +105,7 @@ const Condition: React.FC<Props> = (props) => {
DefaultComponent={ValueComponent}
componentProps={{
...activeField?.props,
options: selectOptions,
operator: operatorValue,
value: internalValue,
onChange: setInternalValue,

View File

@@ -112,8 +112,12 @@ const fieldTypeConditions = {
component: 'Relationship',
operators: [...base],
},
radio: {
component: 'Select',
operators: [...base],
},
select: {
component: 'Text',
component: 'Select',
operators: [...base],
},
checkbox: {

View File

@@ -0,0 +1,4 @@
export { CustomPublishButtonProps } from './Publish';
export { CustomSaveButtonProps } from './Save';
export { CustomSaveDraftButtonProps } from './SaveDraft';
export { CustomPreviewButtonProps } from './PreviewButton';

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
type Props = {
buildRowErrors: () => void;
};
export const WatchFormErrors: React.FC<Props> = ({ buildRowErrors }) => {
useThrottledEffect(() => {
buildRowErrors();
}, 250, [buildRowErrors]);
return null;
};

View File

@@ -0,0 +1,63 @@
import { Field } from '../../../../fields/config/types';
import { createNestedFieldPath } from './createNestedFieldPath';
/**
* **Returns Map with array and block field schemas**
* - Takes entity fields and returns a Map to retrieve field schemas by path without indexes
*
* **Accessing field schemas**
* - array fields: indexes must be removed from path i.e. `array.innerArray` instead of `array.0.innerArray`
* - block fields: the block slug must be appended to the path `blocksFieldName.blockSlug` instead of `blocksFieldName`
*
* @param entityFields
* @returns Map<string, Field[]>
*/
export const buildFieldSchemaMap = (entityFields: Field[]): Map<string, Field[]> => {
const fieldMap = new Map<string, Field[]>();
const buildUpMap = (fields: Field[], parentPath = '') => {
fields.forEach((field) => {
const path = createNestedFieldPath(parentPath, field);
switch (field.type) {
case 'blocks':
field.blocks.forEach((block) => {
const blockPath = `${path}.${block.slug}`;
fieldMap.set(blockPath, block.fields);
buildUpMap(block.fields, blockPath);
});
break;
case 'array':
fieldMap.set(path, field.fields);
buildUpMap(field.fields, path);
break;
case 'row':
case 'collapsible':
case 'group':
buildUpMap(field.fields, path);
break;
case 'tabs':
field.tabs.forEach((tab) => {
let tabPath = path;
if (tabPath) {
tabPath = 'name' in tab ? `${tabPath}.${tab.name}` : tabPath;
} else {
tabPath = 'name' in tab ? `${tab.name}` : tabPath;
}
buildUpMap(tab.fields, tabPath);
});
break;
default:
break;
}
});
};
buildUpMap(entityFields);
return fieldMap;
};

View File

@@ -9,7 +9,7 @@ import {
tabHasName,
} from '../../../../../fields/config/types';
import getValueWithDefault from '../../../../../fields/getDefaultValue';
import { Fields, Field, Data } from '../types';
import { Fields, FormField, Data } from '../types';
import { iterateFields } from './iterateFields';
type Args = {
@@ -24,6 +24,9 @@ type Args = {
data: Data
fullData: Data
t: TFunction
preferences: {
[key: string]: unknown
}
}
export const addFieldStatePromise = async ({
@@ -38,9 +41,10 @@ export const addFieldStatePromise = async ({
id,
operation,
t,
preferences,
}: Args): Promise<void> => {
if (fieldAffectsData(field)) {
const fieldState: Field = {
const fieldState: FormField = {
valid: true,
value: undefined,
initialValue: undefined,
@@ -78,16 +82,17 @@ export const addFieldStatePromise = async ({
switch (field.type) {
case 'array': {
const arrayValue = Array.isArray(valueWithDefault) ? valueWithDefault : [];
const promises = arrayValue.map((row, i) => {
const { promises, rowMetadata } = arrayValue.reduce((acc, row, i) => {
const rowPath = `${path}${field.name}.${i}.`;
row.id = row?.id || new ObjectID().toHexString();
state[`${rowPath}id`] = {
value: row.id,
initialValue: row.id || new ObjectID().toHexString(),
initialValue: row.id,
valid: true,
};
return iterateFields({
acc.promises.push(iterateFields({
state,
fields: field.fields,
data: row,
@@ -99,7 +104,21 @@ export const addFieldStatePromise = async ({
locale,
operation,
t,
preferences,
}));
const collapsedRowIDs = preferences?.fields?.[`${path}${field.name}`]?.collapsed;
acc.rowMetadata.push({
id: row.id,
collapsed: collapsedRowIDs === undefined ? field.admin.initCollapsed : collapsedRowIDs.includes(row.id),
childErrorPaths: new Set(),
});
return acc;
}, {
promises: [],
rowMetadata: [],
});
await Promise.all(promises);
@@ -117,6 +136,8 @@ export const addFieldStatePromise = async ({
}
}
fieldState.rows = rowMetadata;
// Add field to state
state[`${path}${field.name}`] = fieldState;
@@ -126,15 +147,16 @@ export const addFieldStatePromise = async ({
case 'blocks': {
const blocksValue = Array.isArray(valueWithDefault) ? valueWithDefault : [];
const promises = [];
blocksValue.forEach((row, i) => {
const { promises, rowMetadata } = blocksValue.reduce((acc, row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
const rowPath = `${path}${field.name}.${i}.`;
if (block) {
row.id = row?.id || new ObjectID().toHexString();
state[`${rowPath}id`] = {
value: row.id,
initialValue: row.id || new ObjectID().toHexString(),
initialValue: row.id,
valid: true,
};
@@ -150,7 +172,7 @@ export const addFieldStatePromise = async ({
valid: true,
};
promises.push(iterateFields({
acc.promises.push(iterateFields({
state,
fields: block.fields,
data: row,
@@ -162,10 +184,27 @@ export const addFieldStatePromise = async ({
operation,
id,
t,
preferences,
}));
const collapsedRowIDs = preferences?.fields?.[`${path}${field.name}`]?.collapsed;
acc.rowMetadata.push({
id: row.id,
collapsed: collapsedRowIDs === undefined ? field.admin.initCollapsed : collapsedRowIDs.includes(row.id),
blockType: row.blockType,
childErrorPaths: new Set(),
});
}
return acc;
}, {
promises: [],
rowMetadata: [],
});
await Promise.all(promises);
// Add values to field state
if (valueWithDefault === null) {
fieldState.value = null;
@@ -179,6 +218,8 @@ export const addFieldStatePromise = async ({
}
}
fieldState.rows = rowMetadata;
// Add field to state
state[`${path}${field.name}`] = fieldState;
@@ -198,6 +239,7 @@ export const addFieldStatePromise = async ({
locale,
user,
t,
preferences,
});
break;
@@ -227,6 +269,7 @@ export const addFieldStatePromise = async ({
locale,
operation,
t,
preferences,
});
} else if (field.type === 'tabs') {
const promises = field.tabs.map((tab) => iterateFields({
@@ -241,6 +284,7 @@ export const addFieldStatePromise = async ({
locale,
operation,
t,
preferences,
}));
await Promise.all(promises);

View File

@@ -13,6 +13,9 @@ type Args = {
operation?: 'create' | 'update'
locale: string
t: TFunction
preferences: {
[key: string]: unknown
}
}
const buildStateFromSchema = async (args: Args): Promise<Fields> => {
@@ -24,6 +27,7 @@ const buildStateFromSchema = async (args: Args): Promise<Fields> => {
operation,
locale,
t,
preferences,
} = args;
if (fieldSchema) {
@@ -41,6 +45,7 @@ const buildStateFromSchema = async (args: Args): Promise<Fields> => {
fullData,
parentPassesCondition: true,
t,
preferences,
});
return state;

View File

@@ -1,10 +1,7 @@
import type { TFunction } from 'i18next';
import { User } from '../../../../../auth';
import {
Field as FieldSchema,
fieldIsPresentationalOnly,
} from '../../../../../fields/config/types';
import { Fields, Data } from '../types';
import { Field as FieldSchema, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
import { Data, Fields } from '../types';
import { addFieldStatePromise } from './addFieldStatePromise';
type Args = {
@@ -19,6 +16,9 @@ type Args = {
id: string | number
operation: 'create' | 'update'
t: TFunction
preferences: {
[key: string]: unknown
}
}
export const iterateFields = async ({
@@ -33,12 +33,13 @@ export const iterateFields = async ({
id,
state,
t,
preferences,
}: Args): Promise<void> => {
const promises = [];
fields.forEach((field) => {
const initialData = data;
if (!fieldIsPresentationalOnly(field) && !field?.admin?.disabled) {
const passesCondition = Boolean((field?.admin?.condition ? field.admin.condition(fullData || {}, initialData || {}) : true) && parentPassesCondition);
const passesCondition = Boolean((field?.admin?.condition ? field.admin.condition(fullData || {}, initialData || {}, { user }) : true) && parentPassesCondition);
promises.push(addFieldStatePromise({
fullData,
@@ -52,6 +53,7 @@ export const iterateFields = async ({
passesCondition,
data,
t,
preferences,
}));
}
});

View File

@@ -1,11 +1,12 @@
import equal from 'deep-equal';
import ObjectID from 'bson-objectid';
import getSiblingData from './getSiblingData';
import reduceFieldsToValues from './reduceFieldsToValues';
import { Field, FieldAction, Fields } from './types';
import { FormField, FieldAction, Fields } from './types';
import deepCopyObject from '../../../../utilities/deepCopyObject';
import { flattenRows, separateRows } from './rows';
function fieldReducer(state: Fields, action: FieldAction): Fields {
export function fieldReducer(state: Fields, action: FieldAction): Fields {
switch (action.type) {
case 'REPLACE_STATE': {
const newState = {};
@@ -36,105 +37,8 @@ function fieldReducer(state: Fields, action: FieldAction): Fields {
return newState;
}
case 'REMOVE_ROW': {
const { rowIndex, path } = action;
const { remainingFields, rows } = separateRows(path, state);
rows.splice(rowIndex, 1);
const newState: Fields = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: rows.length > 0,
},
...flattenRows(path, rows),
};
return newState;
}
case 'ADD_ROW': {
const {
rowIndex, path, subFieldState, blockType,
} = action;
if (blockType) {
subFieldState.blockType = {
value: blockType,
initialValue: blockType,
valid: true,
};
}
const { remainingFields, rows } = separateRows(path, state);
rows.splice(rowIndex + 1, 0, subFieldState);
const newState = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
},
...flattenRows(path, rows),
};
return newState;
}
case 'DUPLICATE_ROW': {
const {
rowIndex, path,
} = action;
const { remainingFields, rows } = separateRows(path, state);
const duplicate = deepCopyObject(rows[rowIndex]);
if (duplicate.id) delete duplicate.id;
// If there are subfields
if (Object.keys(duplicate).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicate);
}
const newState = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
},
...flattenRows(path, rows),
};
return newState;
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action;
const { remainingFields, rows } = separateRows(path, state);
// copy the row to move
const copyOfMovingRow = rows[moveFromIndex];
// delete the row by index
rows.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
rows.splice(moveToIndex, 0, copyOfMovingRow);
const newState = {
...remainingFields,
...flattenRows(path, rows),
};
return newState;
}
case 'MODIFY_CONDITION': {
const { path, result } = action;
const { path, result, user } = action;
return Object.entries(state).reduce((newState, [fieldPath, field]) => {
if (fieldPath === path || fieldPath.indexOf(`${path}.`) === 0) {
@@ -145,7 +49,7 @@ function fieldReducer(state: Fields, action: FieldAction): Fields {
// Besides those who still fail their own conditions
if (passesCondition && field.condition) {
passesCondition = field.condition(reduceFieldsToValues(state), getSiblingData(state, path));
passesCondition = field.condition(reduceFieldsToValues(state, true), getSiblingData(state, path), { user });
}
return {
@@ -168,7 +72,7 @@ function fieldReducer(state: Fields, action: FieldAction): Fields {
case 'UPDATE': {
const newField = Object.entries(action).reduce((field, [key, value]) => {
if (['value', 'valid', 'errorMessage', 'disableFormData', 'initialValue', 'validate', 'condition', 'passesCondition'].includes(key)) {
if (['value', 'valid', 'errorMessage', 'disableFormData', 'initialValue', 'validate', 'condition', 'passesCondition', 'rows'].includes(key)) {
return {
...field,
[key]: value,
@@ -176,7 +80,7 @@ function fieldReducer(state: Fields, action: FieldAction): Fields {
}
return field;
}, state[action.path] || {} as Field);
}, state[action.path] || {} as FormField);
return {
...state,
@@ -184,10 +88,233 @@ function fieldReducer(state: Fields, action: FieldAction): Fields {
};
}
case 'REMOVE_ROW': {
const { rowIndex, path } = action;
const { remainingFields, rows } = separateRows(path, state);
const rowsMetadata = [...state[path]?.rows || []];
rows.splice(rowIndex, 1);
rowsMetadata.splice(rowIndex, 1);
const newState: Fields = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: rows.length > 0,
rows: rowsMetadata,
},
...flattenRows(path, rows),
};
return newState;
}
case 'ADD_ROW': {
const { rowIndex, path, subFieldState, blockType } = action;
const rowsMetadata = [...state[path]?.rows || []];
rowsMetadata.splice(
rowIndex + 1,
0,
// new row
{
id: new ObjectID().toHexString(),
collapsed: false,
blockType: blockType || undefined,
childErrorPaths: new Set(),
},
);
if (blockType) {
subFieldState.blockType = {
value: blockType,
initialValue: blockType,
valid: true,
};
}
const { remainingFields, rows } = separateRows(path, state);
// actual form state (value saved in db)
rows.splice(rowIndex + 1, 0, subFieldState);
const newState: Fields = {
...remainingFields,
...flattenRows(path, rows),
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
rows: rowsMetadata,
},
};
return newState;
}
case 'REPLACE_ROW': {
const { rowIndex: rowIndexArg, path, blockType, subFieldState } = action;
const { remainingFields, rows } = separateRows(path, state);
const rowIndex = Math.max(0, Math.min(rowIndexArg, rows?.length - 1 || 0));
const rowsMetadata = [...state[path]?.rows || []];
rowsMetadata[rowIndex] = {
id: new ObjectID().toHexString(),
collapsed: false,
blockType: blockType || undefined,
childErrorPaths: new Set(),
};
if (blockType) {
subFieldState.blockType = {
value: blockType,
initialValue: blockType,
valid: true,
};
}
// replace form field state
rows[rowIndex] = subFieldState;
const newState: Fields = {
...remainingFields,
...flattenRows(path, rows),
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
rows: rowsMetadata,
},
};
return newState;
}
case 'DUPLICATE_ROW': {
const { rowIndex, path } = action;
const { remainingFields, rows } = separateRows(path, state);
const rowsMetadata = state[path]?.rows || [];
const duplicateRowMetadata = deepCopyObject(rowsMetadata[rowIndex]);
if (duplicateRowMetadata.id) duplicateRowMetadata.id = new ObjectID().toHexString();
const duplicateRowState = deepCopyObject(rows[rowIndex]);
if (duplicateRowState.id) duplicateRowState.id = new ObjectID().toHexString();
// If there are subfields
if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState);
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata);
}
const newState = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
rows: rowsMetadata,
},
...flattenRows(path, rows),
};
return newState;
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action;
const { remainingFields, rows } = separateRows(path, state);
// copy the row to move
const copyOfMovingRow = rows[moveFromIndex];
// delete the row by index
rows.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
rows.splice(moveToIndex, 0, copyOfMovingRow);
// modify array/block internal row state (i.e. collapsed, blockType)
const rowStateCopy = [...state[path]?.rows || []];
const movingRowState = { ...rowStateCopy[moveFromIndex] };
rowStateCopy.splice(moveFromIndex, 1);
rowStateCopy.splice(moveToIndex, 0, movingRowState);
const newState = {
...remainingFields,
...flattenRows(path, rows),
[path]: {
...state[path],
rows: rowStateCopy,
},
};
return newState;
}
case 'SET_ROW_COLLAPSED': {
const { rowID, path, collapsed, setDocFieldPreferences } = action;
const arrayState = state[path];
const { matchedIndex, collapsedRowIDs } = state[path].rows.reduce((acc, row, index) => {
const isMatchingRow = row.id === rowID;
if (isMatchingRow) acc.matchedIndex = index;
if (!isMatchingRow && row.collapsed) acc.collapsedRowIDs.push(row.id);
else if (isMatchingRow && collapsed) acc.collapsedRowIDs.push(row.id);
return acc;
}, {
matchedIndex: undefined,
collapsedRowIDs: [],
});
if (matchedIndex > -1) {
arrayState.rows[matchedIndex].collapsed = collapsed;
setDocFieldPreferences(path, { collapsed: collapsedRowIDs });
}
const newState = {
...state,
[path]: {
...arrayState,
},
};
return newState;
}
case 'SET_ALL_ROWS_COLLAPSED': {
const { collapsed, path, setDocFieldPreferences } = action;
const { rows, collapsedRowIDs } = state[path].rows.reduce((acc, row) => {
if (collapsed) acc.collapsedRowIDs.push(row.id);
acc.rows.push({
...row,
collapsed,
});
return acc;
}, {
rows: [],
collapsedRowIDs: [],
});
setDocFieldPreferences(path, { collapsed: collapsedRowIDs });
return {
...state,
[path]: {
...state[path],
rows,
},
};
}
default: {
return state;
}
}
}
export default fieldReducer;

View File

@@ -12,7 +12,7 @@ import { useLocale } from '../../utilities/Locale';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { requests } from '../../../api';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import fieldReducer from './fieldReducer';
import { fieldReducer } from './fieldReducer';
import initContextState from './initContextState';
import reduceFieldsToValues from './reduceFieldsToValues';
import getSiblingDataFunc from './getSiblingData';
@@ -21,10 +21,15 @@ import wait from '../../../../utilities/wait';
import { Field } from '../../../../fields/config/types';
import buildInitialState from './buildInitialState';
import errorMessages from './errorMessages';
import { Context as FormContextType, GetDataByPath, Props, SubmitOptions } from './types';
import { Context, Fields, Context as FormContextType, GetDataByPath, Props, Row, SubmitOptions } from './types';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormFieldsContext, FormWatchContext } from './context';
import buildStateFromSchema from './buildStateFromSchema';
import { useOperation } from '../../utilities/OperationProvider';
import { WatchFormErrors } from './WatchFormErrors';
import { splitPathByArrayFields } from '../../../../utilities/splitPathByArrayFields';
import { setsAreEqual } from '../../../../utilities/setsAreEqual';
import { buildFieldSchemaMap } from './buildFieldSchemaMap';
import { isNumber } from '../../../../utilities/isNumber';
const baseClass = 'form';
@@ -49,7 +54,7 @@ const Form: React.FC<Props> = (props) => {
const locale = useLocale();
const { t, i18n } = useTranslation('general');
const { refreshCookie, user } = useAuth();
const { id } = useDocumentInfo();
const { id, getDocPreferences, collection, global } = useDocumentInfo();
const operation = useOperation();
const [modified, setModified] = useState(false);
@@ -71,6 +76,62 @@ const Form: React.FC<Props> = (props) => {
contextRef.current.fields = fields;
contextRef.current.dispatchFields = dispatchFields;
// Build a current set of child errors for all rows in form state
const buildRowErrors = useCallback(() => {
const existingFieldRows: { [path: string]: Row[] } = {};
const newFieldRows: { [path: string]: Row[] } = {};
Object.entries(fields).forEach(([path, field]) => {
const pathSegments = splitPathByArrayFields(path);
for (let i = 0; i < pathSegments.length; i += 1) {
const fieldPath = pathSegments.slice(0, i + 1).join('.');
const formField = fields?.[fieldPath];
// Is this an array or blocks field?
if (Array.isArray(formField?.rows)) {
// Keep a reference to the existing row state
existingFieldRows[fieldPath] = formField.rows;
// A new row state will be used to compare
// against the old state later,
// to see if we need to dispatch an update
if (!newFieldRows[fieldPath]) {
newFieldRows[fieldPath] = formField.rows.map((existingRow) => ({
...existingRow,
childErrorPaths: new Set(),
}));
}
const rowIndex = pathSegments[i + 1];
const childFieldPath = pathSegments.slice(i + 1).join('.');
if (field.valid === false && childFieldPath) {
newFieldRows[fieldPath][rowIndex].childErrorPaths.add(`${fieldPath}.${childFieldPath}`);
}
}
}
});
// Now loop over all fields with rows -
// if anything changed, dispatch an update for the field
// with the new row state
Object.entries(newFieldRows).forEach(([path, newRows]) => {
const stateMatches = newRows.every((newRow, i) => {
const existingRowErrorPaths = existingFieldRows[path][i]?.childErrorPaths;
return setsAreEqual(newRow.childErrorPaths, existingRowErrorPaths);
});
if (!stateMatches) {
dispatchFields({
type: 'UPDATE',
path,
rows: newRows,
});
}
});
}, [fields, dispatchFields]);
const validateForm = useCallback(async () => {
const validatedFieldState = {};
let isValid = true;
@@ -140,6 +201,7 @@ const Form: React.FC<Props> = (props) => {
if (waitForAutocomplete) await wait(100);
const isValid = skipValidation ? true : await contextRef.current.validateForm();
contextRef.current.buildRowErrors();
if (!skipValidation) setSubmitted(true);
@@ -277,8 +339,6 @@ const Form: React.FC<Props> = (props) => {
toast.error(message);
}
return;
} catch (err) {
setProcessing(false);
@@ -301,6 +361,92 @@ const Form: React.FC<Props> = (props) => {
waitForAutocomplete,
]);
const traverseRowConfigs = React.useCallback(({ pathPrefix, path, fieldConfig }: {
path: string,
fieldConfig: Field[]
pathPrefix?: string,
}) => {
const config = fieldConfig;
const pathSegments = splitPathByArrayFields(path);
const configMap = buildFieldSchemaMap(config);
for (let i = 0; i < pathSegments.length; i += 1) {
const pathSegment = pathSegments[i];
if (isNumber(pathSegment)) {
const rowIndex = parseInt(pathSegment, 10);
const parentFieldPath = pathSegments.slice(0, i).join('.');
const remainingPath = pathSegments.slice(i + 1).join('.');
const arrayFieldPath = pathPrefix ? `${pathPrefix}.${parentFieldPath}` : parentFieldPath;
const parentArrayField = contextRef.current.getField(arrayFieldPath);
const rowField = parentArrayField.rows[rowIndex];
if (rowField.blockType) {
const blockConfig = configMap.get(`${parentFieldPath}.${rowField.blockType}`);
if (blockConfig) {
return traverseRowConfigs({
pathPrefix: `${arrayFieldPath}.${rowIndex}`,
path: remainingPath,
fieldConfig: blockConfig,
});
}
throw new Error(`Block config not found for ${rowField.blockType} at path ${path}`);
} else {
return traverseRowConfigs({
pathPrefix: `${arrayFieldPath}.${rowIndex}`,
path: remainingPath,
fieldConfig: configMap.get(parentFieldPath),
});
}
}
}
return config;
}, []);
const getRowConfigByPath = React.useCallback(({ path, blockType }: {
path: string,
blockType?: string
}) => {
const rowConfig = traverseRowConfigs({ path, fieldConfig: collection?.fields || global?.fields });
const rowFieldConfigs = buildFieldSchemaMap(rowConfig);
const pathSegments = splitPathByArrayFields(path);
const fieldKey = pathSegments.at(-1);
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey);
}, [traverseRowConfigs, collection?.fields, global?.fields]);
// Array/Block row manipulation
const addFieldRow: Context['addFieldRow'] = useCallback(async ({ path, rowIndex, data }) => {
const preferences = await getDocPreferences();
const fieldConfig = getRowConfigByPath({
path,
blockType: data?.blockType,
});
if (fieldConfig) {
const subFieldState = await buildStateFromSchema({ fieldSchema: fieldConfig, data, preferences, operation, id, user, locale, t });
dispatchFields({ type: 'ADD_ROW', rowIndex: rowIndex - 1, path, blockType: data?.blockType, subFieldState });
}
}, [dispatchFields, getDocPreferences, id, user, operation, locale, t, getRowConfigByPath]);
const removeFieldRow: Context['removeFieldRow'] = useCallback(async ({ path, rowIndex }) => {
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
}, [dispatchFields]);
const replaceFieldRow: Context['replaceFieldRow'] = useCallback(async ({ path, rowIndex, data }) => {
const preferences = await getDocPreferences();
const fieldConfig = getRowConfigByPath({
path,
blockType: data?.blockType,
});
if (fieldConfig) {
const subFieldState = await buildStateFromSchema({ fieldSchema: fieldConfig, data, preferences, operation, id, user, locale, t });
dispatchFields({ type: 'REPLACE_ROW', rowIndex: rowIndex - 1, path, blockType: data?.blockType, subFieldState });
}
}, [dispatchFields, getDocPreferences, id, user, operation, locale, t, getRowConfigByPath]);
const getFields = useCallback(() => contextRef.current.fields, [contextRef]);
const getField = useCallback((path: string) => contextRef.current.fields[path], [contextRef]);
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
@@ -332,10 +478,18 @@ const Form: React.FC<Props> = (props) => {
}, [contextRef]);
const reset = useCallback(async (fieldSchema: Field[], data: unknown) => {
const state = await buildStateFromSchema({ fieldSchema, data, user, id, operation, locale, t });
const preferences = await getDocPreferences();
const state = await buildStateFromSchema({ fieldSchema, preferences, data, user, id, operation, locale, t });
contextRef.current = { ...initContextState } as FormContextType;
setModified(false);
dispatchFields({ type: 'REPLACE_STATE', state });
}, [id, user, operation, locale, t, dispatchFields]);
}, [id, user, operation, locale, t, dispatchFields, getDocPreferences]);
const replaceState = useCallback((state: Fields) => {
contextRef.current = { ...initContextState } as FormContextType;
setModified(false);
dispatchFields({ type: 'REPLACE_STATE', state });
}, [dispatchFields]);
contextRef.current.submit = submit;
contextRef.current.getFields = getFields;
@@ -351,6 +505,11 @@ const Form: React.FC<Props> = (props) => {
contextRef.current.disabled = disabled;
contextRef.current.formRef = formRef;
contextRef.current.reset = reset;
contextRef.current.replaceState = replaceState;
contextRef.current.buildRowErrors = buildRowErrors;
contextRef.current.addFieldRow = addFieldRow;
contextRef.current.removeFieldRow = removeFieldRow;
contextRef.current.replaceFieldRow = replaceFieldRow;
useEffect(() => {
if (initialState) {
@@ -401,6 +560,7 @@ const Form: React.FC<Props> = (props) => {
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
<FormFieldsContext.Provider value={fieldsReducer}>
<WatchFormErrors buildRowErrors={buildRowErrors} />
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>

View File

@@ -1,6 +1,6 @@
import {
Fields,
Field,
FormField,
Data,
DispatchFields,
Submit,
@@ -27,7 +27,7 @@ const reset: Reset = () => undefined;
const initialContextState: Context = {
getFields: (): Fields => ({}),
getField: (): Field => undefined,
getField: (): FormField => undefined,
getData: (): Data => undefined,
getSiblingData,
getDataByPath: () => undefined,
@@ -42,6 +42,11 @@ const initialContextState: Context = {
disabled: false,
formRef: null,
reset,
replaceState: () => undefined,
buildRowErrors: () => undefined,
addFieldRow: () => undefined,
removeFieldRow: () => undefined,
replaceFieldRow: () => undefined,
};
export default initialContextState;

View File

@@ -14,7 +14,7 @@ export const separateRows = (path: string, fields: Fields): Result => {
if (fieldPath.indexOf(`${path}.`) === 0) {
const index = Number(fieldPath.replace(`${path}.`, '').split('.')[0]);
if (!newRows[index]) newRows[index] = {};
newRows[index][fieldPath.replace(`${path}.${String(index)}.`, '')] = field;
newRows[index][fieldPath.replace(`${path}.${String(index)}.`, '')] = { ...field };
} else {
remainingFields[fieldPath] = field;
}
@@ -34,7 +34,7 @@ export const flattenRows = (path: string, rows: Fields[]): Fields => {
...Object.entries(row).reduce((subFields, [subPath, subField]) => {
return {
...subFields,
[`${path}.${i}.${subPath}`]: subField,
[`${path}.${i}.${subPath}`]: { ...subField },
};
}, {}),
}), {});

View File

@@ -1,7 +1,15 @@
import React, { Dispatch } from 'react';
import { Field as FieldConfig, Condition, Validate } from '../../../../fields/config/types';
import { Condition, Field as FieldConfig, Validate } from '../../../../fields/config/types';
import { User } from '../../../../auth/types';
export type Field = {
export type Row = {
id: string
collapsed?: boolean
blockType?: string
childErrorPaths?: Set<string>
}
export type FormField = {
value: unknown
initialValue: unknown
errorMessage?: string
@@ -10,10 +18,11 @@ export type Field = {
disableFormData?: boolean
condition?: Condition
passesCondition?: boolean
rows?: Row[]
}
export type Fields = {
[path: string]: Field
[path: string]: FormField
}
export type Data = {
@@ -54,7 +63,7 @@ export type Submit = (options?: SubmitOptions, e?: React.FormEvent<HTMLFormEleme
export type ValidateForm = () => Promise<boolean>;
export type CreateFormData = (overrides?: any) => FormData;
export type GetFields = () => Fields;
export type GetField = (path: string) => Field;
export type GetField = (path: string) => FormField;
export type GetData = () => Data;
export type GetSiblingData = (path: string) => Data;
export type GetDataByPath = <T = unknown>(path: string) => T;
@@ -74,6 +83,18 @@ export type REMOVE = {
path: string
}
export type MODIFY_CONDITION = {
type: 'MODIFY_CONDITION'
path: string
result: boolean
user: User
}
export type UPDATE = {
type: 'UPDATE'
path: string
} & Partial<FormField>
export type REMOVE_ROW = {
type: 'REMOVE_ROW'
rowIndex: number
@@ -82,6 +103,14 @@ export type REMOVE_ROW = {
export type ADD_ROW = {
type: 'ADD_ROW'
rowIndex?: number
path: string
subFieldState?: Fields
blockType?: string
}
export type REPLACE_ROW = {
type: 'REPLACE_ROW'
rowIndex: number
path: string
subFieldState?: Fields
@@ -101,26 +130,33 @@ export type MOVE_ROW = {
path: string
}
export type MODIFY_CONDITION = {
type: 'MODIFY_CONDITION'
export type SET_ROW_COLLAPSED = {
type: 'SET_ROW_COLLAPSED'
path: string
result: boolean
rowID: string
collapsed: boolean
setDocFieldPreferences: (field: string, fieldPreferences: { [key: string]: unknown }) => void
}
export type UPDATE = {
type: 'UPDATE'
export type SET_ALL_ROWS_COLLAPSED = {
type: 'SET_ALL_ROWS_COLLAPSED'
path: string
} & Partial<Field>
collapsed: boolean
setDocFieldPreferences: (field: string, fieldPreferences: { [key: string]: unknown }) => void
}
export type FieldAction =
| REPLACE_STATE
| REMOVE
| REMOVE_ROW
| ADD_ROW
| DUPLICATE_ROW
| MOVE_ROW
| MODIFY_CONDITION
| UPDATE
| REMOVE_ROW
| ADD_ROW
| REPLACE_ROW
| DUPLICATE_ROW
| MOVE_ROW
| SET_ROW_COLLAPSED
| SET_ALL_ROWS_COLLAPSED
export type FormFieldsContext = [Fields, Dispatch<FieldAction>]
@@ -144,4 +180,9 @@ export type Context = {
setSubmitted: SetSubmitted
formRef: React.MutableRefObject<HTMLFormElement>
reset: Reset
replaceState: (state: Fields) => void
buildRowErrors: () => void
addFieldRow: ({ path, rowIndex, data }: { path: string, rowIndex: number, data?: Data }) => Promise<void>
removeFieldRow: ({ path, rowIndex }: { path: string, rowIndex: number }) => Promise<void>
replaceFieldRow: ({ path, rowIndex, data }: { path: string, rowIndex: number, data?: Data }) => Promise<void>
}

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Props } from './types';

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Field, TabAsField, fieldAffectsData, fieldHasSubFields, tabHasName } from '../../../../fields/config/types';
import { useAllFormFields, useFormSubmitted } from '../Form/context';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
const buildPathSegments = (parentPath: string, fieldSchema: Field[]): string[] => {
const pathNames = fieldSchema.reduce((acc, subField) => {
if (fieldHasSubFields(subField) && fieldAffectsData(subField)) {
// group, block, array
acc.push(parentPath ? `${parentPath}.${subField.name}.` : `${subField.name}.`);
} else if (fieldHasSubFields(subField)) {
// rows, collapsibles, unnamed-tab
acc.push(...buildPathSegments(parentPath, subField.fields));
} else if (subField.type === 'tabs') {
// tabs
subField.tabs.forEach((tab: TabAsField) => {
let tabPath = parentPath;
if (tabHasName(tab)) {
tabPath = parentPath ? `${parentPath}.${tab.name}` : tab.name;
}
acc.push(...buildPathSegments(tabPath, tab.fields));
});
} else if (fieldAffectsData(subField)) {
// text, number, date, etc.
acc.push(parentPath ? `${parentPath}.${subField.name}` : subField.name);
}
return acc;
}, []);
return pathNames;
};
type TrackSubSchemaErrorCountProps = {
/**
* Only for collapsibles, and unnamed-tabs
*/
fieldSchema?: Field[];
path: string;
setErrorCount: (count: number) => void;
}
export const WatchChildErrors: React.FC<TrackSubSchemaErrorCountProps> = ({ path, fieldSchema, setErrorCount }) => {
const [fields] = useAllFormFields();
const hasSubmitted = useFormSubmitted();
const [pathSegments] = React.useState(() => {
if (fieldSchema) {
return buildPathSegments(path, fieldSchema);
}
return [`${path}.`];
});
useThrottledEffect(() => {
let errorCount = 0;
if (hasSubmitted) {
Object.entries(fields).forEach(([key]) => {
const matchingSegment = pathSegments.some((segment) => {
if (segment.endsWith('.')) {
return key.startsWith(segment);
}
return key === segment;
});
if (matchingSegment) {
if ('valid' in fields[key] && !fields[key].valid) {
errorCount += 1;
}
}
});
}
setErrorCount(errorCount);
}, 250, [fields, hasSubmitted, pathSegments]);
return null;
};

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '../../../elements/Collapsible';
import RenderFields from '../../RenderFields';
import { Props } from './types';
import { ArrayAction } from '../../../elements/ArrayAction';
import HiddenInput from '../HiddenInput';
import { RowLabel } from '../../RowLabel';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import type { UseDraggableSortableReturn } from '../../../elements/DraggableSortable/useDraggableSortable/types';
import type { Row } from '../../Form/types';
import type { RowLabel as RowLabelType } from '../../RowLabel/types';
import { useFormSubmitted } from '../../Form/context';
import { ErrorPill } from '../../../elements/ErrorPill';
import './index.scss';
const baseClass = 'array-field';
type ArrayRowProps = UseDraggableSortableReturn & Pick<Props, 'fields' | 'path' | 'indexPath' | 'fieldTypes' | 'permissions' | 'labels'> & {
addRow: (rowIndex: number) => void
duplicateRow: (rowIndex: number) => void
removeRow: (rowIndex: number) => void
moveRow: (fromIndex: number, toIndex: number) => void
setCollapse: (rowID: string, collapsed: boolean) => void
rowCount: number
rowIndex: number
row: Row
CustomRowLabel?: RowLabelType
readOnly?: boolean
hasMaxRows?: boolean
}
export const ArrayRow: React.FC<ArrayRowProps> = ({
path: parentPath,
addRow,
removeRow,
moveRow,
duplicateRow,
setCollapse,
transform,
listeners,
attributes,
setNodeRef,
row,
rowIndex,
rowCount,
indexPath,
readOnly,
labels,
fieldTypes,
permissions,
CustomRowLabel,
fields,
hasMaxRows,
}) => {
const path = `${parentPath}.${rowIndex}`;
const { i18n } = useTranslation();
const hasSubmitted = useFormSubmitted();
const fallbackLabel = `${getTranslation(labels.singular, i18n)} ${String(rowIndex + 1).padStart(2, '0')}`;
const childErrorPathsCount = row.childErrorPaths?.size;
const fieldHasErrors = hasSubmitted && childErrorPathsCount > 0;
const classNames = [
`${baseClass}__row`,
fieldHasErrors ? `${baseClass}__row--has-errors` : `${baseClass}__row--no-errors`,
].filter(Boolean).join(' ');
return (
<div
key={`${parentPath}-row-${row.id}`}
id={`${parentPath.split('.').join('-')}-row-${rowIndex}`}
ref={setNodeRef}
style={{
transform,
}}
>
<Collapsible
collapsed={row.collapsed}
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
className={classNames}
collapsibleStyle={fieldHasErrors ? 'error' : 'default'}
dragHandleProps={{
id: row.id,
attributes,
listeners,
}}
header={(
<div className={`${baseClass}__row-header`}>
<RowLabel
path={path}
label={CustomRowLabel || fallbackLabel}
rowNumber={rowIndex + 1}
/>
{fieldHasErrors && (
<ErrorPill
count={childErrorPathsCount}
withMessage
/>
)}
</div>
)}
actions={!readOnly ? (
<ArrayAction
addRow={addRow}
removeRow={removeRow}
moveRow={moveRow}
duplicateRow={duplicateRow}
rowCount={rowCount}
index={rowIndex}
hasMaxRows={hasMaxRows}
/>
) : undefined}
>
<HiddenInput
name={`${path}.id`}
value={row.id}
/>
<RenderFields
className={`${baseClass}__fields`}
readOnly={readOnly}
fieldTypes={fieldTypes}
permissions={permissions?.fields}
indexPath={indexPath}
fieldSchema={fields.map((field) => ({
...field,
path: createNestedFieldPath(path, field),
}))}
/>
</Collapsible>
</div>
);
};

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