merges with master

This commit is contained in:
Jarrod Flesch
2020-07-27 12:38:26 -04:00
271 changed files with 9042 additions and 6899 deletions

View File

@@ -1,24 +1,9 @@
import Cookies from 'universal-cookie';
import qs from 'qs';
import config from 'payload/config';
const { cookiePrefix } = config;
const cookieTokenName = `${cookiePrefix}-token`;
export const getJWTHeader = () => {
const cookies = new Cookies();
const jwt = cookies.get(cookieTokenName);
return jwt ? { Authorization: `JWT ${jwt}` } : {};
};
export const requests = {
get: (url, params) => {
const query = qs.stringify(params, { addQueryPrefix: true, depth: 10 });
return fetch(`${url}${query}`, {
headers: {
...getJWTHeader(),
},
});
return fetch(`${url}${query}`);
},
post: (url, options = {}) => {
@@ -29,7 +14,6 @@ export const requests = {
method: 'post',
headers: {
...headers,
...getJWTHeader(),
},
};
@@ -44,7 +28,6 @@ export const requests = {
method: 'put',
headers: {
...headers,
...getJWTHeader(),
},
};
@@ -58,7 +41,6 @@ export const requests = {
method: 'delete',
headers: {
...headers,
...getJWTHeader(),
},
});
},

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect,
Route, Switch, withRouter, Redirect, useHistory,
} from 'react-router-dom';
import config from 'payload/config';
import List from './views/collections/List';
@@ -25,17 +25,22 @@ const {
} = config;
const Routes = () => {
const history = useHistory();
const [initialized, setInitialized] = useState(null);
const { user, permissions, permissions: { canAccessAdmin } } = useUser();
useEffect(() => {
requests.get(`${routes.api}/${userSlug}/init`).then(res => res.json().then((data) => {
requests.get(`${routes.api}/${userSlug}/init`).then((res) => res.json().then((data) => {
if (data && 'initialized' in data) {
setInitialized(data.initialized);
}
}));
}, []);
useEffect(() => {
history.replace();
}, [history]);
return (
<Route
path={routes.admin}
@@ -62,6 +67,9 @@ const Routes = () => {
<Route path={`${match.url}/logout`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Logout inactivity />
</Route>
<Route path={`${match.url}/forgot`}>
<ForgotPassword />
</Route>
@@ -94,14 +102,12 @@ const Routes = () => {
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
return (
<List
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<List
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -116,14 +122,12 @@ const Routes = () => {
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
return (
<Edit
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<Edit
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -138,15 +142,13 @@ const Routes = () => {
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
return (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
);
}}
render={(routeProps) => (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
)}
/>
);
}
@@ -161,14 +163,12 @@ const Routes = () => {
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
return (
<EditGlobal
{...routeProps}
global={global}
/>
);
}}
render={(routeProps) => (
<EditGlobal
{...routeProps}
global={global}
/>
)}
/>
);
}
@@ -189,6 +189,10 @@ const Routes = () => {
return <Loading />;
}
if (user === undefined) {
return <Loading />;
}
return <Redirect to={`${match.url}/login`} />;
}}
/>

View File

@@ -24,9 +24,9 @@ function recursivelyAddFieldComponents(fields) {
};
}
if (field.components || field.fields) {
if (field.admin.components || field.fields) {
const fieldComponents = {
...(field.components || {}),
...(field.admin.components || {}),
};
if (field.fields) {
@@ -56,7 +56,7 @@ function customComponents(config) {
newComponents[collection.slug] = {
fields: recursivelyAddFieldComponents(collection.fields),
...(collection.components || {}),
...(collection.admin.components || {}),
};
return newComponents;
@@ -67,7 +67,7 @@ function customComponents(config) {
newComponents[global.slug] = {
fields: recursivelyAddFieldComponents(global.fields),
...(global.components || {}),
...(global.admin.components || {}),
};
return newComponents;
@@ -76,7 +76,7 @@ function customComponents(config) {
const string = stringify({
...(allCollectionComponents || {}),
...(allGlobalComponents || {}),
...(config.components || {}),
...(config.admin.components || {}),
}).replace(/\\/g, '\\\\');
return {

View File

@@ -1,18 +1,16 @@
import React, {
useState, createContext, useContext, useEffect, useCallback,
} from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import jwt from 'jsonwebtoken';
import { useLocation, useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import Cookies from 'universal-cookie';
import config from 'payload/config';
import { useModal } from '@trbl/react-modal';
import { useModal } from '@faceless-ui/modal';
import { requests } from '../../api';
import StayLoggedInModal from '../modals/StayLoggedIn';
import useDebounce from '../../hooks/useDebounce';
const {
cookiePrefix,
admin: {
user: userSlug,
},
@@ -23,16 +21,12 @@ const {
},
} = config;
const cookieTokenName = `${cookiePrefix}-token`;
const cookies = new Cookies();
const Context = createContext({});
const isNotExpired = decodedJWT => (decodedJWT?.exp || 0) > Date.now() / 1000;
const UserProvider = ({ children }) => {
const [token, setToken] = useState('');
const [user, setUser] = useState(null);
const [user, setUser] = useState(undefined);
const [tokenInMemory, setTokenInMemory] = useState(null);
const exp = user?.exp;
const [permissions, setPermissions] = useState({ canAccessAdmin: null });
@@ -42,64 +36,72 @@ const UserProvider = ({ children }) => {
const [lastLocationChange, setLastLocationChange] = useState(0);
const debouncedLocationChange = useDebounce(lastLocationChange, 10000);
const exp = user?.exp || 0;
const email = user?.email;
const refreshToken = useCallback(() => {
// Need to retrieve token straight from cookie so as to keep this function
// with no dependencies and to make sure we have the exact token that will be used
// in the request to the /refresh route
const tokenFromCookie = cookies.get(cookieTokenName);
const decodedToken = jwt.decode(tokenFromCookie);
const refreshCookie = useCallback(() => {
const now = Math.round((new Date()).getTime() / 1000);
const remainingTime = (exp || 0) - now;
if (decodedToken?.exp > (Date.now() / 1000)) {
if (exp && remainingTime < 120) {
setTimeout(async () => {
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`);
if (request.status === 200) {
const json = await request.json();
setToken(json.refreshedToken);
setUser(json.user);
} else {
history.push(`${admin}/logout-inactivity`);
}
}, 1000);
}
}, [setToken]);
}, [setUser, history, exp]);
const setToken = useCallback((token) => {
const decoded = jwt.decode(token);
setUser(decoded);
setTokenInMemory(token);
}, []);
const logOut = () => {
setUser(null);
setToken(null);
cookies.remove(cookieTokenName, { path: '/' });
setTokenInMemory(null);
requests.get(`${serverURL}${api}/${userSlug}/logout`);
};
// On mount, get cookie and set as token
// On mount, get user and set
useEffect(() => {
const cookieToken = cookies.get(cookieTokenName);
if (cookieToken) setToken(cookieToken);
}, []);
const fetchMe = async () => {
const request = await requests.get(`${serverURL}${api}/${userSlug}/me`);
// When location changes, refresh token
if (request.status === 200) {
const json = await request.json();
setUser(json?.user || null);
if (json?.token) {
setToken(json.token);
}
}
};
fetchMe();
}, [setToken]);
// When location changes, refresh cookie
useEffect(() => {
refreshToken();
}, [debouncedLocationChange, refreshToken]);
if (email) {
refreshCookie();
}
}, [debouncedLocationChange, refreshCookie, email]);
useEffect(() => {
setLastLocationChange(Date.now());
}, [pathname]);
// When token changes, set cookie, decode and set user
useEffect(() => {
if (token) {
const decoded = jwt.decode(token);
if (isNotExpired(decoded)) {
setUser(decoded);
cookies.set(cookieTokenName, token, { path: '/' });
}
}
}, [token]);
// When user changes, get new policies
// When user changes, get new access
useEffect(() => {
async function getPermissions() {
const request = await requests.get(`${serverURL}${api}/policies`);
const request = await requests.get(`${serverURL}${api}/access`);
if (request.status === 200) {
const json = await request.json();
@@ -135,7 +137,6 @@ const UserProvider = ({ children }) => {
if (remainingTime > 0) {
forceLogOut = setTimeout(() => {
logOut();
history.push(`${admin}/logout`);
closeAllModals();
}, remainingTime * 1000);
@@ -149,15 +150,15 @@ const UserProvider = ({ children }) => {
return (
<Context.Provider value={{
user,
setToken,
logOut,
refreshToken,
token,
refreshCookie,
permissions,
setToken,
token: tokenInMemory,
}}
>
{children}
<StayLoggedInModal refreshToken={refreshToken} />
<StayLoggedInModal refreshCookie={refreshCookie} />
</Context.Provider>
);
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../Button';
import './index.scss';
const baseClass = 'card';
const Card = (props) => {
const { title, actions, onClick } = props;
const classes = [
baseClass,
onClick && `${baseClass}--has-onclick`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<h5>
{title}
</h5>
{actions && (
<div className={`${baseClass}__actions`}>
{actions}
</div>
)}
{onClick && (
<Button
className={`${baseClass}__click`}
buttonStyle="none"
onClick={onClick}
/>
)}
</div>
);
};
Card.defaultProps = {
actions: null,
onClick: undefined,
};
Card.propTypes = {
title: PropTypes.string.isRequired,
actions: PropTypes.node,
onClick: PropTypes.func,
};
export default Card;

View File

@@ -0,0 +1,41 @@
@import '../../../scss/styles';
.card {
background: $color-background-gray;
padding: base(1.25) $baseline;
position: relative;
h5 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__actions {
position: relative;
z-index: 2;
margin-top: base(.5);
display: inline-flex;
.btn {
margin: 0;
}
}
&--has-onclick {
cursor: pointer;
&:hover {
background: darken($color-background-gray, 3%);
}
}
&__click {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}

View File

@@ -1,10 +1,12 @@
const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
let initialColumns = [];
const hasThumbnail = fields.find(field => field.type === 'thumbnail');
const hasThumbnail = fields.find((field) => field.type === 'thumbnail');
if (Array.isArray(defaultColumns)) {
initialColumns = defaultColumns;
if (Array.isArray(defaultColumns) && defaultColumns.length >= 1) {
return {
columns: defaultColumns,
};
}
if (hasThumbnail) {
@@ -15,11 +17,8 @@ const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
initialColumns.push(useAsTitle);
}
const remainingColumns = fields.filter((field) => {
return field.name !== useAsTitle && field.type !== 'thumbnail';
}).slice(0, 3 - initialColumns.length).map((field) => {
return field.name;
});
const remainingColumns = fields.filter((field) => field.name !== useAsTitle && field.type !== 'thumbnail')
.slice(0, 3 - initialColumns.length).map((field) => field.name);
initialColumns = initialColumns.concat(remainingColumns);

View File

@@ -23,17 +23,19 @@ const reducer = (state, { type, payload }) => {
];
}
return state.filter(remainingColumn => remainingColumn !== payload);
return state.filter((remainingColumn) => remainingColumn !== payload);
};
const ColumnSelector = (props) => {
const {
collection: {
fields,
useAsTitle,
admin: {
useAsTitle,
defaultColumns,
},
},
handleChange,
defaultColumns,
} = props;
const [initialColumns, setInitialColumns] = useState([]);
@@ -55,7 +57,7 @@ const ColumnSelector = (props) => {
return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find(column => column === field.name);
const isEnabled = columns.find((column) => column === field.name);
return (
<Pill
onClick={() => dispatchColumns({ payload: field.name, type: isEnabled ? 'disable' : 'enable' })}
@@ -73,20 +75,18 @@ const ColumnSelector = (props) => {
);
};
ColumnSelector.defaultProps = {
defaultColumns: undefined,
};
ColumnSelector.propTypes = {
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
useAsTitle: PropTypes.string,
}),
}).isRequired,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
handleChange: PropTypes.func.isRequired,
};

View File

@@ -2,7 +2,7 @@ import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import config from 'payload/config';
import { useHistory } from 'react-router-dom';
import { Modal, useModal } from '@trbl/react-modal';
import { Modal, useModal } from '@faceless-ui/modal';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import useTitle from '../../../hooks/useTitle';
@@ -20,7 +20,9 @@ const DeleteDocument = (props) => {
title: titleFromProps,
id,
collection: {
useAsTitle,
admin: {
useAsTitle,
},
slug,
labels: {
singular,
@@ -80,7 +82,7 @@ const DeleteDocument = (props) => {
if (id) {
return (
<>
<React.Fragment>
<button
type="button"
slug={modalSlug}
@@ -127,7 +129,7 @@ const DeleteDocument = (props) => {
</Button>
</MinimalTemplate>
</Modal>
</>
</React.Fragment>
);
}
@@ -141,7 +143,9 @@ DeleteDocument.defaultProps = {
DeleteDocument.propTypes = {
collection: PropTypes.shape({
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
slug: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import config from 'payload/config';
import useForm from '../../forms/Form/useForm';
import Button from '../Button';
import { useForm } from '../../forms/Form/context';
import './index.scss';
@@ -11,21 +12,28 @@ const { routes: { admin } } = config;
const baseClass = 'duplicate';
const Duplicate = ({ slug }) => {
const { push } = useHistory();
const { getData } = useForm();
const data = getData();
const handleClick = useCallback(() => {
const data = getData();
push({
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
});
}, [push, getData, slug]);
return (
<Link
<Button
buttonStyle="none"
className={baseClass}
to={{
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
}}
onClick={handleClick}
>
Duplicate
</Link>
</Button>
);
};

View File

@@ -91,6 +91,7 @@
&__main-detail {
border-top: $style-stroke-width-m solid white;
order: 3;
width: 100%;
}
}
}

View File

@@ -14,10 +14,13 @@ const ListControls = (props) => {
const {
handleChange,
collection,
enableColumns,
collection: {
fields,
useAsTitle,
defaultColumns,
admin: {
useAsTitle,
defaultColumns,
},
},
} = props;
@@ -29,7 +32,7 @@ const ListControls = (props) => {
useEffect(() => {
if (useAsTitle) {
const foundTitleField = fields.find(field => field.name === useAsTitle);
const foundTitleField = fields.find((field) => field.name === useAsTitle);
if (foundTitleField) {
setTitleField(foundTitleField);
@@ -71,35 +74,43 @@ const ListControls = (props) => {
fieldName={titleField ? titleField.name : undefined}
fieldLabel={titleField ? titleField.label : undefined}
/>
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : false)}
icon="chevron"
iconStyle="none"
>
Columns
</Button>
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : false)}
icon="chevron"
iconStyle="none"
>
Filters
</Button>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
{enableColumns && (
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : false)}
icon="chevron"
iconStyle="none"
>
Columns
</Button>
)}
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : false)}
icon="chevron"
iconStyle="none"
>
Filters
</Button>
</div>
</div>
</div>
<AnimateHeight
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
defaultColumns={defaultColumns}
handleChange={setColumns}
/>
</AnimateHeight>
{enableColumns && (
<AnimateHeight
className={`${baseClass}__columns`}
height={visibleDrawer === 'columns' ? 'auto' : 0}
>
<ColumnSelector
collection={collection}
defaultColumns={defaultColumns}
handleChange={setColumns}
/>
</AnimateHeight>
)}
<AnimateHeight
className={`${baseClass}__where`}
height={visibleDrawer === 'where' ? 'auto' : 0}
@@ -113,13 +124,20 @@ const ListControls = (props) => {
);
};
ListControls.defaultProps = {
enableColumns: true,
};
ListControls.propTypes = {
enableColumns: PropTypes.bool,
collection: PropTypes.shape({
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}),
fields: PropTypes.arrayOf(PropTypes.shape),
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};

View File

@@ -15,9 +15,20 @@
}
}
&__buttons {
margin-left: $baseline;
}
&__buttons-wrap {
display: flex;
margin-left: - base(.5);
margin-right: - base(.5);
width: calc(100% + #{$baseline});
}
&__toggle-columns,
&__toggle-where {
margin: 0 0 0 $baseline;
margin: 0 base(.5);
min-width: 140px;
&.btn--style-primary {
@@ -33,9 +44,8 @@
}
@include mid-break {
&__toggle-columns,
&__toggle-where {
margin: 0 0 0 base(.5);
&__buttons {
margin-left: base(.5);
}
}
@@ -49,17 +59,17 @@
width: 100%;
}
&__buttons {
margin: 0;
}
&__buttons {
width: 100%;
}
&__toggle-columns,
&__toggle-where {
width: calc(50% - #{base(.25)});
}
&__toggle-columns {
margin: 0 base(.25) 0 0;
}
&__toggle-where {
margin: 0 0 0 base(.25);
flex-grow: 1;
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink, Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import config from 'payload/config';
import { useUser } from '../../data/User';
import Chevron from '../../icons/Chevron';
@@ -25,12 +25,17 @@ const {
const Nav = () => {
const { permissions } = useUser();
const [menuActive, setMenuActive] = useState(false);
const history = useHistory();
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
useEffect(() => history.listen(() => {
setMenuActive(false);
}), []);
return (
<aside className={classes}>
<div className={`${baseClass}__scroll`}>
@@ -76,27 +81,31 @@ const Nav = () => {
return null;
})}
</nav>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals && globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
{(globals && globals.length > 0) && (
<React.Fragment>
<span className={`${baseClass}__label`}>Globals</span>
<nav>
{globals.map((global, i) => {
const href = `${admin}/globals/${global.slug}`;
if (permissions?.[global.slug].read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
if (permissions?.[global.slug].read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{global.label}
</NavLink>
);
}
return null;
})}
</nav>
return null;
})}
</nav>
</React.Fragment>
)}
<div className={`${baseClass}__controls`}>
<Localizer />
<Link

View File

@@ -159,5 +159,11 @@
opacity: 1;
}
}
nav a {
font-size: base(.875);
line-height: base(1.25);
font-weight: 600;
}
}
}

View File

@@ -29,7 +29,7 @@
outline: 0;
padding: base(.5);
color: $color-dark-gray;
line-height: 1;
line-height: base(1);
&:hover:not(.clickable-arrow--is-disabled) {
background: $color-background-gray;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@trbl/react-window-info';
import { useScrollInfo } from '@trbl/react-scroll-info';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useScrollInfo } from '@faceless-ui/scroll-info';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import PopupButton from './PopupButton';
@@ -115,8 +115,7 @@ const Popup = (props) => {
setActive={setActive}
active={active}
/>
)
}
)}
</div>
<div

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import useForm from '../../forms/Form/useForm';
import { useForm } from '../../forms/Form/context';
import { useUser } from '../../data/User';
import Button from '../Button';
@@ -11,9 +11,9 @@ const PreviewButton = ({ generatePreviewURL }) => {
const { getFields } = useForm();
const fields = getFields();
const previewURL = (generatePreviewURL && typeof generatePreviewURL === 'function') ? generatePreviewURL(fields, token) : null;
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const previewURL = generatePreviewURL(fields, token);
if (previewURL) {
return (
<Button
el="anchor"

View File

@@ -1,7 +1,11 @@
import React from 'react';
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import useTitle from '../../../hooks/useTitle';
import './index.scss';
const baseClass = 'render-title';
const RenderTitle = (props) => {
const {
useAsTitle, title: titleFromProps, data, fallback,
@@ -12,11 +16,27 @@ const RenderTitle = (props) => {
let title = titleFromData;
if (!title) title = titleFromForm;
if (!title) title = data.id;
if (!title) title = data?.id;
if (!title) title = fallback;
title = titleFromProps || title;
return <>{title}</>;
const idAsTitle = title === data?.id;
const classes = [
baseClass,
idAsTitle && `${baseClass}--id-as-title`,
].filter(Boolean).join(' ');
return (
<span className={classes}>
{idAsTitle && (
<Fragment>
ID:&nbsp;&nbsp;
</Fragment>
)}
{title}
</span>
);
};
RenderTitle.defaultProps = {

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles.scss';
.render-title {
&--id-as-title {
font-size: base(.75);
font-weight: normal;
color: $color-gray;
background: $color-background-gray;
padding: base(.25) base(.5);
border-radius: $style-radius-m;
}
}

View File

@@ -17,10 +17,10 @@ const StatusListProvider = ({ children }) => {
const [statusList, dispatchStatus] = useReducer(reducer, []);
const { pathname, state } = useLocation();
const removeStatus = useCallback(i => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback(status => dispatchStatus({ type: 'ADD', payload: status }), []);
const removeStatus = useCallback((i) => dispatchStatus({ type: 'REMOVE', payload: i }), []);
const addStatus = useCallback((status) => dispatchStatus({ type: 'ADD', payload: status }), []);
const clearStatus = useCallback(() => dispatchStatus({ type: 'CLEAR' }), []);
const replaceStatus = useCallback(status => dispatchStatus({ type: 'REPLACE', payload: status }), []);
const replaceStatus = useCallback((status) => dispatchStatus({ type: 'REPLACE', payload: status }), []);
useEffect(() => {
if (state && state.status) {

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import Popup from '../../../elements/Popup';
import BlockSelector from '../../field-types/Flexible/BlockSelector';
import BlockSelector from '../../field-types/Blocks/BlockSelector';
import './index.scss';
@@ -52,7 +52,7 @@ const ActionPanel = (props) => {
{singularLabel}
</Popup>
{blockType === 'flexible'
{blockType === 'blocks'
? (
<Popup
buttonType="custom"
@@ -102,8 +102,7 @@ const ActionPanel = (props) => {
Add&nbsp;
{singularLabel}
</Popup>
)
}
)}
</div>
</div>
</div>
@@ -115,13 +114,17 @@ ActionPanel.defaultProps = {
verticalAlignment: 'center',
blockType: null,
isHovered: false,
blocks: [],
};
ActionPanel.propTypes = {
singularLabel: PropTypes.string,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
blockType: PropTypes.oneOf(['flexible', 'repeater']),
blockType: PropTypes.oneOf(['blocks', 'array']),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
),
verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
isHovered: PropTypes.bool,
rowIndex: PropTypes.number.isRequired,

View File

@@ -61,7 +61,7 @@ $controls-top-adjustment: base(.1);
// External scopes
.field-type.flexible {
.field-type.blocks {
.position-panel {
&__controls-container {
min-height: calc(100% + #{$controls-top-adjustment});

View File

@@ -8,7 +8,7 @@ import './index.scss';
const baseClass = 'editable-block-title';
const EditableBlockTitle = (props) => {
const { path, initialData } = props;
const { path } = props;
const inputRef = useRef(null);
const inputCloneRef = useRef(null);
const [inputWidth, setInputWidth] = useState(0);
@@ -18,7 +18,6 @@ const EditableBlockTitle = (props) => {
setValue,
} = useFieldType({
path,
initialData,
});
useEffect(() => {
@@ -31,7 +30,7 @@ const EditableBlockTitle = (props) => {
};
return (
<>
<React.Fragment>
<div className={baseClass}>
<input
ref={inputRef}
@@ -53,17 +52,12 @@ const EditableBlockTitle = (props) => {
>
{value || 'Untitled'}
</span>
</>
</React.Fragment>
);
};
EditableBlockTitle.defaultProps = {
initialData: undefined,
};
EditableBlockTitle.propTypes = {
path: PropTypes.string.isRequired,
initialData: PropTypes.string,
};
export default EditableBlockTitle;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import { Draggable } from 'react-beautiful-dnd';
@@ -22,17 +22,16 @@ const DraggableSection = (props) => {
rowCount,
parentPath,
fieldSchema,
initialData,
singularLabel,
blockType,
fieldTypes,
customComponentsPath,
isOpen,
toggleRowCollapse,
id,
positionPanelVerticalAlignment,
actionPanelVerticalAlignment,
toggleRowCollapse,
permissions,
isOpen,
} = props;
const [isHovered, setIsHovered] = useState(false);
@@ -48,79 +47,73 @@ const DraggableSection = (props) => {
draggableId={id}
index={rowIndex}
>
{(providedDrag) => {
return (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
{(providedDrag) => (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
<div className={`${baseClass}__content-wrapper`}>
<PositionPanel
dragHandleProps={providedDrag.dragHandleProps}
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
<div className={`${baseClass}__content-wrapper`}>
<PositionPanel
dragHandleProps={providedDrag.dragHandleProps}
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
<div className={`${baseClass}__render-fields-wrapper`}>
<div className={`${baseClass}__render-fields-wrapper`}>
{blockType === 'flexible' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={singularLabel}
initialData={initialData?.blockName}
path={`${parentPath}.${rowIndex}.blockName`}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<RenderFields
initialData={initialData}
customComponentsPath={customComponentsPath}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => {
return ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
});
})}
{blockType === 'blocks' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={singularLabel}
path={`${parentPath}.${rowIndex}.blockName`}
/>
</AnimateHeight>
</div>
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
singularLabel={singularLabel}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<RenderFields
customComponentsPath={customComponentsPath}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
}))}
/>
</AnimateHeight>
</div>
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
singularLabel={singularLabel}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
</div>
);
}}
</div>
)}
</Draggable>
);
};

View File

@@ -4,7 +4,7 @@
// HELPER MIXINS
//////////////////////
@mixin realtively-position-panels {
@mixin relatively-position-panels {
.position-panel {
position: relative;
right: 0;
@@ -117,7 +117,7 @@
}
@include mid-break {
@include realtively-position-panels();
@include relatively-position-panels();
.position-panel__move-forward,
.position-panel__move-backward {
@@ -130,21 +130,25 @@
// EXTERNAL SCOPES
//////////////////////
.global-edit,
.collection-edit {
@include absolutely-position-panels();
@include mid-break {
@include realtively-position-panels();
@include relatively-position-panels();
}
}
.field-type.repeater .field-type.repeater {
@include realtively-position-panels();
.field-type.blocks .field-type.array,
.field-type.array .field-type.array,
.field-type.array .field-type.blocks,
.field-type.blocks .field-type.blocks {
@include relatively-position-panels();
}
// remove padding above repeater rows to level
// remove padding above array rows to level
// the line with the top of the input label
.field-type.repeater {
.field-type.array {
.draggable-section {
&__content-wrapper {
padding-top: 0;

View File

@@ -1,3 +0,0 @@
import { createContext } from 'react';
export default createContext({});

View File

@@ -1,3 +0,0 @@
import { createContext } from 'react';
export default createContext({});

View File

@@ -0,0 +1,101 @@
const buildValidationPromise = async (fieldState, field) => {
const validatedFieldState = fieldState;
validatedFieldState.valid = typeof field.validate === 'function' ? await field.validate(fieldState.value, field) : true;
if (typeof validatedFieldState.valid === 'string') {
validatedFieldState.errorMessage = validatedFieldState.valid;
validatedFieldState.valid = false;
}
};
const buildStateFromSchema = async (fieldSchema, fullData) => {
if (fieldSchema && fullData) {
const validationPromises = [];
const structureFieldState = (field, data = {}) => {
const value = data[field.name] || field.defaultValue;
const fieldState = {
value,
initialValue: value,
};
validationPromises.push(buildValidationPromise(fieldState, field));
return fieldState;
};
const iterateFields = (fields, data, path = '') => fields.reduce((state, field) => {
if (field.name && data[field.name]) {
if (Array.isArray(data[field.name])) {
if (field.type === 'array') {
return {
...state,
...data[field.name].reduce((rowState, row, i) => ({
...rowState,
...iterateFields(field.fields, row, `${path}${field.name}.${i}.`),
}), {}),
};
}
if (field.type === 'blocks') {
return {
...state,
...data[field.name].reduce((rowState, row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
const rowPath = `${path}${field.name}.${i}.`;
return {
...rowState,
[`${rowPath}blockType`]: {
value: row.blockType,
initialValue: row.blockType,
valid: true,
},
[`${rowPath}blockName`]: {
value: row.blockName,
initialValue: row.blockName,
valid: true,
},
...iterateFields(block.fields, row, rowPath),
};
}, {}),
};
}
}
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data[field.name], `${path}${field.name}.`),
};
}
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data, path),
};
}
return state;
}, {});
const resultingState = iterateFields(fieldSchema, fullData);
await Promise.all(validationPromises);
return resultingState;
}
return {};
};
module.exports = buildStateFromSchema;

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
import React, {
useReducer, useEffect, useRef,
useReducer, useEffect, useRef, useState, useCallback,
} from 'react';
import { objectToFormData } from 'object-to-formdata';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { unflatten } from 'flatley';
import HiddenInput from '../field-types/HiddenInput';
import FormContext from './FormContext';
import FieldContext from './FieldContext';
import { useLocale } from '../../utilities/Locale';
import { useStatusList } from '../../elements/Status';
import { requests } from '../../../api';
@@ -16,6 +13,8 @@ import { useUser } from '../../data/User';
import fieldReducer from './fieldReducer';
import initContextState from './initContextState';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FieldContext } from './context';
import './index.scss';
const baseClass = 'form';
@@ -40,36 +39,41 @@ const Form = (props) => {
const {
disabled,
onSubmit,
ajax,
method,
action,
handleAjaxResponse,
handleResponse,
onSuccess,
children,
className,
redirect,
disableSuccessStatus,
initialState,
} = props;
const history = useHistory();
const locale = useLocale();
const { replaceStatus, addStatus, clearStatus } = useStatusList();
const { refreshToken } = useUser();
const { refreshCookie } = useUser();
const [modified, setModified] = useState(false);
const [processing, setProcessing] = useState(false);
const [submitted, setSubmitted] = useState(false);
const contextRef = useRef({ ...initContextState });
contextRef.current.initialState = initialState;
const [fields, dispatchFields] = useReducer(fieldReducer, {});
contextRef.current.fields = fields;
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = (e) => {
const submit = useCallback((e) => {
if (disabled) {
e.preventDefault();
return false;
}
e.stopPropagation();
contextRef.current.setSubmitted(true);
setSubmitted(true);
const isValid = contextRef.current.validateForm();
@@ -96,116 +100,129 @@ const Form = (props) => {
return onSubmit(fields);
}
// If form is AJAX, fetch data
if (ajax !== false) {
e.preventDefault();
e.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth',
});
window.scrollTo({
top: 0,
behavior: 'smooth',
});
const formData = contextRef.current.createFormData();
contextRef.current.setProcessing(true);
const formData = contextRef.current.createFormData();
setProcessing(true);
// Make the API call from the action
return requests[method.toLowerCase()](action, {
body: formData,
}).then((res) => {
contextRef.current.setModified(false);
if (typeof handleAjaxResponse === 'function') return handleAjaxResponse(res);
// Make the API call from the action
return requests[method.toLowerCase()](action, {
body: formData,
}).then((res) => {
setModified(false);
if (typeof handleResponse === 'function') return handleResponse(res);
return res.json().then((json) => {
contextRef.current.setProcessing(false);
clearStatus();
return res.json().then((json) => {
setProcessing(false);
clearStatus();
if (res.status < 400) {
if (typeof onSuccess === 'function') onSuccess(json);
if (res.status < 400) {
if (typeof onSuccess === 'function') onSuccess(json);
if (redirect) {
return history.push(redirect, json);
}
if (!disableSuccessStatus) {
replaceStatus([{
message: json.message,
type: 'success',
disappear: 3000,
}]);
}
} else {
if (json.message) {
addStatus({
message: json.message,
type: 'error',
});
return json;
}
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(([fieldErrs, nonFieldErrs], err) => {
return err.field && err.message ? [[...fieldErrs, err], nonFieldErrs] : [fieldErrs, [...nonFieldErrs, err]];
}, [[], []]);
fieldErrors.forEach((err) => {
dispatchFields({
valid: false,
errorMessage: err.message,
path: err.field,
value: contextRef.current.fields?.[err.field]?.value,
});
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
});
if (fieldErrors.length > 0 && nonFieldErrors.length === 0) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
}
return json;
if (redirect) {
const destination = {
pathname: redirect,
};
if (json.message && !disableSuccessStatus) {
destination.state = {
status: [
{
message: json.message,
type: 'success',
},
],
};
}
history.push(destination);
} else if (!disableSuccessStatus) {
replaceStatus([{
message: json.message,
type: 'success',
disappear: 3000,
}]);
}
} else {
if (json.message) {
addStatus({
message: 'An unknown error occurred.',
message: json.message,
type: 'error',
});
return json;
}
return json;
});
}).catch((err) => {
addStatus({
message: err,
type: 'error',
});
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(([fieldErrs, nonFieldErrs], err) => (err.field && err.message ? [[...fieldErrs, err], nonFieldErrs] : [fieldErrs, [...nonFieldErrs, err]]), [[], []]);
fieldErrors.forEach((err) => {
dispatchFields({
...(contextRef.current?.fields?.[err.field] || {}),
valid: false,
errorMessage: err.message,
path: err.field,
});
});
nonFieldErrors.forEach((err) => {
addStatus({
message: err.message || 'An unknown error occurred.',
type: 'error',
});
});
if (fieldErrors.length > 0 && nonFieldErrors.length === 0) {
addStatus({
message: 'Please correct the fields below.',
type: 'error',
});
}
return json;
}
addStatus({
message: 'An unknown error occurred.',
type: 'error',
});
}
return json;
});
}
}).catch((err) => {
addStatus({
message: err,
type: 'error',
});
});
}, [
action,
addStatus,
clearStatus,
disableSuccessStatus,
disabled,
fields,
handleResponse,
history,
method,
onSubmit,
onSuccess,
redirect,
replaceStatus,
]);
return true;
};
contextRef.current.getFields = () => {
return contextRef.current.fields;
};
const getFields = useCallback(() => contextRef.current.fields, [contextRef]);
const getField = useCallback((path) => contextRef.current.fields[path], [contextRef]);
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
contextRef.current.getField = (path) => {
return contextRef.current.fields[path];
};
contextRef.current.getData = () => {
return reduceFieldsToValues(contextRef.current.fields, true);
};
contextRef.current.getSiblingData = (path) => {
const getSiblingData = useCallback((path) => {
let siblingFields = contextRef.current.fields;
// If this field is nested
@@ -225,9 +242,9 @@ const Form = (props) => {
}
return reduceFieldsToValues(siblingFields, true);
};
}, [contextRef]);
contextRef.current.getDataByPath = (path) => {
const getDataByPath = useCallback((path) => {
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
const name = path.split('.').pop();
@@ -245,42 +262,42 @@ const Form = (props) => {
const values = reduceFieldsToValues(data, true);
const unflattenedData = unflatten(values);
return unflattenedData?.[name];
};
}, [contextRef]);
contextRef.current.getUnflattenedValues = () => {
return reduceFieldsToValues(contextRef.current.fields);
};
const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]);
contextRef.current.validateForm = () => {
return !Object.values(contextRef.current.fields).some((field) => {
return field.valid === false;
});
};
const validateForm = useCallback(() => !Object.values(contextRef.current.fields).some((field) => field.valid === false), [contextRef]);
contextRef.current.createFormData = () => {
const createFormData = useCallback(() => {
const data = reduceFieldsToValues(contextRef.current.fields);
return objectToFormData(data, { indices: true });
};
}, [contextRef]);
contextRef.current.setModified = (modified) => {
contextRef.current.modified = modified;
};
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = submit;
contextRef.current.getFields = getFields;
contextRef.current.getField = getField;
contextRef.current.getData = getData;
contextRef.current.getSiblingData = getSiblingData;
contextRef.current.getDataByPath = getDataByPath;
contextRef.current.getUnflattenedValues = getUnflattenedValues;
contextRef.current.validateForm = validateForm;
contextRef.current.createFormData = createFormData;
contextRef.current.setModified = setModified;
contextRef.current.setProcessing = setProcessing;
contextRef.current.setSubmitted = setSubmitted;
contextRef.current.setSubmitted = (submitted) => {
contextRef.current.submitted = submitted;
};
contextRef.current.setProcessing = (processing) => {
contextRef.current.processing = processing;
};
useEffect(() => {
dispatchFields({ type: 'REPLACE_STATE', state: initialState });
}, [initialState]);
useThrottledEffect(() => {
refreshToken();
refreshCookie();
}, 15000, [fields]);
useEffect(() => {
contextRef.current.modified = false;
}, [locale, contextRef.current.modified]);
setModified(false);
}, [locale]);
const classes = [
className,
@@ -301,13 +318,16 @@ const Form = (props) => {
...contextRef.current,
}}
>
<HiddenInput
path="locale"
defaultValue={locale}
/>
{children}
<SubmittedContext.Provider value={submitted}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
{children}
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</SubmittedContext.Provider>
</FieldContext.Provider>
</FormContext.Provider>
</form>
);
};
@@ -315,23 +335,22 @@ const Form = (props) => {
Form.defaultProps = {
redirect: '',
onSubmit: null,
ajax: true,
method: 'POST',
action: '',
handleAjaxResponse: null,
handleResponse: null,
onSuccess: null,
className: '',
disableSuccessStatus: false,
disabled: false,
initialState: {},
};
Form.propTypes = {
disableSuccessStatus: PropTypes.bool,
onSubmit: PropTypes.func,
ajax: PropTypes.bool,
method: PropTypes.oneOf(['post', 'POST', 'get', 'GET', 'put', 'PUT', 'delete', 'DELETE']),
action: PropTypes.string,
handleAjaxResponse: PropTypes.func,
handleResponse: PropTypes.func,
onSuccess: PropTypes.func,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
@@ -340,6 +359,7 @@ Form.propTypes = {
className: PropTypes.string,
redirect: PropTypes.string,
disabled: PropTypes.bool,
initialState: PropTypes.shape({}),
};
export default Form;

View File

@@ -1,7 +1,4 @@
export default {
processing: false,
modified: false,
submitted: false,
getFields: () => { },
getField: () => { },
getData: () => { },
@@ -13,4 +10,6 @@ export default {
submit: () => { },
dispatchFields: () => { },
setModified: () => { },
initialState: {},
reset: 0,
};

View File

@@ -1,4 +0,0 @@
import { useContext } from 'react';
import FormContext from './FormContext';
export default () => useContext(FormContext);

View File

@@ -1,4 +0,0 @@
import { useContext } from 'react';
import FieldContext from './FieldContext';
export default () => useContext(FieldContext);

View File

@@ -6,7 +6,7 @@ slides.1.heroInfo.title
fields: [
{
name: slides,
type: repeater,
type: array,
fields: [
{
type: group,

View File

@@ -1,8 +1,13 @@
import React, { createContext, useContext } from 'react';
import React, { createContext, useEffect, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import useIntersect from '../../../hooks/useIntersect';
import './index.scss';
const baseClass = 'render-fields';
const intersectionObserverOptions = {
rootMargin: '1000px',
};
const RenderedFieldContext = createContext({});
@@ -11,86 +16,118 @@ export const useRenderedFields = () => useContext(RenderedFieldContext);
const RenderFields = (props) => {
const {
fieldSchema,
initialData,
customComponentsPath: customComponentsPathFromProps,
fieldTypes,
filter,
permissions,
readOnly: readOnlyOverride,
operation: operationFromProps,
className,
} = props;
const [hasIntersected, setHasIntersected] = useState(false);
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions);
const isIntersecting = Boolean(entry?.isIntersecting);
const { customComponentsPath: customComponentsPathFromContext, operation: operationFromContext } = useRenderedFields();
const customComponentsPath = customComponentsPathFromProps || customComponentsPathFromContext;
const operation = operationFromProps || operationFromContext;
const customComponentsPath = customComponentsPathFromProps || customComponentsPathFromContext;
const [contextValue, setContextValue] = useState({
operation,
customComponentsPath,
});
useEffect(() => {
setContextValue({
operation,
customComponentsPath,
});
}, [operation, customComponentsPath]);
useEffect(() => {
if (isIntersecting && !hasIntersected) {
setHasIntersected(true);
}
}, [isIntersecting, hasIntersected]);
const classes = [
baseClass,
className,
].filter(Boolean).join(' ');
if (fieldSchema) {
return (
<RenderedFieldContext.Provider value={{ customComponentsPath, operation }}>
{fieldSchema.map((field, i) => {
if (field?.hidden !== 'api' && field?.hidden !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.hidden === 'admin' ? fieldTypes.hidden : fieldTypes[field.type];
<div
ref={intersectionRef}
className={classes}
>
{hasIntersected && (
<RenderedFieldContext.Provider value={contextValue}>
{fieldSchema.map((field, i) => {
if (!field?.hidden && field?.admin?.disabled !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.admin?.hidden ? fieldTypes.hidden : fieldTypes[field.type];
let initialFieldData;
let fieldPermissions = permissions[field.name];
let fieldPermissions = permissions[field.name];
if (!field.name) {
initialFieldData = initialData;
fieldPermissions = permissions;
} else if (initialData?.[field.name] !== undefined) {
initialFieldData = initialData[field.name];
}
if (!field.name) {
fieldPermissions = permissions;
}
let { readOnly } = field;
let { admin: { readOnly } = {} } = field;
if (readOnlyOverride) readOnly = true;
if (readOnlyOverride) readOnly = true;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
}
if (FieldComponent) {
return (
<RenderCustomComponent
key={i}
path={`${customComponentsPath}${field.name ? `${field.name}.field` : ''}`}
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
fieldTypes,
admin: {
...(field.admin || {}),
readOnly,
},
permissions: fieldPermissions,
}}
/>
);
}
return (
<div
className="missing-field"
key={i}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
</div>
);
}
}
if (FieldComponent) {
return (
<RenderCustomComponent
key={field.name || `field-${i}`}
path={`${customComponentsPath}${field.name ? `${field.name}.field` : ''}`}
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
fieldTypes,
initialData: initialFieldData,
readOnly,
permissions: fieldPermissions,
}}
/>
);
}
return (
<div
className="missing-field"
key={i}
>
No matched field found for
{' '}
&quot;
{field.label}
&quot;
</div>
);
return null;
}
}
return null;
}
return null;
})}
</RenderedFieldContext.Provider>
return null;
})}
</RenderedFieldContext.Provider>
)}
</div>
);
}
@@ -98,19 +135,18 @@ const RenderFields = (props) => {
};
RenderFields.defaultProps = {
initialData: {},
customComponentsPath: '',
filter: null,
readOnly: false,
permissions: {},
operation: undefined,
className: undefined,
};
RenderFields.propTypes = {
fieldSchema: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
initialData: PropTypes.shape({}),
customComponentsPath: PropTypes.string,
fieldTypes: PropTypes.shape({
hidden: PropTypes.function,
@@ -118,6 +154,8 @@ RenderFields.propTypes = {
filter: PropTypes.func,
permissions: PropTypes.shape({}),
readOnly: PropTypes.bool,
operation: PropTypes.string,
className: PropTypes.string,
};
export default RenderFields;

View File

@@ -1,3 +0,0 @@
.missing-field {
}

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import FormContext from '../Form/FormContext';
import { useFormProcessing } from '../Form/context';
import Button from '../../elements/Button';
import './index.scss';
@@ -8,12 +8,13 @@ import './index.scss';
const baseClass = 'form-submit';
const FormSubmit = ({ children }) => {
const formContext = useContext(FormContext);
const processing = useFormProcessing();
return (
<div className={baseClass}>
<Button
type="submit"
disabled={formContext.processing ? true : undefined}
disabled={processing ? true : undefined}
>
{children}
</Button>

View File

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

View File

@@ -1,6 +1,6 @@
@import '../../../../scss/styles.scss';
.field-type.repeater {
.field-type.array {
background: white;
&__add-button-wrap {
@@ -25,14 +25,13 @@
width: 100%;
}
}
}
.field-type.repeater {
.field-type.repeater__add-button-wrap {
.field-type.array,
.field-type.blocks {
.field-type.array {
.field-type.array__add-button-wrap {
margin-left: base(2.65);
}
.field-type.repeater__header {
display: none;
}
}
}

View File

@@ -17,16 +17,15 @@ const BlockSelection = (props) => {
} = block;
const handleBlockSelection = () => {
console.log('adding');
close();
addRow(addRowIndex, slug);
};
return (
<div
<button
className={baseClass}
role="button"
tabIndex={0}
type="button"
onClick={handleBlockSelection}
>
<div className={`${baseClass}__image`}>
@@ -37,11 +36,10 @@ const BlockSelection = (props) => {
alt={blockImageAltText}
/>
)
: <DefaultBlockImage />
}
: <DefaultBlockImage />}
</div>
<div className={`${baseClass}__label`}>{labels.singular}</div>
</div>
</button>
);
};

View File

@@ -8,6 +8,10 @@
padding: base(.75) base(.5);
cursor: pointer;
align-items: center;
background: none;
border-radius: 0;
box-shadow: 0;
border: 0;
&:hover {
background-color: $color-background-gray;

View File

@@ -1,34 +1,31 @@
import React, {
useEffect, useReducer, useCallback,
useEffect, useReducer, useCallback, useState,
} from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { v4 as uuidv4 } from 'uuid';
import withCondition from '../../withCondition';
import Button from '../../../elements/Button';
import reducer from '../rowReducer';
import useForm from '../../Form/useForm';
import { useForm } from '../../Form/context';
import DraggableSection from '../../DraggableSection';
import { useRenderedFields } from '../../RenderFields';
import Error from '../../Error';
import useFieldType from '../../useFieldType';
import Popup from '../../../elements/Popup';
import BlockSelector from './BlockSelector';
import { flexible } from '../../../../../fields/validations';
import { blocks as blocksValidator } from '../../../../../fields/validations';
import './index.scss';
const baseClass = 'field-type flexible';
const baseClass = 'field-type blocks';
const Flexible = (props) => {
const Blocks = (props) => {
const {
label,
name,
path: pathFromProps,
blocks,
defaultValue,
initialData,
singularLabel,
fieldTypes,
maxRows,
@@ -40,6 +37,10 @@ const Flexible = (props) => {
const path = pathFromProps || name;
const [rows, dispatchRows] = useReducer(reducer, []);
const { customComponentsPath } = useRenderedFields();
const { getDataByPath, initialState, dispatchFields } = useForm();
const memoizedValidate = useCallback((value) => {
const validationResult = validate(
value,
@@ -50,6 +51,8 @@ const Flexible = (props) => {
return validationResult;
}, [validate, maxRows, minRows, singularLabel, blocks, required]);
const [disableFormData, setDisableFormData] = useState(false);
const {
showError,
errorMessage,
@@ -58,73 +61,127 @@ const Flexible = (props) => {
} = useFieldType({
path,
validate: memoizedValidate,
disableFormData: true,
initialData: initialData?.length,
defaultValue: defaultValue?.length,
disableFormData,
ignoreWhileFlattening: true,
required,
});
const dataToInitialize = initialData || defaultValue;
const [rows, dispatchRows] = useReducer(reducer, []);
const { customComponentsPath } = useRenderedFields();
const { getDataByPath } = useForm();
const addRow = (index, blockType) => {
const data = getDataByPath(path);
dispatchRows({
type: 'ADD', index, data, initialRowData: { blockType },
});
const addRow = useCallback((rowIndex, blockType) => {
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
dispatchRows({ type: 'ADD', rowIndex, blockType });
dispatchFields({ type: 'ADD_ROW', rowIndex, fieldSchema: block.fields, path, blockType });
setValue(value + 1);
};
const removeRow = (index) => {
const data = getDataByPath(path);
dispatchRows({
type: 'REMOVE',
index,
data,
});
}, [path, setValue, value, blocks, dispatchFields]);
const removeRow = useCallback((rowIndex) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
setValue(value - 1);
};
}, [path, setValue, value, dispatchFields]);
const moveRow = (moveFromIndex, moveToIndex) => {
const data = getDataByPath(path);
const moveRow = useCallback((moveFromIndex, moveToIndex) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]);
dispatchRows({
type: 'MOVE', index: moveFromIndex, moveToIndex, data,
});
};
const toggleCollapse = useCallback((rowIndex) => {
dispatchRows({ type: 'TOGGLE_COLLAPSE', rowIndex });
}, []);
const toggleCollapse = (index) => {
dispatchRows({
type: 'TOGGLE_COLLAPSE', index, rows,
});
};
const onDragEnd = (result) => {
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
};
}, [moveRow]);
useEffect(() => {
dispatchRows({
type: 'SET_ALL',
rows: dataToInitialize.reduce((acc, data) => ([
...acc,
{
key: uuidv4(),
open: true,
data,
},
]), []),
});
}, [dataToInitialize]);
const data = getDataByPath(path);
dispatchRows({ type: 'SET_ALL', data });
}, [initialState, getDataByPath, path]);
useEffect(() => {
setValue(rows?.length || 0);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
return (
<RenderBlocks
onDragEnd={onDragEnd}
label={label}
showError={showError}
errorMessage={errorMessage}
rows={rows}
singularLabel={singularLabel}
addRow={addRow}
removeRow={removeRow}
moveRow={moveRow}
path={path}
customComponentsPath={customComponentsPath}
name={name}
fieldTypes={fieldTypes}
toggleCollapse={toggleCollapse}
permissions={permissions}
value={value}
blocks={blocks}
/>
);
};
Blocks.defaultProps = {
label: '',
singularLabel: 'Block',
validate: blocksValidator,
required: false,
maxRows: undefined,
minRows: undefined,
permissions: {},
};
Blocks.propTypes = {
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
singularLabel: PropTypes.string,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
};
const RenderBlocks = React.memo((props) => {
const {
onDragEnd,
label,
showError,
errorMessage,
rows,
singularLabel,
addRow,
removeRow,
moveRow,
path,
customComponentsPath,
name,
fieldTypes,
permissions,
value,
toggleCollapse,
blocks,
} = props;
return (
<DragDropContext onDragEnd={onDragEnd}>
@@ -138,27 +195,22 @@ const Flexible = (props) => {
/>
</header>
<Droppable droppableId="flexible-drop">
{provided => (
<Droppable droppableId="blocks-drop">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => {
let { blockType } = row.data;
if (!blockType) {
blockType = dataToInitialize?.[i]?.blockType;
}
const blockToRender = blocks.find(block => block.slug === blockType);
const { blockType } = row;
const blockToRender = blocks.find((block) => block.slug === blockType);
if (blockToRender) {
return (
<DraggableSection
key={row.key}
id={row.key}
blockType="flexible"
blockType="blocks"
blocks={blocks}
singularLabel={blockToRender?.labels?.singular}
isOpen={row.open}
@@ -169,7 +221,6 @@ const Flexible = (props) => {
moveRow={moveRow}
toggleRowCollapse={() => toggleCollapse(i)}
parentPath={path}
initialData={row.data}
customComponentsPath={`${customComponentsPath}${name}.fields.`}
fieldTypes={fieldTypes}
permissions={permissions.fields}
@@ -178,7 +229,9 @@ const Flexible = (props) => {
{
name: 'blockType',
type: 'text',
hidden: 'admin',
admin: {
hidden: true,
},
},
]}
/>
@@ -186,8 +239,7 @@ const Flexible = (props) => {
}
return null;
})
}
})}
{provided.placeholder}
</div>
)}
@@ -221,42 +273,43 @@ const Flexible = (props) => {
</div>
</DragDropContext>
);
});
RenderBlocks.defaultProps = {
label: undefined,
showError: false,
errorMessage: undefined,
rows: [],
singularLabel: 'Row',
path: '',
customComponentsPath: undefined,
value: undefined,
};
Flexible.defaultProps = {
label: '',
defaultValue: [],
initialData: [],
singularLabel: 'Block',
validate: flexible,
required: false,
maxRows: undefined,
minRows: undefined,
permissions: {},
};
Flexible.propTypes = {
defaultValue: PropTypes.arrayOf(
PropTypes.shape({}),
),
initialData: PropTypes.arrayOf(
RenderBlocks.propTypes = {
label: PropTypes.string,
showError: PropTypes.bool,
errorMessage: PropTypes.string,
rows: PropTypes.arrayOf(
PropTypes.shape({}),
),
singularLabel: PropTypes.string,
path: PropTypes.string,
customComponentsPath: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.number,
onDragEnd: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
moveRow: PropTypes.func.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}).isRequired,
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
singularLabel: PropTypes.string,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
toggleCollapse: PropTypes.func.isRequired,
};
export default withCondition(Flexible);
export default withCondition(Blocks);

View File

@@ -1,6 +1,6 @@
@import '../../../../scss/styles.scss';
.field-type.flexible {
.field-type.blocks {
&__add-button-wrap {
@@ -24,3 +24,12 @@
}
}
}
.field-type.array,
.field-type.blocks {
.field-type.blocks {
.field-type.blocks__add-button-wrap {
margin-left: base(2.65);
}
}
}

View File

@@ -15,15 +15,15 @@ const Checkbox = (props) => {
name,
path: pathFromProps,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
readOnly,
onChange,
disableFormData,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
@@ -41,8 +41,6 @@ const Checkbox = (props) => {
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
disableFormData,
});
@@ -95,12 +93,8 @@ const Checkbox = (props) => {
Checkbox.defaultProps = {
label: null,
required: false,
readOnly: false,
defaultValue: false,
initialData: false,
admin: {},
validate: checkbox,
width: undefined,
style: {},
path: '',
onChange: undefined,
disableFormData: false,
@@ -109,13 +103,13 @@ Checkbox.defaultProps = {
Checkbox.propTypes = {
path: PropTypes.string,
name: PropTypes.string.isRequired,
readOnly: PropTypes.bool,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
required: PropTypes.bool,
defaultValue: PropTypes.bool,
initialData: PropTypes.bool,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
label: PropTypes.string,
onChange: PropTypes.func,
disableFormData: PropTypes.bool,

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Error from '../../Error';
import useFormFields from '../../Form/useFormFields';
import { useFormFields } from '../../Form/context';
import './index.scss';

View File

@@ -19,14 +19,14 @@ const DateTime = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
errorMessage,
label,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
@@ -43,8 +43,6 @@ const DateTime = (props) => {
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
});
@@ -86,14 +84,10 @@ const DateTime = (props) => {
DateTime.defaultProps = {
label: null,
required: false,
defaultValue: undefined,
initialData: undefined,
validate: date,
errorMessage: defaultError,
width: undefined,
style: {},
admin: {},
path: '',
readOnly: false,
};
DateTime.propTypes = {
@@ -101,13 +95,13 @@ DateTime.propTypes = {
path: PropTypes.string,
label: PropTypes.string,
required: PropTypes.bool,
defaultValue: PropTypes.string,
initialData: PropTypes.string,
validate: PropTypes.func,
errorMessage: PropTypes.string,
width: PropTypes.string,
style: PropTypes.shape({}),
readOnly: PropTypes.bool,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
};
export default withCondition(DateTime);

View File

@@ -13,15 +13,15 @@ const Email = (props) => {
name,
path: pathFromProps,
required,
defaultValue,
initialData,
validate,
style,
width,
admin: {
readOnly,
style,
width,
} = {},
label,
placeholder,
autoComplete,
readOnly,
} = props;
const path = pathFromProps || name;
@@ -33,9 +33,6 @@ const Email = (props) => {
const fieldType = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
enableDebouncedValue: true,
});
@@ -91,12 +88,10 @@ Email.defaultProps = {
defaultValue: undefined,
initialData: undefined,
placeholder: undefined,
width: undefined,
style: {},
admin: {},
autoComplete: undefined,
validate: email,
path: '',
readOnly: false,
};
Email.propTypes = {
@@ -107,11 +102,13 @@ Email.propTypes = {
defaultValue: PropTypes.string,
initialData: PropTypes.string,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
autoComplete: PropTypes.string,
readOnly: PropTypes.bool,
};
export default withCondition(Email);

View File

@@ -7,7 +7,7 @@ import './index.scss';
const Group = (props) => {
const {
label, fields, name, path: pathFromProps, fieldTypes, initialData,
label, fields, name, path: pathFromProps, fieldTypes,
} = props;
const path = pathFromProps || name;
@@ -18,15 +18,12 @@ const Group = (props) => {
<div className="field-type group">
<h3>{label}</h3>
<RenderFields
initialData={initialData}
fieldTypes={fieldTypes}
customComponentsPath={`${customComponentsPath}${name}.fields.`}
fieldSchema={fields.map((subField) => {
return {
...subField,
path: `${path}${subField.name ? `.${subField.name}` : ''}`,
};
})}
fieldSchema={fields.map((subField) => ({
...subField,
path: `${path}${subField.name ? `.${subField.name}` : ''}`,
}))}
/>
</div>
);
@@ -34,12 +31,10 @@ const Group = (props) => {
Group.defaultProps = {
label: '',
initialData: {},
path: '',
};
Group.propTypes = {
initialData: PropTypes.shape({}),
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,

View File

@@ -8,8 +8,6 @@ const HiddenInput = (props) => {
name,
path: pathFromProps,
required,
defaultValue,
initialData,
} = props;
const path = pathFromProps || name;
@@ -17,8 +15,6 @@ const HiddenInput = (props) => {
const { value, setValue } = useFieldType({
path,
required,
initialData,
defaultValue,
});
return (
@@ -33,8 +29,6 @@ const HiddenInput = (props) => {
HiddenInput.defaultProps = {
required: false,
defaultValue: undefined,
initialData: undefined,
path: '',
};
@@ -42,8 +36,6 @@ HiddenInput.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
defaultValue: PropTypes.string,
initialData: PropTypes.string,
};
export default withCondition(HiddenInput);

View File

@@ -13,24 +13,24 @@ const NumberField = (props) => {
name,
path: pathFromProps,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
placeholder,
max,
min,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { min, max });
const validationResult = validate(value, { min, max, required });
return validationResult;
}, [validate, max, min]);
}, [validate, max, min, required]);
const {
value,
@@ -39,13 +39,16 @@ const NumberField = (props) => {
errorMessage,
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const handleChange = useCallback((e) => {
let val = parseInt(e.target.value, 10);
if (Number.isNaN(val)) val = '';
setValue(val);
}, [setValue]);
const classes = [
'field-type',
'number',
@@ -72,7 +75,7 @@ const NumberField = (props) => {
/>
<input
value={value || ''}
onChange={e => setValue(parseInt(e.target.value, 10))}
onChange={handleChange}
disabled={readOnly ? 'disabled' : undefined}
placeholder={placeholder}
type="number"
@@ -85,17 +88,13 @@ const NumberField = (props) => {
NumberField.defaultProps = {
label: null,
path: undefined,
required: false,
defaultValue: undefined,
initialData: undefined,
placeholder: undefined,
width: undefined,
style: {},
max: undefined,
min: undefined,
path: '',
readOnly: false,
validate: number,
admin: {},
};
NumberField.propTypes = {
@@ -103,15 +102,15 @@ NumberField.propTypes = {
path: PropTypes.string,
required: PropTypes.bool,
placeholder: PropTypes.string,
defaultValue: PropTypes.number,
initialData: PropTypes.number,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
max: PropTypes.number,
min: PropTypes.number,
readOnly: PropTypes.bool,
};
export default withCondition(NumberField);

View File

@@ -13,8 +13,6 @@ const Password = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
@@ -38,8 +36,6 @@ const Password = (props) => {
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
enableDebouncedValue: true,
});
@@ -82,8 +78,6 @@ const Password = (props) => {
Password.defaultProps = {
required: false,
initialData: undefined,
defaultValue: undefined,
validate: password,
width: undefined,
style: {},
@@ -95,8 +89,6 @@ Password.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
initialData: PropTypes.string,
defaultValue: PropTypes.string,
width: PropTypes.string,
style: PropTypes.shape({}),
label: PropTypes.string.isRequired,

View File

@@ -15,13 +15,13 @@ const RadioGroup = (props) => {
name,
path: pathFromProps,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
options,
} = props;
@@ -40,8 +40,6 @@ const RadioGroup = (props) => {
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
});
@@ -70,7 +68,7 @@ const RadioGroup = (props) => {
required={required}
/>
{options?.map((option) => {
const isSelected = !value ? (option.value === defaultValue) : (option.value === value);
const isSelected = option.value === value;
return (
<RadioInput
@@ -88,25 +86,21 @@ const RadioGroup = (props) => {
RadioGroup.defaultProps = {
label: null,
required: false,
readOnly: false,
defaultValue: null,
initialData: undefined,
validate: radio,
width: undefined,
style: {},
admin: {},
path: '',
};
RadioGroup.propTypes = {
path: PropTypes.string,
name: PropTypes.string.isRequired,
readOnly: PropTypes.bool,
required: PropTypes.bool,
defaultValue: PropTypes.string,
initialData: PropTypes.string,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({

View File

@@ -1,8 +1,7 @@
import React, {
Component, useState, useEffect, useCallback,
Component, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import Cookies from 'universal-cookie';
import some from 'async-some';
import config from 'payload/config';
import withCondition from '../../withCondition';
@@ -14,16 +13,14 @@ import { relationship } from '../../../../../fields/validations';
import './index.scss';
const cookies = new Cookies();
const {
cookiePrefix, serverURL, routes: { api }, collections,
serverURL, routes: { api }, collections,
} = config;
const cookieTokenName = `${cookiePrefix}-token`;
const maxResultsPerRequest = 10;
const baseClass = 'relationship';
class Relationship extends Component {
constructor(props) {
super(props);
@@ -36,6 +33,7 @@ class Relationship extends Component {
lastFullyLoadedRelation: -1,
lastLoadedPage: 1,
options: [],
errorLoading: false,
};
}
@@ -51,6 +49,7 @@ class Relationship extends Component {
}
getNextOptions = (params = {}) => {
const { errorLoading } = this.state;
const { clear } = params;
if (clear) {
@@ -59,47 +58,55 @@ class Relationship extends Component {
});
}
const {
relations, lastFullyLoadedRelation, lastLoadedPage, search,
} = this.state;
const token = cookies.get(cookieTokenName);
if (!errorLoading) {
const {
relations, lastFullyLoadedRelation, lastLoadedPage, search,
} = this.state;
const relationsToSearch = relations.slice(lastFullyLoadedRelation + 1);
const relationsToSearch = relations.slice(lastFullyLoadedRelation + 1);
if (relationsToSearch.length > 0) {
some(relationsToSearch, async (relation, callback) => {
const collection = collections.find(coll => coll.slug === relation);
const fieldToSearch = collection.useAsTitle || 'id';
const searchParam = search ? `&where[${fieldToSearch}][like]=${search}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPage}${searchParam}`, {
headers: {
Authorization: `JWT ${token}`,
},
if (relationsToSearch.length > 0) {
some(relationsToSearch, async (relation, callback) => {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = search ? `&where[${fieldToSearch}][like]=${search}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPage}${searchParam}`);
const data = await response.json();
if (response.ok) {
if (data.hasNextPage) {
return callback(false, {
data,
relation,
});
}
return callback({ relation, data });
}
let error = 'There was a problem loading options for this field.';
if (response.status === 403) {
error = 'You do not have permission to load options for this field.';
}
return this.setState({
errorLoading: error,
});
}, (lastPage, nextPage) => {
if (nextPage) {
const { data, relation } = nextPage;
this.addOptions(data, relation);
} else {
const { data, relation } = lastPage;
this.addOptions(data, relation);
this.setState({
lastFullyLoadedRelation: relations.indexOf(relation),
lastLoadedPage: 1,
});
}
});
const data = await response.json();
if (data.hasNextPage) {
return callback(false, {
data,
relation,
});
}
return callback({ relation, data });
}, (lastPage, nextPage) => {
if (nextPage) {
const { data, relation } = nextPage;
this.addOptions(data, relation);
} else {
const { data, relation } = lastPage;
this.addOptions(data, relation);
this.setState({
lastFullyLoadedRelation: relations.indexOf(relation),
lastLoadedPage: 1,
});
}
});
}
}
}
@@ -109,7 +116,7 @@ class Relationship extends Component {
const { hasMany } = this.props;
if (hasMany && Array.isArray(selectedValue)) {
return selectedValue.map(val => val.value);
return selectedValue.map((val) => val.value);
}
return selectedValue ? selectedValue.value : selectedValue;
@@ -136,9 +143,9 @@ class Relationship extends Component {
});
} else if (value) {
if (hasMany) {
foundValue = value.map(val => options.find(option => option.value === val));
foundValue = value.map((val) => options.find((option) => option.value === val));
} else {
foundValue = options.find(option => option.value === value);
foundValue = options.find((option) => option.value === value);
}
}
@@ -148,31 +155,29 @@ class Relationship extends Component {
addOptions = (data, relation) => {
const { hasMultipleRelations } = this.props;
const { lastLoadedPage, options } = this.state;
const collection = collections.find(coll => coll.slug === relation);
const collection = collections.find((coll) => coll.slug === relation);
if (!hasMultipleRelations) {
this.setState({
options: [
...options,
...data.docs.map(doc => ({
label: doc[collection.useAsTitle || 'id'],
...data.docs.map((doc) => ({
label: doc[collection?.admin?.useAsTitle || 'id'],
value: doc.id,
})),
],
});
} else {
const allOptionGroups = [...options];
const optionsToAddTo = allOptionGroups.find(optionGroup => optionGroup.label === collection.labels.plural);
const optionsToAddTo = allOptionGroups.find((optionGroup) => optionGroup.label === collection.labels.plural);
const newOptions = data.docs.map((doc) => {
return {
label: doc[collection.useAsTitle || 'id'],
value: {
relationTo: collection.slug,
value: doc.id,
},
};
});
const newOptions = data.docs.map((doc) => ({
label: doc[collection?.admin?.useAsTitle || 'id'],
value: {
relationTo: collection.slug,
value: doc.id,
},
}));
if (optionsToAddTo) {
optionsToAddTo.options = [
@@ -211,13 +216,11 @@ class Relationship extends Component {
}
render() {
const { options } = this.state;
const { options, errorLoading } = this.state;
const {
path,
required,
style,
width,
errorMessage,
label,
hasMany,
@@ -225,22 +228,23 @@ class Relationship extends Component {
showError,
formProcessing,
setValue,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
} = this.props;
const classes = [
'field-type',
'relationship',
baseClass,
showError && 'error',
errorLoading && 'error-loading',
readOnly && 'read-only',
].filter(Boolean).join(' ');
const valueToRender = this.findValueInOptions(options, value);
// ///////////////////////////////////////////
// TODO: simplify formatValue pattern seen below with react select
// ///////////////////////////////////////////
return (
<div
className={classes}
@@ -258,56 +262,59 @@ class Relationship extends Component {
label={label}
required={required}
/>
<ReactSelect
onInputChange={this.handleInputChange}
onChange={!readOnly ? setValue : undefined}
formatValue={this.formatSelectedValue}
onMenuScrollToBottom={this.handleMenuScrollToBottom}
findValueInOptions={this.findValueInOptions}
value={valueToRender}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
/>
{!errorLoading && (
<ReactSelect
onInputChange={this.handleInputChange}
onChange={!readOnly ? setValue : undefined}
formatValue={this.formatSelectedValue}
onMenuScrollToBottom={this.handleMenuScrollToBottom}
findValueInOptions={this.findValueInOptions}
value={valueToRender}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
/>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>
{errorLoading}
</div>
)}
</div>
);
}
}
Relationship.defaultProps = {
style: {},
required: false,
errorMessage: '',
hasMany: false,
width: undefined,
showError: false,
value: undefined,
path: '',
formProcessing: false,
readOnly: false,
admin: {},
};
Relationship.propTypes = {
readOnly: PropTypes.bool,
relationTo: PropTypes.oneOfType([
PropTypes.oneOf(Object.keys(collections).map((key) => {
return collections[key].slug;
})),
PropTypes.oneOf(Object.keys(collections).map((key) => collections[key].slug)),
PropTypes.arrayOf(
PropTypes.oneOf(Object.keys(collections).map((key) => {
return collections[key].slug;
})),
PropTypes.oneOf(Object.keys(collections).map((key) => collections[key].slug)),
),
]).isRequired,
required: PropTypes.bool,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
errorMessage: PropTypes.string,
showError: PropTypes.bool,
label: PropTypes.string.isRequired,
path: PropTypes.string,
formProcessing: PropTypes.bool,
width: PropTypes.string,
hasMany: PropTypes.bool,
setValue: PropTypes.func.isRequired,
hasMultipleRelations: PropTypes.bool.isRequired,
@@ -319,14 +326,11 @@ Relationship.propTypes = {
};
const RelationshipFieldType = (props) => {
const [formattedInitialData, setFormattedInitialData] = useState(undefined);
const {
defaultValue, relationTo, hasMany, validate, path, name, initialData, required,
relationTo, validate, path, name, required,
} = props;
const hasMultipleRelations = Array.isArray(relationTo);
const dataToInitialize = initialData || defaultValue;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
@@ -337,39 +341,10 @@ const RelationshipFieldType = (props) => {
const fieldType = useFieldType({
...props,
path: path || name,
initialData: formattedInitialData,
defaultValue,
validate: memoizedValidate,
required,
});
useEffect(() => {
const formatInitialData = (valueToFormat) => {
if (hasMultipleRelations) {
return {
...valueToFormat,
value: valueToFormat.value.id,
};
}
return valueToFormat.id;
};
if (dataToInitialize) {
if (hasMany && Array.isArray(dataToInitialize)) {
const newFormattedInitialData = [];
dataToInitialize.forEach((individualValue) => {
newFormattedInitialData.push(formatInitialData(individualValue));
});
setFormattedInitialData(newFormattedInitialData);
} else {
setFormattedInitialData(formatInitialData(dataToInitialize));
}
}
}, [dataToInitialize, hasMany, hasMultipleRelations]);
return (
<Relationship
{...props}

View File

@@ -3,3 +3,11 @@
.field-type.relationship {
position: relative;
}
.relationship__error-loading {
border: 1px solid $color-red;
min-height: base(2);
padding: base(.5) base(.75);
background-color: $color-red;
color: white;
}

View File

@@ -1,217 +0,0 @@
import React, { useEffect, useReducer, useCallback } from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { v4 as uuidv4 } from 'uuid';
import withCondition from '../../withCondition';
import Button from '../../../elements/Button';
import DraggableSection from '../../DraggableSection';
import reducer from '../rowReducer';
import { useRenderedFields } from '../../RenderFields';
import useForm from '../../Form/useForm';
import useFieldType from '../../useFieldType';
import Error from '../../Error';
import { repeater } from '../../../../../fields/validations';
import './index.scss';
const baseClass = 'field-type repeater';
const Repeater = (props) => {
const {
label,
name,
path: pathFromProps,
fields,
defaultValue,
initialData,
fieldTypes,
validate,
required,
maxRows,
minRows,
labels: {
singular: singularLabel,
},
permissions,
} = props;
const dataToInitialize = initialData || defaultValue;
const [rows, dispatchRows] = useReducer(reducer, []);
const { customComponentsPath } = useRenderedFields();
const { getDataByPath } = useForm();
const path = pathFromProps || name;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { minRows, maxRows, required });
return validationResult;
}, [validate, maxRows, minRows, required]);
const {
showError,
errorMessage,
value,
setValue,
} = useFieldType({
path,
validate: memoizedValidate,
disableFormData: true,
initialData: initialData?.length,
defaultValue: defaultValue?.length,
required,
});
const addRow = (rowIndex) => {
const data = getDataByPath(path);
dispatchRows({
type: 'ADD', index: rowIndex, data,
});
setValue(value + 1);
};
const removeRow = (rowIndex) => {
const data = getDataByPath(path);
dispatchRows({
type: 'REMOVE',
index: rowIndex,
data,
});
setValue(value - 1);
};
const moveRow = (moveFromIndex, moveToIndex) => {
const data = getDataByPath(path);
dispatchRows({
type: 'MOVE', index: moveFromIndex, moveToIndex, data,
});
};
const onDragEnd = (result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
};
useEffect(() => {
dispatchRows({
type: 'SET_ALL',
rows: dataToInitialize.reduce((acc, data) => ([
...acc,
{
key: uuidv4(),
open: true,
data,
},
]), []),
});
}, [dataToInitialize]);
return (
<DragDropContext onDragEnd={onDragEnd}>
<div className={baseClass}>
<header className={`${baseClass}__header`}>
<h3>{label}</h3>
<Error
showError={showError}
message={errorMessage}
/>
</header>
<Droppable droppableId="repeater-drop">
{provided => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rows.length > 0 && rows.map((row, i) => {
return (
<DraggableSection
key={row.key}
id={row.key}
blockType="repeater"
singularLabel={singularLabel}
isOpen={row.open}
rowCount={rows.length}
rowIndex={i}
addRow={() => addRow(i)}
removeRow={() => removeRow(i)}
moveRow={moveRow}
parentPath={path}
initialData={row.data}
initNull={row.initNull}
customComponentsPath={`${customComponentsPath}${name}.fields.`}
fieldTypes={fieldTypes}
fieldSchema={fields}
permissions={permissions.fields}
/>
);
})
}
{provided.placeholder}
</div>
)}
</Droppable>
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={() => addRow(value)}
buttonStyle="icon-label"
icon="plus"
iconStyle="with-border"
iconPosition="left"
>
{`Add ${singularLabel}`}
</Button>
</div>
</div>
</DragDropContext>
);
};
Repeater.defaultProps = {
label: '',
defaultValue: [],
initialData: [],
validate: repeater,
required: false,
maxRows: undefined,
minRows: undefined,
labels: {
singular: 'Row',
},
permissions: {},
};
Repeater.propTypes = {
defaultValue: PropTypes.arrayOf(
PropTypes.shape({}),
),
initialData: PropTypes.arrayOf(
PropTypes.shape({}),
),
fields: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
}),
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
validate: PropTypes.func,
required: PropTypes.bool,
maxRows: PropTypes.number,
minRows: PropTypes.number,
permissions: PropTypes.shape({
fields: PropTypes.shape({}),
}),
};
export default withCondition(Repeater);

View File

@@ -75,14 +75,14 @@ const RichText = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
placeholder,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const editor = useMemo(() => pipe(createEditor(), ...withPlugins), []);
@@ -92,8 +92,6 @@ const RichText = (props) => {
const fieldType = useFieldType({
path,
required,
initialData,
defaultValue,
validate,
});
@@ -150,12 +148,8 @@ const RichText = (props) => {
RichText.defaultProps = {
label: null,
required: false,
readOnly: false,
defaultValue: undefined,
initialData: undefined,
placeholder: undefined,
width: undefined,
style: {},
admin: {},
validate: richText,
path: '',
};
@@ -164,13 +158,13 @@ RichText.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
readOnly: PropTypes.bool,
placeholder: PropTypes.string,
defaultValue: PropTypes.string,
initialData: PropTypes.arrayOf(PropTypes.shape({})),
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
};

View File

@@ -7,29 +7,24 @@ import './index.scss';
const Row = (props) => {
const {
fields, fieldTypes, initialData, path, permissions,
fields, fieldTypes, path, permissions,
} = props;
return (
<div className="field-type row">
<RenderFields
permissions={permissions}
initialData={initialData}
fieldTypes={fieldTypes}
fieldSchema={fields.map((field) => {
return {
...field,
path: `${path ? `${path}.` : ''}${field.name}`,
};
})}
/>
</div>
<RenderFields
className="field-type row"
permissions={permissions}
fieldTypes={fieldTypes}
fieldSchema={fields.map((field) => ({
...field,
path: `${path ? `${path}.` : ''}${field.name}`,
}))}
/>
);
};
Row.defaultProps = {
path: '',
initialData: undefined,
permissions: {},
};
@@ -39,7 +34,6 @@ Row.propTypes = {
).isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
path: PropTypes.string,
initialData: PropTypes.shape({}),
permissions: PropTypes.shape({}),
};

View File

@@ -10,7 +10,7 @@ import { select } from '../../../../../fields/validations';
import './index.scss';
const findFullOption = (value, options) => {
const matchedOption = options.find(option => option?.value === value);
const matchedOption = options.find((option) => option?.value === value);
if (matchedOption) {
if (typeof matchedOption === 'object' && matchedOption.label && matchedOption.value) {
@@ -65,15 +65,15 @@ const Select = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
options,
hasMany,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
} = props;
const path = pathFromProps || name;
@@ -92,8 +92,6 @@ const Select = (props) => {
path,
label,
required,
initialData,
defaultValue,
validate: memoizedValidate,
});
@@ -137,34 +135,24 @@ const Select = (props) => {
};
Select.defaultProps = {
style: {},
admin: {},
required: false,
validate: select,
defaultValue: undefined,
initialData: undefined,
hasMany: false,
width: undefined,
path: '',
readOnly: false,
};
Select.propTypes = {
required: PropTypes.bool,
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string.isRequired,
defaultValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
initialData: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
validate: PropTypes.func,
name: PropTypes.string.isRequired,
path: PropTypes.string,
width: PropTypes.string,
options: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.string,

View File

@@ -13,14 +13,14 @@ const Text = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
label,
placeholder,
readOnly,
admin: {
readOnly,
style,
width,
} = {},
minLength,
maxLength,
} = props;
@@ -34,9 +34,6 @@ const Text = (props) => {
const fieldType = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
enableDebouncedValue: true,
});
@@ -88,12 +85,8 @@ const Text = (props) => {
Text.defaultProps = {
label: null,
required: false,
readOnly: false,
defaultValue: undefined,
initialData: undefined,
admin: {},
placeholder: undefined,
width: undefined,
style: {},
validate: text,
path: '',
minLength: undefined,
@@ -104,13 +97,13 @@ Text.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
readOnly: PropTypes.bool,
placeholder: PropTypes.string,
defaultValue: PropTypes.string,
initialData: PropTypes.string,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,

View File

@@ -13,14 +13,14 @@ const Textarea = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
validate,
style,
width,
admin: {
readOnly,
style,
width,
} = {},
label,
placeholder,
readOnly,
minLength,
maxLength,
rows,
@@ -40,9 +40,6 @@ const Textarea = (props) => {
errorMessage,
} = useFieldType({
path,
required,
initialData,
defaultValue,
validate: memoizedValidate,
enableDebouncedValue: true,
});
@@ -87,14 +84,10 @@ const Textarea = (props) => {
Textarea.defaultProps = {
required: false,
label: null,
defaultValue: undefined,
initialData: undefined,
validate: textarea,
width: undefined,
style: {},
placeholder: null,
path: '',
readOnly: false,
admin: {},
minLength: undefined,
maxLength: undefined,
rows: 8,
@@ -104,14 +97,14 @@ Textarea.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
defaultValue: PropTypes.string,
initialData: PropTypes.string,
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
label: PropTypes.string,
placeholder: PropTypes.string,
readOnly: PropTypes.bool,
minLength: PropTypes.number,
maxLength: PropTypes.number,
rows: PropTypes.number,

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { Modal, useModal } from '@trbl/react-modal';
import { Modal, useModal } from '@faceless-ui/modal';
import config from '../../../../../config';
import MinimalTemplate from '../../../../templates/Minimal';
import Form from '../../../Form';
@@ -67,7 +67,7 @@ const AddUploadModal = (props) => {
/>
</header>
<RenderFields
filter={field => (!field.position || (field.position && field.position !== 'sidebar'))}
filter={(field) => (!field.position || (field.position && field.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
customComponentsPath={`${collection.slug}.fields.`}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Modal, useModal } from '@trbl/react-modal';
import { Modal, useModal } from '@faceless-ui/modal';
import config from '../../../../../config';
import MinimalTemplate from '../../../../templates/Minimal';
import Button from '../../../../elements/Button';
@@ -74,6 +74,7 @@ const SelectExistingUploadModal = (props) => {
/>
</header>
<ListControls
enableColumns={false}
handleChange={setListControls}
collection={{
...collection,

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useModal } from '@trbl/react-modal';
import { useModal } from '@faceless-ui/modal';
import config from '../../../../config';
import useFieldType from '../../useFieldType';
import withCondition from '../../withCondition';
@@ -14,7 +14,7 @@ import SelectExistingModal from './SelectExisting';
import './index.scss';
const { collections } = config;
const { collections, serverURL, routes: { api } } = config;
const baseClass = 'upload';
@@ -26,29 +26,32 @@ const Upload = (props) => {
path: pathFromProps,
name,
required,
defaultValue,
initialData,
style,
width,
admin: {
readOnly,
style,
width,
} = {},
label,
readOnly,
validate,
relationTo,
fieldTypes,
} = props;
const collection = collections.find(coll => coll.slug === relationTo);
const collection = collections.find((coll) => coll.slug === relationTo);
const path = pathFromProps || name;
const addModalSlug = `${path}-add`;
const selectExistingModalSlug = `${path}-select-existing`;
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
path,
required,
initialData: initialData?.id,
defaultValue,
validate,
validate: memoizedValidate,
});
const {
@@ -66,10 +69,19 @@ const Upload = (props) => {
].filter(Boolean).join(' ');
useEffect(() => {
if (initialData) {
setInternalValue(initialData);
if (typeof value === 'string') {
const fetchFile = async () => {
const response = await fetch(`${serverURL}${api}/${relationTo}/${value}`);
if (response.ok) {
const json = await response.json();
setInternalValue(json);
}
};
fetchFile();
}
}, [initialData]);
}, [value, setInternalValue, relationTo]);
return (
<div
@@ -89,7 +101,7 @@ const Upload = (props) => {
required={required}
/>
{collection?.upload && (
<>
<React.Fragment>
{internalValue && (
<FileDetails
{...collection.upload}
@@ -142,7 +154,7 @@ const Upload = (props) => {
addModalSlug,
}}
/>
</>
</React.Fragment>
)}
</div>
);
@@ -151,11 +163,7 @@ const Upload = (props) => {
Upload.defaultProps = {
label: null,
required: false,
readOnly: false,
defaultValue: undefined,
initialData: undefined,
width: undefined,
style: {},
admin: {},
validate: upload,
path: '',
};
@@ -164,14 +172,12 @@ Upload.propTypes = {
name: PropTypes.string.isRequired,
path: PropTypes.string,
required: PropTypes.bool,
readOnly: PropTypes.bool,
defaultValue: PropTypes.string,
initialData: PropTypes.shape({
id: PropTypes.string,
}),
validate: PropTypes.func,
width: PropTypes.string,
style: PropTypes.shape({}),
admin: PropTypes.shape({
readOnly: PropTypes.bool,
style: PropTypes.shape({}),
width: PropTypes.string,
}),
relationTo: PropTypes.string.isRequired,
fieldTypes: PropTypes.shape({}).isRequired,
label: PropTypes.oneOfType([

View File

@@ -4,11 +4,12 @@
position: relative;
&__wrap {
padding: base(1.5);
display: flex;
padding: base(1.5) base(1.5) $baseline;
background: $color-background-gray;
.btn {
margin: 0 $baseline 0 0;
margin: 0 $baseline base(.5) 0;
}
}
}

View File

@@ -1,6 +1,3 @@
export { default as auth } from './Auth';
export { default as file } from './File';
export { default as email } from './Email';
export { default as hidden } from './HiddenInput';
export { default as text } from './Text';
@@ -15,8 +12,8 @@ export { default as checkbox } from './Checkbox';
export { default as richText } from './RichText';
export { default as radio } from './RadioGroup';
export { default as flexible } from './Flexible';
export { default as blocks } from './Blocks';
export { default as group } from './Group';
export { default as repeater } from './Repeater';
export { default as array } from './Array';
export { default as row } from './Row';
export { default as upload } from './Upload';

View File

@@ -2,59 +2,57 @@ import { v4 as uuidv4 } from 'uuid';
const reducer = (currentState, action) => {
const {
type, index, moveToIndex, rows, data = [], initialRowData = {},
type, rowIndex, moveFromIndex, moveToIndex, data, blockType,
} = action;
const stateCopy = [...currentState];
switch (type) {
case 'SET_ALL':
return rows;
case 'SET_ALL': {
if (Array.isArray(data)) {
return data.map((dataRow) => {
const row = {
key: uuidv4(),
open: true,
};
if (dataRow.blockType) {
row.blockType = dataRow.blockType;
}
return row;
});
}
return [];
}
case 'TOGGLE_COLLAPSE':
stateCopy[index].open = !stateCopy[index].open;
stateCopy[rowIndex].open = !stateCopy[rowIndex].open;
return stateCopy;
case 'ADD': {
stateCopy.splice(index + 1, 0, {
const newRow = {
open: true,
key: uuidv4(),
data,
});
};
data.splice(index + 1, 0, initialRowData);
if (blockType) newRow.blockType = blockType;
const result = stateCopy.map((row, i) => {
return {
...row,
data: {
...(data[i] || {}),
},
};
});
stateCopy.splice(rowIndex + 1, 0, newRow);
return result;
return stateCopy;
}
case 'REMOVE':
stateCopy.splice(index, 1);
stateCopy.splice(rowIndex, 1);
return stateCopy;
case 'MOVE': {
const stateCopyWithNewData = stateCopy.map((row, i) => {
return {
...row,
data: {
...(data[i] || {}),
},
};
});
const movingRowState = { ...stateCopyWithNewData[index] };
stateCopyWithNewData.splice(index, 1);
stateCopyWithNewData.splice(moveToIndex, 0, movingRowState);
return stateCopyWithNewData;
const movingRowState = { ...stateCopy[moveFromIndex] };
stateCopy.splice(moveFromIndex, 1);
stateCopy.splice(moveToIndex, 0, movingRowState);
return stateCopy;
}
default:

View File

@@ -1,42 +1,38 @@
import {
useContext, useCallback, useEffect, useState,
useCallback, useEffect, useState,
} from 'react';
import FormContext from '../Form/FormContext';
import { useFormProcessing, useFormSubmitted, useFormModified, useForm } from '../Form/context';
import useDebounce from '../../../hooks/useDebounce';
import useUnmountEffect from '../../../hooks/useUnmountEffect';
import './index.scss';
const useFieldType = (options) => {
const {
path,
initialData: data,
defaultValue,
validate,
disableFormData,
enableDebouncedValue,
disableFormData,
ignoreWhileFlattening,
} = options;
// Determine what the initial data is to be used
// If initialData is defined, that means that data has been provided
// via the API and should override any default values present.
// If no initialData, use default value
const initialData = data !== undefined ? data : defaultValue;
const formContext = useContext(FormContext);
const formContext = useForm();
const submitted = useFormSubmitted();
const processing = useFormProcessing();
const modified = useFormModified();
const {
dispatchFields, submitted, processing, getField, setModified, modified,
dispatchFields, getField, setModified, reset,
} = formContext;
const [internalValue, setInternalValue] = useState(initialData);
const [internalValue, setInternalValue] = useState(undefined);
// Debounce internal values to update form state only every 60ms
const debouncedValue = useDebounce(internalValue, 120);
// Get field by path
const field = getField(path);
const fieldExists = Boolean(field);
const initialValue = field?.initialValue;
// Valid could be a string equal to an error message
const valid = (field && typeof field.valid === 'boolean') ? field.valid : true;
@@ -54,48 +50,39 @@ const useFieldType = (options) => {
fieldToDispatch.valid = false;
}
if (disableFormData) {
fieldToDispatch.disableFormData = true;
}
fieldToDispatch.disableFormData = disableFormData;
fieldToDispatch.ignoreWhileFlattening = ignoreWhileFlattening;
fieldToDispatch.initialValue = initialValue;
dispatchFields(fieldToDispatch);
}, [path, dispatchFields, validate, disableFormData]);
}, [path, dispatchFields, validate, disableFormData, ignoreWhileFlattening, initialValue]);
// Method to return from `useFieldType`, used to
// update internal field values from field component(s)
// as fast as they arrive. NOTE - this method is NOT debounced
const setValue = useCallback((e) => {
const value = (e && e.target) ? e.target.value : e;
const val = (e && e.target) ? e.target.value : e;
if (!modified) setModified(true);
setInternalValue(value);
setInternalValue(val);
}, [setModified, modified]);
// Remove field from state on "unmount"
// This is mostly used for repeater / flex content row modifications
useUnmountEffect(() => {
formContext.dispatchFields({ path, type: 'REMOVE' });
});
useEffect(() => {
setInternalValue(initialValue);
}, [initialValue]);
// The only time that the FORM value should be updated
// is when the debounced value updates. So, when the debounced value updates,
// send it up to the form
const formValue = enableDebouncedValue ? debouncedValue : internalValue;
const valueToSend = enableDebouncedValue ? debouncedValue : internalValue;
useEffect(() => {
if (!fieldExists || formValue !== undefined) {
sendField(formValue);
if (valueToSend !== undefined) {
sendField(valueToSend);
}
}, [formValue, sendField, fieldExists]);
useEffect(() => {
if (initialData !== undefined) {
setInternalValue(initialData);
}
}, [initialData]);
}, [valueToSend, sendField]);
return {
...options,

View File

@@ -1,11 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import useFormFields from '../Form/useFormFields';
import { useFormFields } from '../Form/context';
const withCondition = (Field) => {
const CheckForCondition = (props) => {
const { condition } = props;
const {
admin: {
condition,
} = {},
} = props;
if (condition) {
return <WithCondition {...props} />;
@@ -15,19 +19,27 @@ const withCondition = (Field) => {
};
CheckForCondition.defaultProps = {
condition: null,
admin: undefined,
name: '',
path: '',
};
CheckForCondition.propTypes = {
condition: PropTypes.func,
admin: PropTypes.shape({
condition: PropTypes.func,
}),
name: PropTypes.string,
path: PropTypes.string,
};
const WithCondition = (props) => {
const { condition, name, path } = props;
const {
name,
path,
admin: {
condition,
} = {},
} = props;
const { getData, getSiblingData } = useFormFields();
@@ -43,13 +55,15 @@ const withCondition = (Field) => {
};
WithCondition.defaultProps = {
condition: null,
admin: undefined,
name: '',
path: '',
};
WithCondition.propTypes = {
condition: PropTypes.func,
admin: PropTypes.shape({
condition: PropTypes.func,
}),
name: PropTypes.string,
path: PropTypes.string,
};

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { render } from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { ScrollInfoProvider } from '@trbl/react-scroll-info';
import { WindowInfoProvider } from '@trbl/react-window-info';
import { ModalProvider, ModalContainer } from '@trbl/react-modal';
import { ScrollInfoProvider } from '@faceless-ui/scroll-info';
import { WindowInfoProvider } from '@faceless-ui/window-info';
import { ModalProvider, ModalContainer } from '@faceless-ui/modal';
import { SearchParamsProvider } from './utilities/SearchParams';
import { LocaleProvider } from './utilities/Locale';
import StatusList, { StatusListProvider } from './elements/Status';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import NavigationPrompt from 'react-router-navigation-prompt';
import useForm from '../../forms/Form/useForm';
import { useForm } from '../../forms/Form/context';
import MinimalTemplate from '../../templates/Minimal';
import Button from '../../elements/Button';
@@ -13,27 +13,25 @@ const LeaveWithoutSaving = () => {
return (
<NavigationPrompt when={modified}>
{({ onConfirm, onCancel }) => {
return (
<div className={modalSlug}>
<MinimalTemplate>
<h1>Leave without saving</h1>
<p>Your changes have not been saved. If you leave now, you will lose your changes.</p>
<Button
onClick={onCancel}
buttonStyle="secondary"
>
Stay on this page
</Button>
<Button
onClick={onConfirm}
>
Leave anyway
</Button>
</MinimalTemplate>
</div>
);
}}
{({ onConfirm, onCancel }) => (
<div className={modalSlug}>
<MinimalTemplate>
<h1>Leave without saving</h1>
<p>Your changes have not been saved. If you leave now, you will lose your changes.</p>
<Button
onClick={onCancel}
buttonStyle="secondary"
>
Stay on this page
</Button>
<Button
onClick={onConfirm}
>
Leave anyway
</Button>
</MinimalTemplate>
</div>
)}
</NavigationPrompt>
);
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useModal, Modal } from '@trbl/react-modal';
import { useModal, Modal } from '@faceless-ui/modal';
import config from 'payload/config';
import MinimalTemplate from '../../templates/Minimal';
import Button from '../../elements/Button';
@@ -13,7 +13,7 @@ const baseClass = 'stay-logged-in';
const { routes: { admin } } = config;
const StayLoggedInModal = (props) => {
const { refreshToken } = props;
const { refreshCookie } = props;
const history = useHistory();
const { closeAll: closeAllModals } = useModal();
@@ -36,7 +36,7 @@ const StayLoggedInModal = (props) => {
Log out
</Button>
<Button onClick={() => {
refreshToken();
refreshCookie();
closeAllModals();
}}
>
@@ -49,7 +49,7 @@ const StayLoggedInModal = (props) => {
};
StayLoggedInModal.propTypes = {
refreshToken: PropTypes.func.isRequired,
refreshCookie: PropTypes.func.isRequired,
};
export default StayLoggedInModal;

View File

@@ -37,6 +37,7 @@ const RenderCustomComponent = (props) => {
RenderCustomComponent.defaultProps = {
path: undefined,
componentProps: {},
};
RenderCustomComponent.propTypes = {
@@ -47,7 +48,7 @@ RenderCustomComponent.propTypes = {
PropTypes.node,
PropTypes.element,
]).isRequired,
componentProps: PropTypes.shape({}).isRequired,
componentProps: PropTypes.shape({}),
};
export default RenderCustomComponent;

View File

@@ -31,7 +31,9 @@ const DefaultAccount = (props) => {
const {
slug,
fields,
useAsTitle,
admin: {
useAsTitle,
},
timestamps,
preview,
} = collection;
@@ -61,7 +63,7 @@ const DefaultAccount = (props) => {
</h1>
</header>
<RenderFields
filter={field => (!field.position || (field.position && field.position !== 'sidebar'))}
filter={(field) => (!field.position || (field.position && field.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={dataToRender}
@@ -100,7 +102,7 @@ const DefaultAccount = (props) => {
</div>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
filter={field => field.position === 'sidebar'}
filter={(field) => field.position === 'sidebar'}
position="sidebar"
fieldTypes={fieldTypes}
fieldSchema={fields}
@@ -114,7 +116,7 @@ const DefaultAccount = (props) => {
<div>{data?.id}</div>
</li>
{timestamps && (
<>
<React.Fragment>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
@@ -127,7 +129,7 @@ const DefaultAccount = (props) => {
<div>{format(new Date(data.createdAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
</>
</React.Fragment>
)}
</ul>
@@ -150,7 +152,9 @@ DefaultAccount.propTypes = {
singular: PropTypes.string,
}),
slug: PropTypes.string,
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,
timestamps: PropTypes.bool,

View File

@@ -1,9 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import config from 'payload/config';
import MinimalTemplate from '../../templates/Minimal';
import StatusList, { useStatusList } from '../../elements/Status';
import Form from '../../forms/Form';
import RenderFields from '../../forms/RenderFields';
import * as fieldTypes from '../../forms/field-types';
@@ -16,29 +14,20 @@ const {
admin: { user: userSlug }, collections, serverURL, routes: { admin, api },
} = config;
const userConfig = collections.find(collection => collection.slug === userSlug);
const userConfig = collections.find((collection) => collection.slug === userSlug);
const baseClass = 'create-first-user';
const CreateFirstUser = (props) => {
const { setInitialized } = props;
const { addStatus } = useStatusList();
const { setToken } = useUser();
const history = useHistory();
const handleAjaxResponse = (res) => {
res.json().then((data) => {
if (data.token) {
setToken(data.token);
setInitialized(true);
history.push(`${admin}`);
} else {
addStatus({
type: 'error',
message: 'There was a problem creating your first user.',
});
}
});
const onSuccess = (json) => {
if (json?.user?.token) {
setToken(json.user.token);
}
setInitialized(true);
};
const fields = [
@@ -59,11 +48,10 @@ const CreateFirstUser = (props) => {
<MinimalTemplate className={baseClass}>
<h1>Welcome to Payload</h1>
<p>To begin, create your first user.</p>
<StatusList />
<Form
handleAjaxResponse={handleAjaxResponse}
disableSuccessStatus
onSuccess={onSuccess}
method="POST"
redirect={admin}
action={`${serverURL}${api}/${userSlug}/first-register`}
>
<RenderFields

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import config from 'payload/config';
import { useUser } from '../../data/User';
import { useStepNav } from '../../elements/StepNav';
import Eyebrow from '../../elements/Eyebrow';
import Card from '../../elements/Card';
import Button from '../../elements/Button';
import './index.scss';
const {
collections,
globals,
routes: {
admin,
},
} = config;
const baseClass = 'dashboard';
const Dashboard = () => {
const [filteredGlobals, setFilteredGlobals] = useState([]);
const { setStepNav } = useStepNav();
const { push } = useHistory();
const { permissions } = useUser();
useEffect(() => {
setFilteredGlobals(
globals.filter((global) => permissions?.[global.slug]?.read?.permission),
);
}, [permissions]);
useEffect(() => {
setStepNav([]);
}, [setStepNav]);
return (
<div className={baseClass}>
<Eyebrow />
<div className={`${baseClass}__wrap`}>
<h3 className={`${baseClass}__label`}>Collections</h3>
<ul className={`${baseClass}__card-list`}>
{collections.map((collection) => {
if (permissions?.[collection.slug]?.read?.permission) {
return (
<li key={collection.slug}>
<Card
title={collection.labels.plural}
onClick={() => push({ pathname: `${admin}/collections/${collection.slug}` })}
actions={(
<Button
el="link"
to={`${admin}/collections/${collection.slug}/create`}
icon="plus"
round
buttonStyle="icon-label"
iconStyle="with-border"
/>
)}
/>
</li>
);
}
return null;
})}
</ul>
{(filteredGlobals.length > 0) && (
<React.Fragment>
<h3 className={`${baseClass}__label`}>Globals</h3>
<ul className={`${baseClass}__card-list`}>
{filteredGlobals.map((global) => (
<li key={global.slug}>
<Card
title={global.label}
onClick={() => push({ pathname: `${admin}/globals/${global.slug}` })}
/>
</li>
))}
</ul>
</React.Fragment>
)}
</div>
</div>
);
};
export default Dashboard;

View File

@@ -1,18 +1,7 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import config from 'payload/config';
import { useStepNav } from '../../elements/StepNav';
import Eyebrow from '../../elements/Eyebrow';
import './index.scss';
const {
routes: {
admin,
},
} = config;
const baseClass = 'dashboard';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import DefaultDashboard from './Default';
const Dashboard = () => {
const { setStepNav } = useStepNav();
@@ -22,15 +11,10 @@ const Dashboard = () => {
}, [setStepNav]);
return (
<div className={baseClass}>
<Eyebrow />
<h1>Dashboard</h1>
<Link to={`${admin}/login`}>Login</Link>
<br />
<Link to={`${admin}/collections/pages`}>Pages List</Link>
<br />
<Link to={`${admin}/collections/pages/test123`}>Edit Page</Link>
</div>
<RenderCustomComponent
DefaultComponent={DefaultDashboard}
path="views.List"
/>
);
};

View File

@@ -1,3 +1,66 @@
.dashboard {
@import '../../../scss/styles';
.dashboard {
width: 100%;
padding-right: base(2);
&__wrap {
padding: base(2);
background: white;
}
&__label {
@extend %body;
color: $color-gray;
}
&__card-list {
width: calc(100% + #{$baseline});
margin: 0 - base(.5);
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
li {
width: 20%;
}
.card {
margin: 0 base(.5) $baseline;
}
}
@include large-break {
li {
width: 25%;
}
}
@include mid-break {
padding-right: 0;
&__wrap {
padding: $baseline;
}
li {
width: 33.33%;
}
}
@include small-break {
&__card-list {
width: calc(100% + #{base(.5)});
margin: 0 - base(.25);
}
li {
width: 50%;
}
.card {
margin: 0 base(.25) base(.5);
}
}
}

View File

@@ -27,7 +27,7 @@ const ForgotPassword = () => {
const [hasSubmitted, setHasSubmitted] = useState(false);
const { user } = useUser();
const handleAjaxResponse = (res) => {
const handleResponse = (res) => {
res.json()
.then(() => {
setHasSubmitted(true);
@@ -86,7 +86,7 @@ const ForgotPassword = () => {
<StatusList />
<Form
novalidate
handleAjaxResponse={handleAjaxResponse}
handleResponse={handleResponse}
method="POST"
action={`${serverURL}${api}/${userSlug}/forgot-password`}
>

View File

@@ -0,0 +1,140 @@
import React from 'react';
import PropTypes from 'prop-types';
import format from 'date-fns/format';
import Eyebrow from '../../elements/Eyebrow';
import Form from '../../forms/Form';
import PreviewButton from '../../elements/PreviewButton';
import FormSubmit from '../../forms/Submit';
import RenderFields from '../../forms/RenderFields';
import CopyToClipboard from '../../elements/CopyToClipboard';
import * as fieldTypes from '../../forms/field-types';
import LeaveWithoutSaving from '../../modals/LeaveWithoutSaving';
import './index.scss';
const baseClass = 'global-edit';
const DefaultGlobalView = (props) => {
const {
global, data, onSave, permissions, action, apiURL,
} = props;
const {
slug,
fields,
preview,
label,
} = global;
const hasSavePermission = permissions?.update?.permission;
return (
<div className={baseClass}>
<Form
className={`${baseClass}__form`}
method="post"
action={action}
onSuccess={onSave}
disabled={!hasSavePermission}
>
<div className={`${baseClass}__main`}>
<Eyebrow />
<LeaveWithoutSaving />
<div className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>
Edit
{' '}
{label}
</h1>
</header>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field.position || (field.position && field.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={data}
customComponentsPath={`${slug}.fields.`}
/>
</div>
</div>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__document-actions${preview ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
<PreviewButton generatePreviewURL={preview} />
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
)}
</div>
{data && (
<div className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</div>
)}
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field.position === 'sidebar'}
position="sidebar"
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={data}
customComponentsPath={`${slug}.fields.`}
/>
</div>
{data && (
<ul className={`${baseClass}__meta`}>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
</ul>
)}
</div>
</Form>
</div>
);
};
DefaultGlobalView.defaultProps = {
data: undefined,
};
DefaultGlobalView.propTypes = {
global: PropTypes.shape({
label: PropTypes.string.isRequired,
slug: PropTypes.string,
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,
}).isRequired,
data: PropTypes.shape({
updatedAt: PropTypes.string,
}),
onSave: PropTypes.func.isRequired,
permissions: PropTypes.shape({
update: PropTypes.shape({
permission: PropTypes.bool,
}),
fields: PropTypes.shape({}),
}).isRequired,
action: PropTypes.string.isRequired,
apiURL: PropTypes.string.isRequired,
};
export default DefaultGlobalView;

View File

@@ -1,68 +1,80 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useHistory, useLocation } from 'react-router-dom';
import config from 'payload/config';
import { useStepNav } from '../../elements/StepNav';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Form from '../../forms/Form';
import RenderFields from '../../forms/RenderFields';
import * as fieldTypes from '../../forms/field-types';
import { useUser } from '../../data/User';
import { useLocale } from '../../utilities/Locale';
const {
serverURL,
routes: {
admin,
api,
},
} = config;
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import DefaultGlobal from './Default';
const baseClass = 'global-edit';
const { serverURL, routes: { admin, api } } = config;
const Global = (props) => {
const { global: { slug, label, fields } } = props;
const GlobalView = (props) => {
const { state: locationState } = useLocation();
const history = useHistory();
const locale = useLocale();
const { setStepNav } = useStepNav();
const { permissions } = useUser();
const { global } = props;
const {
slug,
label,
} = global;
const onSave = (json) => {
history.push(`${admin}/globals/${global.slug}`, {
status: {
message: json.message,
type: 'success',
},
data: json.doc,
});
};
const [{ data }] = usePayloadAPI(
`${serverURL}${api}/globals/${slug}`,
{ initialParams: { 'fallback-locale': 'null' } },
);
const dataToRender = locationState?.data || data;
useEffect(() => {
setStepNav([{
url: `${admin}/globals/${slug}`,
const nav = [{
label,
}]);
}, [setStepNav, slug, label]);
}];
setStepNav(nav);
}, [setStepNav, label]);
const globalPermissions = permissions?.[slug];
return (
<div className={baseClass}>
<header className={`${baseClass}__header`}>
<h1>
Edit
{' '}
{label}
</h1>
</header>
<Form
className={`${baseClass}__form`}
method={data ? 'put' : 'post'}
action={`${serverURL}${api}/globals/${slug}`}
>
<RenderFields
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={data}
/>
</Form>
</div>
<RenderCustomComponent
DefaultComponent={DefaultGlobal}
path={`${slug}.views.Edit`}
componentProps={{
data: dataToRender,
permissions: globalPermissions,
global,
onSave,
apiURL: `${serverURL}${api}/globals/${slug}?depth=0`,
action: `${serverURL}${api}/globals/${slug}?locale=${locale}`,
}}
/>
);
};
Global.propTypes = {
GlobalView.propTypes = {
global: PropTypes.shape({
label: PropTypes.string,
slug: PropTypes.string,
label: PropTypes.string.isRequired,
slug: PropTypes.string.isRequired,
fields: PropTypes.arrayOf(PropTypes.shape({})),
}).isRequired,
};
export default Global;
export default GlobalView;

View File

@@ -0,0 +1,186 @@
@import '../../../scss/styles.scss';
.global-edit {
width: 100%;
&__form {
display: flex;
align-items: stretch;
}
&__main {
min-width: 0;
}
&__header {
h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&__collection-actions {
list-style: none;
margin: 0;
padding: base(1.5) 0 base(.5);
display: flex;
li {
margin-right: base(.75);
}
}
&__edit {
background: white;
padding: base(5) base(6) base(6);
}
&__sidebar {
padding-top: base(3);
padding-bottom: base(1);
width: base(22);
display: flex;
flex-direction: column;
min-width: 0;
}
&__collection-actions,
&__document-actions,
&__meta,
&__sidebar-fields,
&__api-url {
padding-left: base(1.5);
}
&__document-actions {
@include blur-bg;
position: sticky;
top: 0;
z-index: 2;
padding-right: $baseline;
}
&__document-actions--with-preview {
display: flex;
> * {
width: calc(50% - #{base(.5)});
}
> *:first-child {
margin-right: base(.5);
}
> *:last-child {
margin-left: base(.5);
}
.form-submit {
.btn {
width: 100%;
padding-left: base(2);
padding-right: base(2);
}
}
}
&__api-url {
margin-bottom: base(1.5);
padding-right: base(1.5);
a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
}
&__meta {
margin: 0;
padding-top: $baseline;
list-style: none;
li {
margin-bottom: base(.5);
}
}
&__label {
color: $color-gray;
}
&__collection-actions,
&__api-url {
a, button {
cursor: pointer;
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&--is-editing {
.collection-edit__sidebar {
padding-top: 0;
}
}
@include large-break {
&__edit {
padding: base(3.5);
}
}
@include mid-break {
&__sidebar {
width: unset;
}
&__form {
display: block;
}
&__edit {
padding: $baseline;
margin-bottom: $baseline;
}
&__document-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
}
&__document-actions,
&__meta,
&__sidebar-fields,
&__api-url {
padding-left: $baseline;
}
&__api-url {
margin-bottom: base(.5);
}
&__collection-actions {
margin-top: base(.5);
padding-left: $baseline;
padding-bottom: 0;
order: 1;
li {
margin: 0 base(.5) 0 0;
}
}
&__sidebar {
padding-bottom: base(4);
}
}
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import config from 'payload/config';
import { useUser } from '../../data/User';
import Minimal from '../../templates/Minimal';
@@ -10,7 +11,9 @@ const { routes: { admin } } = config;
const baseClass = 'logout';
const Logout = () => {
const Logout = (props) => {
const { inactivity } = props;
const { logOut } = useUser();
useEffect(() => {
@@ -20,7 +23,12 @@ const Logout = () => {
return (
<Minimal className={baseClass}>
<div className={`${baseClass}__wrap`}>
<h2>You have been logged out successfully.</h2>
{inactivity && (
<h2>You have been logged out due to inactivity.</h2>
)}
{!inactivity && (
<h2>You have been logged out successfully.</h2>
)}
<br />
<Button
el="anchor"
@@ -34,4 +42,12 @@ const Logout = () => {
);
};
Logout.defaultProps = {
inactivity: false,
};
Logout.propTypes = {
inactivity: PropTypes.bool,
};
export default Logout;

View File

@@ -20,7 +20,7 @@ const ResetPassword = () => {
const history = useHistory();
const { user, setToken } = useUser();
const handleAjaxResponse = (res) => {
const handleResponse = (res) => {
res.json()
.then((data) => {
if (data.token) {
@@ -60,7 +60,7 @@ const ResetPassword = () => {
<div className={`${baseClass}__wrap`}>
<StatusList />
<Form
handleAjaxResponse={handleAjaxResponse}
handleResponse={handleResponse}
method="POST"
action={`${serverURL}${api}/${userSlug}/reset-password`}
redirect={admin}

View File

@@ -1,23 +1,20 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import React, { useMemo, useState, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
import useFieldType from '../../useFieldType';
import Label from '../../Label';
import Button from '../../../elements/Button';
import CopyToClipboard from '../../../elements/CopyToClipboard';
import { text } from '../../../../../fields/validations';
import useFormFields from '../../Form/useFormFields';
import useFieldType from '../../../../forms/useFieldType';
import Label from '../../../../forms/Label';
import Button from '../../../../elements/Button';
import CopyToClipboard from '../../../../elements/CopyToClipboard';
import { text } from '../../../../../../fields/validations';
import { useFormFields } from '../../../../forms/Form/context';
import './index.scss';
const path = 'apiKey';
const baseClass = 'api-key';
const validate = val => text(val, { minLength: 24, maxLength: 48 });
const validate = (val) => text(val, { minLength: 24, maxLength: 48 });
const APIKey = (props) => {
const {
initialData,
} = props;
const APIKey = () => {
const [initialAPIKey, setInitialAPIKey] = useState(null);
const { getField } = useFormFields();
@@ -36,7 +33,6 @@ const APIKey = (props) => {
const fieldType = useFieldType({
path: 'apiKey',
initialData: initialData || uuidv4(),
validate,
});
@@ -45,6 +41,16 @@ const APIKey = (props) => {
setValue,
} = fieldType;
useEffect(() => {
setInitialAPIKey(uuidv4());
}, []);
useEffect(() => {
if (!apiKeyValue) {
setValue(initialAPIKey);
}
}, [apiKeyValue, setValue, initialAPIKey]);
const classes = [
'field-type',
'api-key',
@@ -52,7 +58,7 @@ const APIKey = (props) => {
].filter(Boolean).join(' ');
return (
<>
<React.Fragment>
<div className={classes}>
<Label
htmlFor={path}
@@ -73,16 +79,8 @@ const APIKey = (props) => {
>
Generate new API Key
</Button>
</>
</React.Fragment>
);
};
APIKey.defaultProps = {
initialData: undefined,
};
APIKey.propTypes = {
initialData: PropTypes.string,
};
export default APIKey;

View File

@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Email from '../Email';
import Password from '../Password';
import Checkbox from '../Checkbox';
import Button from '../../../elements/Button';
import ConfirmPassword from '../ConfirmPassword';
import useFormFields from '../../Form/useFormFields';
import Email from '../../../../forms/field-types/Email';
import Password from '../../../../forms/field-types/Password';
import Checkbox from '../../../../forms/field-types/Checkbox';
import Button from '../../../../elements/Button';
import ConfirmPassword from '../../../../forms/field-types/ConfirmPassword';
import { useFormFields, useFormModified } from '../../../../forms/Form/context';
import APIKey from './APIKey';
import './index.scss';
@@ -13,19 +13,25 @@ import './index.scss';
const baseClass = 'auth-fields';
const Auth = (props) => {
const { initialData, useAPIKey, requirePassword } = props;
const { useAPIKey, requirePassword } = props;
const [changingPassword, setChangingPassword] = useState(requirePassword);
const { getField } = useFormFields();
const modified = useFormModified();
const enableAPIKey = getField('enableAPIKey');
useEffect(() => {
if (!modified) {
setChangingPassword(false);
}
}, [modified]);
return (
<div className={baseClass}>
<Email
required
name="email"
label="Email"
initialData={initialData?.email}
autoComplete="email"
/>
{changingPassword && (
@@ -60,12 +66,11 @@ const Auth = (props) => {
{useAPIKey && (
<div className={`${baseClass}__api-key`}>
<Checkbox
initialData={initialData?.enableAPIKey}
label="Enable API Key"
name="enableAPIKey"
/>
{enableAPIKey?.value && (
<APIKey initialData={initialData?.apiKey} />
<APIKey />
)}
</div>
)}
@@ -74,14 +79,11 @@ const Auth = (props) => {
};
Auth.defaultProps = {
initialData: undefined,
useAPIKey: false,
requirePassword: false,
};
Auth.propTypes = {
fieldTypes: PropTypes.shape({}).isRequired,
initialData: PropTypes.shape({}),
useAPIKey: PropTypes.bool,
requirePassword: PropTypes.bool,
};

View File

@@ -1,5 +1,5 @@
@import '../../../../scss/styles.scss';
@import '../shared.scss';
@import '../../../../../scss/styles.scss';
@import '../../../../forms/field-types/shared.scss';
.auth-fields {
margin: base(1.5) 0 base(2);

View File

@@ -5,6 +5,7 @@ import format from 'date-fns/format';
import config from 'payload/config';
import Eyebrow from '../../../elements/Eyebrow';
import Form from '../../../forms/Form';
import Loading from '../../../elements/Loading';
import PreviewButton from '../../../elements/PreviewButton';
import FormSubmit from '../../../forms/Submit';
import RenderFields from '../../../forms/RenderFields';
@@ -14,10 +15,12 @@ import DeleteDocument from '../../../elements/DeleteDocument';
import * as fieldTypes from '../../../forms/field-types';
import RenderTitle from '../../../elements/RenderTitle';
import LeaveWithoutSaving from '../../../modals/LeaveWithoutSaving';
import Auth from './Auth';
import Upload from './Upload';
import './index.scss';
const { serverURL, routes: { api, admin } } = config;
const { routes: { admin } } = config;
const baseClass = 'collection-edit';
@@ -25,26 +28,30 @@ const DefaultEditView = (props) => {
const { params: { id } = {} } = useRouteMatch();
const {
collection, isEditing, data, onSave, permissions,
collection,
isEditing,
data,
onSave,
permissions,
isLoading,
initialState,
apiURL,
action,
hasSavePermission,
} = props;
const {
slug,
fields,
useAsTitle,
admin: {
useAsTitle,
},
timestamps,
preview,
auth,
upload,
} = collection;
const apiURL = `${serverURL}${api}/${slug}/${id}`;
let action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}`;
const hasSavePermission = (isEditing && permissions?.update?.permission) || (!isEditing && permissions?.update?.permission);
if (auth && !isEditing) {
action = `${action}/register`;
}
const classes = [
baseClass,
isEditing && `${baseClass}--is-editing`,
@@ -58,36 +65,56 @@ const DefaultEditView = (props) => {
action={action}
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
>
<div className={`${baseClass}__main`}>
<Eyebrow />
<LeaveWithoutSaving />
<div className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
</header>
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={field => (!field.position || (field.position && field.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={data}
customComponentsPath={`${slug}.fields.`}
/>
{isLoading && (
<Loading />
)}
{!isLoading && (
<React.Fragment>
<header className={`${baseClass}__header`}>
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
</header>
{auth && (
<Auth
useAPIKey={auth.useAPIKey}
requirePassword={!isEditing}
/>
)}
{upload && (
<Upload
data={data}
{...upload}
fieldTypes={fieldTypes}
/>
)}
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))}
fieldTypes={fieldTypes}
fieldSchema={fields}
customComponentsPath={`${slug}.fields.`}
/>
</React.Fragment>
)}
</div>
</div>
<div className={`${baseClass}__sidebar`}>
{isEditing ? (
<ul className={`${baseClass}__collection-actions`}>
{permissions?.create?.permission && (
<>
<React.Fragment>
<li><Link to={`${admin}/collections/${slug}/create`}>Create New</Link></li>
<li><DuplicateDocument slug={slug} /></li>
</>
</React.Fragment>
)}
{permissions?.delete?.permission && (
<li>
@@ -99,68 +126,73 @@ const DefaultEditView = (props) => {
)}
</ul>
) : undefined}
<div className={`${baseClass}__document-actions${(preview && isEditing) ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
{isEditing && (
<PreviewButton generatePreviewURL={preview} />
)}
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
)}
</div>
{isEditing && (
<div className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</div>
)}
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={field => field.position === 'sidebar'}
position="sidebar"
fieldTypes={fieldTypes}
fieldSchema={fields}
initialData={data}
customComponentsPath={`${slug}.fields.`}
/>
</div>
{isEditing && (
<ul className={`${baseClass}__meta`}>
<li>
<div className={`${baseClass}__label`}>ID</div>
<div>{id}</div>
</li>
{timestamps && (
<>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
{data.createdAt && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(data.createdAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
</>
<div className={`${baseClass}__sidebar-sticky`}>
<div className={`${baseClass}__document-actions${(preview && isEditing) ? ` ${baseClass}__document-actions--with-preview` : ''}`}>
{isEditing && (
<PreviewButton generatePreviewURL={preview} />
)}
{hasSavePermission && (
<FormSubmit>Save</FormSubmit>
)}
</div>
{isEditing && (
<div className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</div>
)}
{!isLoading && (
<React.Fragment>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field?.admin?.position === 'sidebar'}
position="sidebar"
fieldTypes={fieldTypes}
fieldSchema={fields}
customComponentsPath={`${slug}.fields.`}
/>
</div>
{isEditing && (
<ul className={`${baseClass}__meta`}>
<li>
<div className={`${baseClass}__label`}>ID</div>
<div>{id}</div>
</li>
{timestamps && (
<React.Fragment>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
{data.createdAt && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(data.createdAt), 'MMMM do yyyy, h:mma')}</div>
</li>
)}
</React.Fragment>
)}
</ul>
)}
</ul>
)}
</React.Fragment>
)}
</div>
</div>
</Form>
</div>
@@ -169,29 +201,40 @@ const DefaultEditView = (props) => {
DefaultEditView.defaultProps = {
isEditing: false,
isLoading: true,
data: undefined,
onSave: null,
initialState: undefined,
apiURL: undefined,
};
DefaultEditView.propTypes = {
hasSavePermission: PropTypes.bool.isRequired,
action: PropTypes.string.isRequired,
apiURL: PropTypes.string,
isLoading: PropTypes.bool,
collection: PropTypes.shape({
labels: PropTypes.shape({
plural: PropTypes.string,
singular: PropTypes.string,
}),
slug: PropTypes.string,
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,
timestamps: PropTypes.bool,
auth: PropTypes.shape({}),
auth: PropTypes.shape({
useAPIKey: PropTypes.bool,
}),
upload: PropTypes.shape({}),
}).isRequired,
isEditing: PropTypes.bool,
data: PropTypes.shape({
updatedAt: PropTypes.string,
createdAt: PropTypes.string,
}),
onSave: PropTypes.func,
onSave: PropTypes.func.isRequired,
permissions: PropTypes.shape({
create: PropTypes.shape({
permission: PropTypes.bool,
@@ -204,6 +247,7 @@ DefaultEditView.propTypes = {
}),
fields: PropTypes.shape({}),
}).isRequired,
initialState: PropTypes.shape({}),
};
export default DefaultEditView;

View File

@@ -2,10 +2,10 @@ import React, {
useState, useRef, useEffect, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../useFieldType';
import Button from '../../../elements/Button';
import FileDetails from '../../../elements/FileDetails';
import Error from '../../Error';
import useFieldType from '../../../../forms/useFieldType';
import Button from '../../../../elements/Button';
import FileDetails from '../../../../elements/FileDetails';
import Error from '../../../../forms/Error';
import './index.scss';
@@ -34,10 +34,10 @@ const File = (props) => {
const [replacingFile, setReplacingFile] = useState(false);
const {
initialData = {}, adminThumbnail, staticURL,
data = {}, adminThumbnail, staticURL,
} = props;
const { filename } = initialData;
const { filename } = data;
const {
value,
@@ -111,7 +111,7 @@ const File = (props) => {
};
}
return null;
return () => { };
}, [handleDragIn, handleDragOut, handleDrop, dropRef]);
useEffect(() => {
@@ -122,7 +122,7 @@ const File = (props) => {
useEffect(() => {
setReplacingFile(false);
}, [initialData]);
}, [data]);
const classes = [
baseClass,
@@ -138,7 +138,7 @@ const File = (props) => {
/>
{(filename && !replacingFile) && (
<FileDetails
{...initialData}
{...data}
staticURL={staticURL}
adminThumbnail={adminThumbnail}
handleRemove={() => {
@@ -167,7 +167,7 @@ const File = (props) => {
</div>
)}
{!value && (
<>
<React.Fragment>
<div
className={`${baseClass}__drop-zone`}
ref={dropRef}
@@ -181,7 +181,7 @@ const File = (props) => {
</Button>
<span className={`${baseClass}__drag-label`}>or drag and drop a file here</span>
</div>
</>
</React.Fragment>
)}
<input
ref={inputRef}
@@ -195,13 +195,13 @@ const File = (props) => {
};
File.defaultProps = {
initialData: undefined,
data: undefined,
adminThumbnail: undefined,
};
File.propTypes = {
fieldTypes: PropTypes.shape({}).isRequired,
initialData: PropTypes.shape({
data: PropTypes.shape({
filename: PropTypes.string,
mimeType: PropTypes.string,
filesize: PropTypes.number,

View File

@@ -1,4 +1,4 @@
@import '../../../../scss/styles.scss';
@import '../../../../../scss/styles.scss';
.file-field {
position: relative;

View File

@@ -5,21 +5,14 @@ import config from 'payload/config';
import { useStepNav } from '../../../elements/StepNav';
import usePayloadAPI from '../../../../hooks/usePayloadAPI';
import { useUser } from '../../../data/User';
import formatFields from './formatFields';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import DefaultEdit from './Default';
import buildStateFromSchema from '../../../forms/Form/buildStateFromSchema';
const { serverURL, routes: { admin, api } } = config;
const EditView = (props) => {
const { params: { id } = {} } = useRouteMatch();
const { state: locationState } = useLocation();
const history = useHistory();
const { setStepNav } = useStepNav();
const [fields, setFields] = useState([]);
const { permissions } = useUser();
const { collection, isEditing } = props;
const {
@@ -27,9 +20,20 @@ const EditView = (props) => {
labels: {
plural: pluralLabel,
},
useAsTitle,
admin: {
useAsTitle,
},
fields,
auth,
} = collection;
const { params: { id } = {} } = useRouteMatch();
const { state: locationState } = useLocation();
const history = useHistory();
const { setStepNav } = useStepNav();
const [initialState, setInitialState] = useState({});
const { permissions } = useUser();
const onSave = (json) => {
history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`, {
status: {
@@ -40,9 +44,9 @@ const EditView = (props) => {
});
};
const [{ data }] = usePayloadAPI(
const [{ data, isLoading }] = usePayloadAPI(
(isEditing ? `${serverURL}${api}/${slug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null' } },
{ initialParams: { 'fallback-locale': 'null', depth: 0 } },
);
const dataToRender = locationState?.data || data;
@@ -67,21 +71,39 @@ const EditView = (props) => {
}, [setStepNav, isEditing, pluralLabel, dataToRender, slug, useAsTitle]);
useEffect(() => {
setFields(formatFields(collection, isEditing));
}, [collection, isEditing]);
const awaitInitialState = async () => {
const state = await buildStateFromSchema(fields, dataToRender);
setInitialState(state);
};
awaitInitialState();
}, [dataToRender, fields]);
const collectionPermissions = permissions?.[slug];
const apiURL = `${serverURL}${api}/${slug}/${id}`;
let action = `${serverURL}${api}/${slug}${isEditing ? `/${id}` : ''}?depth=0`;
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
if (auth && !isEditing) {
action = `${action}/register`;
}
return (
<RenderCustomComponent
DefaultComponent={DefaultEdit}
path={`${slug}.views.Edit`}
componentProps={{
isLoading,
data: dataToRender,
collection: { ...collection, fields },
collection,
permissions: collectionPermissions,
isEditing,
onSave,
initialState,
hasSavePermission,
apiURL,
action,
}}
/>
);
@@ -98,9 +120,12 @@ EditView.propTypes = {
singular: PropTypes.string,
}),
slug: PropTypes.string,
useAsTitle: PropTypes.string,
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
fields: PropTypes.arrayOf(PropTypes.shape({})),
preview: PropTypes.func,
auth: PropTypes.shape({}),
}).isRequired,
isEditing: PropTypes.bool,
};

View File

@@ -96,6 +96,10 @@
}
}
&__sidebar-fields {
padding-right: $baseline;
}
&__meta {
margin: 0;
padding-top: $baseline;

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