Merge remote-tracking branch 'origin/master' into pr/2500
@@ -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';
|
||||
|
||||
BIN
src/admin/assets/images/benefits-banner.jpg
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
src/admin/assets/images/cloud-cta-inline.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
src/admin/assets/images/github-banner-alt.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
src/admin/assets/images/github-banner.jpg
Normal file
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,4 +5,5 @@ export type Props = {
|
||||
moveRow: (from: number, to: number) => void
|
||||
index: number
|
||||
rowCount: number
|
||||
hasMaxRows: boolean
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,4 +10,5 @@ export type Props = {
|
||||
onToggle?: (collapsed: boolean) => void
|
||||
initCollapsed?: boolean
|
||||
dragHandleProps?: DragHandleProps
|
||||
collapsibleStyle?: 'default' | 'error'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)});
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-right: $baseline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ const DraggableSortable: React.FC<Props> = (props) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
delay: 100,
|
||||
tolerance: 5,
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
43
src/admin/components/elements/ErrorPill/index.scss
Normal 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)
|
||||
}
|
||||
}
|
||||
30
src/admin/components/elements/ErrorPill/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
src/admin/components/elements/ErrorPill/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Props = {
|
||||
count: number,
|
||||
className?: string,
|
||||
withMessage?: boolean,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -6,5 +6,7 @@ export type Props = {
|
||||
width?: number,
|
||||
height?: number,
|
||||
sizes?: unknown,
|
||||
url?: string
|
||||
url?: string,
|
||||
id?: string,
|
||||
collection?: string
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
&:focus:not(:focus-visible),
|
||||
&:focus-within:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ const ClickableArrow: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
disabled={isDisabled}
|
||||
onClick={!isDisabled ? updatePage : undefined}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { GeneratePreviewURL } from '../../../../config/types';
|
||||
|
||||
export type Props = {
|
||||
generatePreviewURL?: GeneratePreviewURL,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type Props = {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.react-select--single-value {
|
||||
.rs__single-value {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
src/admin/components/elements/Save/index.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type Props = {
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: var(--accessibility-outline);
|
||||
outline-offset: var(--accessibility-outline-offset);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
|
||||
th,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.thumbnail-card {
|
||||
@include btn-reset;
|
||||
@include shadow;
|
||||
width: 100%;
|
||||
background: var(--theme-input-bg);
|
||||
|
||||
&__label {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.versions-count__button {
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -112,8 +112,12 @@ const fieldTypeConditions = {
|
||||
component: 'Relationship',
|
||||
operators: [...base],
|
||||
},
|
||||
radio: {
|
||||
component: 'Select',
|
||||
operators: [...base],
|
||||
},
|
||||
select: {
|
||||
component: 'Text',
|
||||
component: 'Select',
|
||||
operators: [...base],
|
||||
},
|
||||
checkbox: {
|
||||
|
||||
4
src/admin/components/elements/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CustomPublishButtonProps } from './Publish';
|
||||
export { CustomSaveButtonProps } from './Save';
|
||||
export { CustomSaveDraftButtonProps } from './SaveDraft';
|
||||
export { CustomPreviewButtonProps } from './PreviewButton';
|
||||
13
src/admin/components/forms/Form/WatchFormErrors.tsx
Normal 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;
|
||||
};
|
||||
63
src/admin/components/forms/Form/buildFieldSchemaMap.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}, {}),
|
||||
}), {});
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Props } from './types';
|
||||
|
||||
76
src/admin/components/forms/WatchChildErrors/index.ts
Normal 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;
|
||||
};
|
||||
135
src/admin/components/forms/field-types/Array/ArrayRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||