renames client to admin, sets up component library

This commit is contained in:
James
2020-10-10 18:28:17 -04:00
parent e88be6b251
commit 84191ec8fd
397 changed files with 2042 additions and 579 deletions

View File

@@ -0,0 +1,232 @@
import React, { Suspense, lazy, useState, useEffect } from 'react';
import {
Route, Switch, withRouter, Redirect, useHistory,
} from 'react-router-dom';
import { useConfig } from './providers/Config';
import List from './views/collections/List';
import { useAuthentication } from './providers/Authentication';
import DefaultTemplate from './templates/Default';
import { requests } from '../api';
import Loading from './elements/Loading';
const Dashboard = lazy(() => import('./views/Dashboard'));
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
const Login = lazy(() => import('./views/Login'));
const Logout = lazy(() => import('./views/Logout'));
const NotFound = lazy(() => import('./views/NotFound'));
const Verify = lazy(() => import('./views/Verify'));
const CreateFirstUser = lazy(() => import('./views/CreateFirstUser'));
const Edit = lazy(() => import('./views/collections/Edit'));
const EditGlobal = lazy(() => import('./views/Global'));
const ResetPassword = lazy(() => import('./views/ResetPassword'));
const Unauthorized = lazy(() => import('./views/Unauthorized'));
const Account = lazy(() => import('./views/Account'));
const Routes = () => {
const history = useHistory();
const [initialized, setInitialized] = useState(null);
const { user, permissions, permissions: { canAccessAdmin } } = useAuthentication();
const {
admin: { user: userSlug }, routes, collections, globals,
} = useConfig();
useEffect(() => {
requests.get(`${routes.api}/${userSlug}/init`).then((res) => res.json().then((data) => {
if (data && 'initialized' in data) {
setInitialized(data.initialized);
}
}));
}, [routes, userSlug]);
useEffect(() => {
history.replace();
}, [history]);
return (
<Suspense fallback={<Loading />}>
<Route
path={routes.admin}
render={({ match }) => {
if (initialized === false) {
return (
<Switch>
<Route path={`${match.url}/create-first-user`}>
<CreateFirstUser setInitialized={setInitialized} />
</Route>
<Route>
<Redirect to={`${match.url}/create-first-user`} />
</Route>
</Switch>
);
}
if (initialized === true) {
return (
<Switch>
<Route path={`${match.url}/login`}>
<Login />
</Route>
<Route path={`${match.url}/logout`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Logout inactivity />
</Route>
<Route path={`${match.url}/forgot`}>
<ForgotPassword />
</Route>
<Route path={`${match.url}/reset/:token`}>
<ResetPassword />
</Route>
{collections.map((collection) => {
if (collection?.auth?.emailVerification) {
return (
<Route
key={`${collection.slug}-verify`}
path={`${match.url}/${collection.slug}/verify/:token`}
exact
>
<Verify />
</Route>
);
}
return null;
})}
<Route
render={() => {
if (user) {
if (canAccessAdmin) {
return (
<DefaultTemplate>
<Switch>
<Route
path={`${match.url}/`}
exact
>
<Dashboard />
</Route>
<Route path={`${match.url}/account`}>
<Account />
</Route>
{collections.map((collection) => (
<Route
key={`${collection.slug}-list`}
path={`${match.url}/collections/${collection.slug}`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.read?.permission) {
return (
<List
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{collections.map((collection) => (
<Route
key={`${collection.slug}-create`}
path={`${match.url}/collections/${collection.slug}/create`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.create?.permission) {
return (
<Edit
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{collections.map((collection) => (
<Route
key={`${collection.slug}-edit`}
path={`${match.url}/collections/${collection.slug}/:id`}
exact
render={(routeProps) => {
if (permissions?.[collection.slug]?.read?.permission) {
return (
<Edit
isEditing
{...routeProps}
collection={collection}
/>
);
}
return <Unauthorized />;
}}
/>
))}
{globals && globals.map((global) => (
<Route
key={`${global.slug}`}
path={`${match.url}/globals/${global.slug}`}
exact
render={(routeProps) => {
if (permissions?.[global.slug]?.read?.permission) {
return (
<EditGlobal
{...routeProps}
global={global}
/>
);
}
return <Unauthorized />;
}}
/>
))}
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
</DefaultTemplate>
);
}
if (canAccessAdmin === false) {
return <Unauthorized />;
}
return <Loading />;
}
if (user === undefined) {
return <Loading />;
}
return <Redirect to={`${match.url}/login`} />;
}}
/>
<Route path={`${match.url}*`}>
<NotFound />
</Route>
</Switch>
);
}
return null;
}}
/>
</Suspense>
);
};
export default withRouter(Routes);

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import './index.scss';
const baseClass = 'banner';
const Banner = ({
children, className, to, icon, alignIcon, onClick, type,
}) => {
const classes = [
baseClass,
`${baseClass}--type-${type}`,
className && className,
to && `${baseClass}--has-link`,
(to || onClick) && `${baseClass}--has-action`,
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
].filter(Boolean).join(' ');
let RenderedType = 'div';
if (onClick && !to) RenderedType = 'button';
if (to) RenderedType = Link;
return (
<RenderedType
className={classes}
onClick={onClick}
type={RenderedType === 'button' ? 'button' : undefined}
to={to || undefined}
>
{(icon && alignIcon === 'left') && (
<React.Fragment>
{icon}
</React.Fragment>
)}
{children}
{(icon && alignIcon === 'right') && (
<React.Fragment>
{icon}
</React.Fragment>
)}
</RenderedType>
);
};
Banner.defaultProps = {
children: undefined,
className: '',
to: undefined,
icon: undefined,
alignIcon: 'right',
onClick: undefined,
type: 'default',
};
Banner.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
icon: PropTypes.node,
alignIcon: PropTypes.oneOf(['left', 'right']),
onClick: PropTypes.func,
to: PropTypes.string,
type: PropTypes.oneOf(['error', 'success', 'info', 'default']),
};
export default Banner;

View File

@@ -0,0 +1,74 @@
@import '../../../scss/styles.scss';
.banner {
font-size: 1rem;
line-height: base(1);
border: 0;
vertical-align: middle;
background: rgba($color-dark-gray, .1);
color: $color-dark-gray;
border-radius: $style-radius-s;
padding: base(.5);
margin-bottom: $baseline;
&--has-action {
cursor: pointer;
text-decoration: none;
}
&--has-icon {
svg {
display: block;
}
}
&--align-icon-left {
padding-left: base(.125);
}
&--align-icon-right {
padding-right: base(.125);
}
&--type-default {
&.button--has-action {
&:hover {
background: darken($color-dark-gray, .15);
}
&:active {
background: darken($color-dark-gray, .2);
}
}
}
&--type-error {
background: rgba($color-red, .1);
color: $color-red;
&.button--has-action {
&:hover {
background: rgba($color-red, .15);
}
&:active {
background: rgba($color-red, .2);
}
}
}
&--type-success {
background: rgba($color-green, .1);
color: darken($color-green, 20%);
&.button--has-action {
&:hover {
background: rgba($color-green, .15);
}
&:active {
background: rgba($color-green, .2);
}
}
}
}

View File

@@ -0,0 +1,171 @@
import React, { isValidElement } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import plus from '../../icons/Plus';
import x from '../../icons/X';
import chevron from '../../icons/Chevron';
import './index.scss';
const icons = {
plus,
x,
chevron,
};
const baseClass = 'btn';
const ButtonContents = ({ children, icon }) => {
const BuiltInIcon = icons[icon];
return (
<span className={`${baseClass}__content`}>
{children && (
<span className={`${baseClass}__label`}>
{children}
</span>
)}
{icon && (
<span className={`${baseClass}__icon`}>
{isValidElement(icon) && icon}
{BuiltInIcon && <BuiltInIcon />}
</span>
)}
</span>
);
};
ButtonContents.defaultProps = {
icon: null,
children: null,
};
ButtonContents.propTypes = {
children: PropTypes.node,
icon: PropTypes.node,
};
const Button = (props) => {
const {
className,
type,
el,
to,
url,
children,
onClick,
disabled,
icon,
iconStyle,
buttonStyle,
round,
size,
iconPosition,
} = props;
const classes = [
baseClass,
className && className,
buttonStyle && `${baseClass}--style-${buttonStyle}`,
icon && `${baseClass}--icon`,
iconStyle && `${baseClass}--icon-style-${iconStyle}`,
(icon && !children) && `${baseClass}--icon-only`,
disabled && `${baseClass}--disabled`,
round && `${baseClass}--round`,
size && `${baseClass}--size-${size}`,
iconPosition && `${baseClass}--icon-position-${iconPosition}`,
].filter(Boolean).join(' ');
function handleClick(event) {
if (type !== 'submit' && onClick) event.preventDefault();
if (onClick) onClick(event);
}
const buttonProps = {
type,
className: classes,
onClick: handleClick,
};
switch (el) {
case 'link':
return (
<Link
{...buttonProps}
to={to || url}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</Link>
);
case 'anchor':
return (
<a
{...buttonProps}
href={url}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</a>
);
default:
return (
<button
type="submit"
{...buttonProps}
>
<ButtonContents icon={icon}>
{children}
</ButtonContents>
</button>
);
}
};
Button.defaultProps = {
className: null,
type: 'button',
buttonStyle: 'primary',
el: null,
to: null,
url: null,
children: null,
onClick: null,
disabled: undefined,
icon: null,
size: 'medium',
round: false,
iconPosition: 'right',
iconStyle: 'without-border',
};
Button.propTypes = {
round: PropTypes.bool,
className: PropTypes.string,
type: PropTypes.oneOf(['submit', 'button']),
size: PropTypes.oneOf(['small', 'medium']),
buttonStyle: PropTypes.oneOf(['primary', 'secondary', 'transparent', 'error', 'none', 'icon-label']),
el: PropTypes.oneOf(['link', 'anchor', undefined]),
to: PropTypes.string,
url: PropTypes.string,
children: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
iconStyle: PropTypes.oneOf([
'with-border',
'without-border',
'none',
]),
icon: PropTypes.oneOfType([
PropTypes.node,
PropTypes.oneOf(['chevron', 'x', 'plus']),
]),
iconPosition: PropTypes.oneOf(['left', 'right']),
};
export default Button;

View File

@@ -0,0 +1,177 @@
@import '../../../scss/styles.scss';
.btn {
background: transparent;
line-height: base(1);
border-radius: $style-radius-m;
font-size: 1rem;
margin-top: base(1);
margin-bottom: base(1);
border: 0;
cursor: pointer;
font-weight: normal;
text-decoration: none;
text-align: center;
color: inherit;
.btn__icon {
border: 1px solid;
border-radius: 100%;
@include color-svg(currentColor);
}
&--icon-style-without-border {
.btn__icon {
border: none;
}
}
&--icon-style-none {
.btn__icon {
border: none;
}
}
span {
line-height: base(1);
}
span, svg {
vertical-align: top;
}
&--size-medium {
padding: base(.5) $baseline;
}
&--size-small {
padding: base(.25) base(.5);
}
&--style-primary {
background-color: $color-dark-gray;
color: white;
&:hover {
background: lighten($color-dark-gray, 5%);
}
&:focus {
box-shadow: $focus-box-shadow;
outline: none;
}
&:active {
background: lighten($color-dark-gray, 10%);
}
}
&--style-secondary {
$base-box-shadow: inset 0 0 0 $style-stroke-width $color-dark-gray;
$hover-box-shadow: inset 0 0 0 $style-stroke-width lighten($color-dark-gray, 5%);
box-shadow: $base-box-shadow;
color: $color-dark-gray;
background: none;
&:hover {
background: rgba($color-dark-gray, .02);
box-shadow: $hover-box-shadow;
}
&:focus {
outline: none;
box-shadow: $hover-box-shadow, $focus-box-shadow;
}
&:active {
background: lighten($color-light-gray, 7%);
}
}
&--style-none {
padding: 0;
margin: 0;
border-radius: 0;
&:focus {
opacity: .8;
}
&:active {
opacity: .7;
}
}
&--round {
border-radius: 100%;
}
&--icon {
span {
display: flex;
justify-content: space-between;
}
&.btn--style-primary {
.icon {
@include color-svg(white);
}
}
}
&--style-icon-label {
padding: 0;
font-weight: 600;
}
&--style-light-gray {
box-shadow: inset 0 0 0 $style-stroke-width $color-dark-gray;
}
&--icon-position-left {
.btn__content {
flex-direction: row-reverse;
}
.btn__icon {
margin-right: base(.5);
}
}
&--icon-position-right {
.btn__icon {
margin-left: base(.5);
}
}
&--icon-only {
.btn__icon {
padding: 0;
margin: 0;
}
}
&:hover {
.btn__icon {
@include color-svg(white);
background: $color-dark-gray;
}
}
&:focus {
.btn__icon {
@include color-svg($color-dark-gray);
background: $color-light-gray;
}
outline: none;
}
&:active {
.btn__icon {
@include color-svg(white);
background: lighten($color-dark-gray, 10%);
}
}
}

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

@@ -0,0 +1,42 @@
const getInitialColumnState = (fields, useAsTitle, defaultColumns) => {
let initialColumns = [];
if (Array.isArray(defaultColumns) && defaultColumns.length >= 1) {
return {
columns: defaultColumns,
};
}
if (useAsTitle) {
initialColumns.push(useAsTitle);
}
const remainingColumns = fields.reduce((remaining, field) => {
if (field.name === useAsTitle) {
return remaining;
}
if (!field.name && Array.isArray(field.fields)) {
return [
...remaining,
...field.fields.map((subField) => subField.name),
];
}
return [
...remaining,
field.name,
];
}, []);
initialColumns = initialColumns.concat(remainingColumns);
initialColumns = initialColumns.slice(0, 4);
return {
columns: initialColumns,
};
};
module.exports = getInitialColumnState;

View File

@@ -0,0 +1,95 @@
import React, { useState, useEffect, useReducer } from 'react';
import PropTypes from 'prop-types';
import getInitialState from './getInitialState';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import Pill from '../Pill';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
import './index.scss';
const baseClass = 'column-selector';
const reducer = (state, { type, payload }) => {
if (type === 'enable') {
return [
...state,
payload,
];
}
if (type === 'replace') {
return [
...payload,
];
}
return state.filter((remainingColumn) => remainingColumn !== payload);
};
const ColumnSelector = (props) => {
const {
collection,
collection: {
admin: {
useAsTitle,
defaultColumns,
},
},
handleChange,
} = props;
const [initialColumns, setInitialColumns] = useState([]);
const [fields] = useState(() => flattenTopLevelFields(collection.fields));
const [columns, dispatchColumns] = useReducer(reducer, initialColumns);
useEffect(() => {
if (typeof handleChange === 'function') handleChange(columns);
}, [columns, handleChange]);
useEffect(() => {
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
setInitialColumns(initializedColumns);
}, [fields, useAsTitle, defaultColumns]);
useEffect(() => {
dispatchColumns({ payload: initialColumns, type: 'replace' });
}, [initialColumns]);
return (
<div className={baseClass}>
{fields && fields.map((field, i) => {
const isEnabled = columns.find((column) => column === field.name);
return (
<Pill
onClick={() => dispatchColumns({ payload: field.name, type: isEnabled ? 'disable' : 'enable' })}
alignIcon="left"
key={field.name || i}
icon={isEnabled ? <X /> : <Plus />}
pillStyle={isEnabled ? 'dark' : undefined}
className={`${baseClass}__active-column`}
>
{field.label}
</Pill>
);
})}
</div>
);
};
ColumnSelector.propTypes = {
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
admin: PropTypes.shape({
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
useAsTitle: PropTypes.string,
}),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};
export default ColumnSelector;

View File

@@ -0,0 +1,11 @@
@import '../../../scss/styles.scss';
.column-selector {
background: $color-background-gray;
padding: base(1) base(1) base(.5);
.pill {
margin-right: base(.5);
margin-bottom: base(.5);
}
}

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import Copy from '../../icons/Copy';
import Tooltip from '../Tooltip';
import './index.scss';
const baseClass = 'copy-to-clipboard';
const CopyToClipboard = ({ value, defaultMessage, successMessage }) => {
const ref = useRef(null);
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
useEffect(() => {
if (copied && !hovered) {
setTimeout(() => {
setCopied(false);
}, 1500);
}
}, [copied, hovered]);
if (value) {
return (
<button
onMouseEnter={() => {
setHovered(true);
setCopied(false);
}}
onMouseLeave={() => {
setHovered(false);
setCopied(false);
}}
type="button"
className={baseClass}
onClick={() => {
if (ref && ref.current) {
ref.current.select();
ref.current.setSelectionRange(0, value.length + 1);
document.execCommand('copy');
setCopied(true);
}
}}
>
<Copy />
<Tooltip>
{copied && successMessage}
{!copied && defaultMessage}
</Tooltip>
<textarea
readOnly
value={value}
ref={ref}
/>
</button>
);
}
return null;
};
CopyToClipboard.defaultProps = {
value: '',
defaultMessage: 'Copy',
successMessage: 'Copied',
};
CopyToClipboard.propTypes = {
value: PropTypes.string,
defaultMessage: PropTypes.string,
successMessage: PropTypes.string,
};
export default CopyToClipboard;

View File

@@ -0,0 +1,35 @@
@import '../../../scss/styles.scss';
.copy-to-clipboard {
@extend %btn-reset;
position: relative;
cursor: pointer;
vertical-align: middle;
textarea {
position: absolute;
opacity: 0;
z-index: -1;
height: 0px;
width: 0px;
}
.tooltip {
pointer-events: none;
opacity: 0;
visibility: hidden;
}
&:focus,
&:active {
outline: none;
}
&:hover {
.tooltip {
opacity: 1;
visibility: visible;
}
}
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import PropTypes from 'prop-types';
import DatePicker from 'react-datepicker';
import CalendarIcon from '../../icons/Calendar';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
const baseClass = 'date-time-picker';
const DateTime = (props) => {
const {
inputDateTimeFormat,
useDate,
minDate,
maxDate,
monthsShown,
useTime,
minTime,
maxTime,
timeIntervals,
timeFormat,
placeholder: placeholderText,
value,
onChange,
admin: {
readOnly,
} = {},
} = props;
let dateTimeFormat = inputDateTimeFormat;
if (!dateTimeFormat) {
if (useTime && useDate) dateTimeFormat = 'MMM d, yyy h:mma';
else if (useTime) dateTimeFormat = 'h:mma';
else dateTimeFormat = 'MMM d, yyy';
}
const dateTimePickerProps = {
minDate,
maxDate,
dateFormat: dateTimeFormat,
monthsShown: Math.min(2, monthsShown),
showTimeSelect: useTime,
minTime,
maxTime,
timeIntervals,
timeFormat,
placeholderText,
disabled: readOnly,
onChange,
showPopperArrow: false,
selected: value && new Date(value),
customInputRef: 'ref',
};
const classes = [
baseClass,
!useDate && `${baseClass}--hide-dates`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div className={`${baseClass}__input-wrapper`}>
<DatePicker {...dateTimePickerProps} />
<CalendarIcon />
</div>
</div>
);
};
DateTime.defaultProps = {
placeholder: undefined,
// date specific props
useDate: true,
minDate: undefined,
maxDate: undefined,
monthsShown: 1,
inputDateTimeFormat: '',
// time specific props
useTime: true,
minTime: undefined,
maxTime: undefined,
timeIntervals: 30,
timeFormat: 'h:mm aa',
value: undefined,
onChange: undefined,
admin: {},
};
DateTime.propTypes = {
placeholder: PropTypes.string,
// date specific props
useDate: PropTypes.bool,
minDate: PropTypes.instanceOf(Date),
maxDate: PropTypes.instanceOf(Date),
monthsShown: PropTypes.number,
inputDateTimeFormat: PropTypes.string,
// time specific props
useTime: PropTypes.bool,
minTime: PropTypes.instanceOf(Date),
maxTime: PropTypes.instanceOf(Date),
timeIntervals: PropTypes.number,
timeFormat: PropTypes.string,
value: PropTypes.instanceOf(Date),
onChange: PropTypes.func,
admin: PropTypes.shape({
readOnly: PropTypes.bool,
}),
};
export default DateTime;

View File

@@ -0,0 +1,5 @@
Available props per React DatePicker
- [see here](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md)
Date FNS Information
- [popular mistakes](https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md)

View File

@@ -0,0 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../Loading';
const DatePicker = lazy(() => import('./DatePicker'));
export default (props) => (
<Suspense fallback={<Loading />}>
<DatePicker {...props} />
</Suspense>
);

View File

@@ -0,0 +1,234 @@
@import '../../../scss/styles';
$cal-icon-width: 18px;
.date-time-picker {
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box,
.react-datepicker__time-container {
width: 120px;
}
&--hide-dates {
.react-datepicker {
width: 100%;
&__month-container,
&__navigation--previous,
&__navigation--next {
display: none;
visibility: hidden;
}
&-popper,
&__time-container,
&__time-box {
width: 100%;
}
&__time-container {
.react-datepicker__time {
.react-datepicker__time-box {
width: 100%;
}
}
}
}
}
&__input-wrapper {
position: relative;
.icon {
pointer-events: none;
position: absolute;
right: base(.75);
top: 50%;
transform: translateY(-50%);
width: $cal-icon-width;
height: auto;
@include color-svg($color-dark-gray);
}
}
.react-datepicker-wrapper {
display: block;
}
.react-datepicker-wrapper,
.react-datepicker__input-container {
width: 100%;
}
.react-datepicker__input-container input {
@include formInput;
padding-right: calc(#{base(.75)} + #{$cal-icon-width});
}
&--has-error {
.react-datepicker__input-container input {
background-color: lighten($color-red, 20%);
}
}
.react-datepicker {
@include shadow-sm;
background: white;
display: inline-flex;
border: 1px solid $color-light-gray;
font-family: $font-body;
font-weight: 100;
border-radius: 0;
border: none;
&__header {
padding-top: 0;
text-transform: none;
text-align: center;
border-radius: 0;
border: none;
background-color: white;
&--time {
padding: 10px 0;
border-bottom: 1px solid $color-light-gray;
font-weight: 600;
}
}
&__navigation {
background: none;
line-height: 1.7rem;
text-align: center;
cursor: pointer;
position: absolute;
top: 10px;
width: 0;
padding: 0;
border: 0.45rem solid transparent;
z-index: 1;
height: 10px;
width: 10px;
text-indent: -999em;
overflow: hidden;
top: 15px;
&--next {
border-left-color: $color-gray;
&:focus {
border-left-color: darken($color-gray, 30%);
outline:none;
}
}
&--previous {
border-right-color: $color-gray;
&:focus {
border-right-color: darken($color-gray, 30%);
outline:none;
}
}
}
&__current-month {
padding: 10px 0;
font-weight: 600;
}
&__month-container {
border-right: 1px solid $color-light-gray;
}
&__time-container {
border-left: none;
}
&__day-names {
background-color: $color-light-gray;
}
&__day {
box-shadow: inset 0px 0px 0px 1px $color-light-gray, 0px 0px 0px 1px $color-light-gray;
font-size: base(.55);
&:focus {
outline: 0;
background: $color-gray;
}
&--selected {
font-weight: 600;
&:focus {
background-color: $color-light-gray;
}
}
&--keyboard-selected {
color: white;
font-weight: 600;
&:focus {
background-color: $color-light-gray;
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
}
}
&--today {
font-weight: 600;
}
}
&__day,
&__day-name {
width: base(1.5);
margin: base(.15);
line-height: base(1.25);
}
}
.react-datepicker-popper {
z-index: 10;
border: 1px solid $color-light-gray;
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected {
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
background-color: $color-light-gray;
color: $color-dark-gray;
border-radius: 0;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected,
.react-datepicker__day--selected, .react-datepicker__day--in-selecting-range, .react-datepicker__day--in-range, .react-datepicker__month-text--selected, .react-datepicker__month-text--in-selecting-range, .react-datepicker__month-text--in-range {
box-shadow: inset 0px 0px 0px 1px $color-dark-gray, 0px 0px 0px 1px $color-dark-gray;
background-color: $color-light-gray;
color: $color-dark-gray;
border-radius: 0;
}
.react-datepicker__day:hover, .react-datepicker__month-text:hover {
border-radius: 0;
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 130px;
}
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item {
line-height: 20px;
font-size: base(.5);
font-weight: 500;
}
@include small-break {
&__input-wrapper {
.icon {
top: calc(50% - #{base(.25)});
}
}
}
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { Modal, useModal } from '@faceless-ui/modal';
import { useConfig } from '../../providers/Config';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import { useForm } from '../../forms/Form/context';
import useTitle from '../../../hooks/useTitle';
import { requests } from '../../../api';
import { useStatusList } from '../Status';
import './index.scss';
const baseClass = 'delete-document';
const DeleteDocument = (props) => {
const {
title: titleFromProps,
id,
collection: {
admin: {
useAsTitle,
},
slug,
labels: {
singular,
} = {},
} = {},
} = props;
const { serverURL, routes: { api, admin } } = useConfig();
const { replaceStatus } = useStatusList();
const { setModified } = useForm();
const [deleting, setDeleting] = useState(false);
const { closeAll, toggle } = useModal();
const history = useHistory();
const title = useTitle(useAsTitle) || id;
const titleToRender = titleFromProps || title;
const modalSlug = `delete-${id}`;
const addDefaultError = useCallback(() => {
replaceStatus([{
message: `There was an error while deleting ${title}. Please check your connection and try again.`,
type: 'error',
}]);
}, [replaceStatus, title]);
const handleDelete = useCallback(() => {
setDeleting(true);
setModified(false);
requests.delete(`${serverURL}${api}/${slug}/${id}`, {
headers: {
'Content-Type': 'application/json',
},
}).then(async (res) => {
try {
const json = await res.json();
if (res.status < 400) {
closeAll();
return history.push({
pathname: `${admin}/collections/${slug}`,
state: {
status: {
message: `${singular} "${title}" successfully deleted.`,
},
},
});
}
closeAll();
if (json.errors) {
replaceStatus(json.errors);
}
addDefaultError();
return false;
} catch (e) {
return addDefaultError();
}
});
}, [addDefaultError, closeAll, history, id, replaceStatus, singular, slug, title, admin, api, serverURL, setModified]);
if (id) {
return (
<React.Fragment>
<button
type="button"
slug={modalSlug}
className={`${baseClass}__toggle`}
onClick={(e) => {
e.preventDefault();
toggle(modalSlug);
}}
>
Delete
</button>
<Modal
slug={modalSlug}
className={baseClass}
>
<MinimalTemplate>
<h1>Confirm deletion</h1>
<p>
You are about to delete the
{' '}
{singular}
{' '}
&quot;
<strong>
{titleToRender}
</strong>
&quot;. Are you sure?
</p>
<Button
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggle(modalSlug)}
>
Cancel
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
>
{deleting ? 'Deleting...' : 'Confirm'}
</Button>
</MinimalTemplate>
</Modal>
</React.Fragment>
);
}
return null;
};
DeleteDocument.defaultProps = {
title: undefined,
id: undefined,
};
DeleteDocument.propTypes = {
collection: PropTypes.shape({
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
}),
slug: PropTypes.string,
labels: PropTypes.shape({
singular: PropTypes.string,
}),
}).isRequired,
id: PropTypes.string,
title: PropTypes.string,
};
export default DeleteDocument;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles.scss';
.delete-document {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
&__toggle {
@extend %btn-reset;
}
.btn {
margin-right: $baseline;
}
}

View File

@@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useConfig } from '../../providers/Config';
import Button from '../Button';
import { useForm } from '../../forms/Form/context';
import './index.scss';
const baseClass = 'duplicate';
const Duplicate = ({ slug }) => {
const { push } = useHistory();
const { getData } = useForm();
const { routes: { admin } } = useConfig();
const handleClick = useCallback(() => {
const data = getData();
push({
pathname: `${admin}/collections/${slug}/create`,
state: {
data,
},
});
}, [push, getData, slug, admin]);
return (
<Button
buttonStyle="none"
className={baseClass}
onClick={handleClick}
>
Duplicate
</Button>
);
};
Duplicate.propTypes = {
slug: PropTypes.string.isRequired,
};
export default Duplicate;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import StepNav from '../StepNav';
import './index.scss';
const baseClass = 'eyebrow';
const Eyebrow = ({ actions }) => {
return (
<div className={baseClass}>
<StepNav />
{actions}
</div>
);
};
Eyebrow.defaultProps = {
actions: null,
};
Eyebrow.propTypes = {
actions: PropTypes.node,
};
export default Eyebrow;

View File

@@ -0,0 +1,19 @@
@import '../../../scss/styles.scss';
.eyebrow {
@include blur-bg;
position: sticky;
top: 0;
z-index: $z-nav;
padding: base(1.5) 0;
display: flex;
align-items: center;
justify-content: space-between;
@include mid-break {
padding: 0 0 $baseline;
margin: 0;
position: static;
display: block;
}
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '../../../providers/Config';
import CopyToClipboard from '../../CopyToClipboard';
import formatFilesize from '../../../../../uploads/formatFilesize';
import './index.scss';
const baseClass = 'file-meta';
const Meta = (props) => {
const {
filename, filesize, width, height, mimeType, staticURL,
} = props;
const { serverURL } = useConfig();
const fileURL = `${serverURL}${staticURL}/${filename}`;
return (
<div className={baseClass}>
<div className={`${baseClass}__url`}>
<a
href={fileURL}
target="_blank"
rel="noopener noreferrer"
>
{filename}
</a>
<CopyToClipboard
value={fileURL}
defaultMessage="Copy URL"
/>
</div>
<div className={`${baseClass}__size-type`}>
{formatFilesize(filesize)}
{(width && height) && (
<React.Fragment>
&nbsp;-&nbsp;
{width}
x
{height}
</React.Fragment>
)}
{mimeType && (
<React.Fragment>
&nbsp;-&nbsp;
{mimeType}
</React.Fragment>
)}
</div>
</div>
);
};
Meta.defaultProps = {
width: undefined,
height: undefined,
sizes: undefined,
};
Meta.propTypes = {
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
filesize: PropTypes.number.isRequired,
staticURL: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sizes: PropTypes.shape({}),
};
export default Meta;

View File

@@ -0,0 +1,23 @@
@import '../../../../scss/styles.scss';
.file-meta {
&__url {
display: flex;
a {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__size-type,
&__url a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import Thumbnail from '../Thumbnail';
import Button from '../Button';
import Meta from './Meta';
import Chevron from '../../icons/Chevron';
import './index.scss';
const baseClass = 'file-details';
const FileDetails = (props) => {
const {
filename, mimeType, filesize, staticURL, adminThumbnail, sizes, handleRemove, width, height,
} = props;
const [moreInfoOpen, setMoreInfoOpen] = useState(false);
const hasSizes = sizes && Object.keys(sizes)?.length > 0;
return (
<div className={baseClass}>
<header>
<Thumbnail {...{
mimeType, adminThumbnail, sizes, staticURL, filename,
}}
/>
<div className={`${baseClass}__main-detail`}>
<Meta
staticURL={staticURL}
filename={filename}
filesize={filesize}
width={width}
height={height}
mimeType={mimeType}
/>
{hasSizes && (
<Button
className={`${baseClass}__toggle-more-info${moreInfoOpen ? ' open' : ''}`}
buttonStyle="none"
onClick={() => setMoreInfoOpen(!moreInfoOpen)}
>
{!moreInfoOpen && (
<React.Fragment>
More info
<Chevron />
</React.Fragment>
)}
{moreInfoOpen && (
<React.Fragment>
Less info
<Chevron />
</React.Fragment>
)}
</Button>
)}
</div>
{handleRemove && (
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={handleRemove}
className={`${baseClass}__remove`}
/>
)}
</header>
{hasSizes && (
<AnimateHeight
className={`${baseClass}__more-info`}
height={moreInfoOpen ? 'auto' : 0}
>
<ul className={`${baseClass}__sizes`}>
{Object.entries(sizes).map(([key, val]) => (
<li key={key}>
<div className={`${baseClass}__size-label`}>
{key}
</div>
<Meta
{...val}
mimeType={mimeType}
staticURL={staticURL}
/>
</li>
))}
</ul>
</AnimateHeight>
)}
</div>
);
};
FileDetails.defaultProps = {
adminThumbnail: undefined,
handleRemove: undefined,
width: undefined,
height: undefined,
sizes: undefined,
};
FileDetails.propTypes = {
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
filesize: PropTypes.number.isRequired,
staticURL: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sizes: PropTypes.shape({}),
adminThumbnail: PropTypes.string,
handleRemove: PropTypes.func,
};
export default FileDetails;

View File

@@ -0,0 +1,97 @@
@import '../../../scss/styles.scss';
.file-details {
background-color: $color-background-gray;
header {
display: flex;
align-items: flex-start;
border-bottom: $style-stroke-width-m solid white;
}
&__remove {
margin: $baseline $baseline $baseline 0;
}
&__main-detail {
padding: $baseline base(1.5);
width: auto;
flex-grow: 1;
min-width: 0;
align-self: center;
}
&__toggle-more-info {
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__toggle-more-info.open {
.icon--chevron {
transform: rotate(180deg);
}
}
&__sizes {
margin: 0;
padding: base(1.5) $baseline 0;
list-style: none;
display: flex;
flex-wrap: wrap;
li {
width: 50%;
padding: 0 base(.5);
margin-bottom: $baseline;
}
}
&__size-label {
color: $color-gray;
}
@include large-break {
&__main-detail {
padding: $baseline;
}
&__sizes {
display: block;
padding: $baseline $baseline base(.5);
li {
padding: 0;
width: 100%;
}
}
}
@include mid-break {
header {
flex-wrap: wrap;
}
.thumbnail {
width: 50%;
order: 1;
}
&__remove {
order: 2;
margin-left: auto;
margin-right: $baseline;
}
&__main-detail {
border-top: $style-stroke-width-m solid white;
order: 3;
width: 100%;
}
}
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Modal, useModal } from '@faceless-ui/modal';
import Button from '../Button';
import MinimalTemplate from '../../templates/Minimal';
import { useStatusList } from '../Status';
import './index.scss';
const baseClass = 'generate-confirmation';
const GenerateConfirmation = (props) => {
const {
setKey,
highlightField,
} = props;
const { toggle } = useModal();
const { replaceStatus } = useStatusList();
const modalSlug = 'generate-confirmation';
const handleGenerate = () => {
setKey();
toggle(modalSlug);
replaceStatus([{
message: 'New API Key Generated.',
type: 'success',
}]);
highlightField(true);
};
return (
<React.Fragment>
<Button
size="small"
buttonStyle="secondary"
slug={modalSlug}
onClick={() => {
toggle(modalSlug);
}}
>
Generate new API key
</Button>
<Modal
slug={modalSlug}
className={baseClass}
>
<MinimalTemplate>
<h1>Confirm Generation</h1>
<p>
Generating a new API key will
{' '}
<strong>invalidate</strong>
{' '}
the previous key.
{' '}
Are you sure you wish to continue?
</p>
<Button
buttonStyle="secondary"
type="button"
onClick={() => {
toggle(modalSlug);
}}
>
Cancel
</Button>
<Button
onClick={handleGenerate}
>
Generate
</Button>
</MinimalTemplate>
</Modal>
</React.Fragment>
);
};
GenerateConfirmation.propTypes = {
setKey: PropTypes.func.isRequired,
highlightField: PropTypes.func.isRequired,
};
export default GenerateConfirmation;

View File

@@ -0,0 +1,12 @@
@import '../../../scss/styles.scss';
.generate-confirmation {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
.btn {
margin-right: $baseline;
}
}

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector';
import WhereBuilder from '../WhereBuilder';
import Button from '../Button';
import './index.scss';
const baseClass = 'list-controls';
const ListControls = (props) => {
const {
handleChange,
collection,
enableColumns,
collection: {
fields,
admin: {
useAsTitle,
defaultColumns,
},
},
} = props;
const [titleField, setTitleField] = useState(null);
const [search, setSearch] = useState('');
const [columns, setColumns] = useState([]);
const [where, setWhere] = useState({});
const [visibleDrawer, setVisibleDrawer] = useState(false);
useEffect(() => {
if (useAsTitle) {
const foundTitleField = fields.find((field) => field.name === useAsTitle);
if (foundTitleField) {
setTitleField(foundTitleField);
}
}
}, [useAsTitle, fields]);
useEffect(() => {
const newState = {
columns,
};
if (search) {
newState.where = {
and: [
search,
],
};
}
if (where) {
if (!search) {
newState.where = {
and: [],
};
}
newState.where.and.push(where);
}
handleChange(newState);
}, [search, columns, where, handleChange]);
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
<SearchFilter
handleChange={setSearch}
fieldName={titleField ? titleField.name : undefined}
fieldLabel={titleField ? titleField.label : undefined}
/>
<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>
{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}
>
<WhereBuilder
handleChange={setWhere}
collection={collection}
/>
</AnimateHeight>
</div>
);
};
ListControls.defaultProps = {
enableColumns: true,
};
ListControls.propTypes = {
enableColumns: PropTypes.bool,
collection: PropTypes.shape({
admin: PropTypes.shape({
useAsTitle: PropTypes.string,
defaultColumns: PropTypes.arrayOf(
PropTypes.string,
),
}),
fields: PropTypes.arrayOf(PropTypes.shape),
}).isRequired,
handleChange: PropTypes.func.isRequired,
};
export default ListControls;

View File

@@ -0,0 +1,75 @@
@import '../../../scss/styles';
.list-controls {
margin-bottom: $baseline;
&__wrap {
display: flex;
}
.search-filter {
flex-grow: 1;
input {
margin: 0;
}
}
&__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 base(.5);
min-width: 140px;
&.btn--style-primary {
svg {
transform: rotate(180deg);
}
}
}
.column-selector,
.where-builder {
margin-top: base(1);
}
@include mid-break {
&__buttons {
margin-left: base(.5);
}
}
@include small-break {
&__wrap {
flex-wrap: wrap;
}
.search-filter {
margin-bottom: base(.5);
width: 100%;
}
&__buttons {
margin: 0;
}
&__buttons {
width: 100%;
}
&__toggle-columns,
&__toggle-where {
flex-grow: 1;
}
}
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
const Loading = () => <div>Loading</div>;
export default Loading;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Link } from 'react-router-dom';
import qs from 'qs';
import { useConfig } from '../../providers/Config';
import { useLocale } from '../../utilities/Locale';
import { useSearchParams } from '../../utilities/SearchParams';
import Popup from '../Popup';
import './index.scss';
const baseClass = 'localizer';
const Localizer = () => {
const { localization } = useConfig();
const locale = useLocale();
const searchParams = useSearchParams();
if (localization) {
const { locales } = localization;
return (
<div className={baseClass}>
<Popup
align="left"
button={locale}
render={({ close }) => (
<div>
<span>Locales</span>
<ul>
{locales.map((localeOption) => {
const baseLocaleClass = `${baseClass}__locale`;
const localeClasses = [
baseLocaleClass,
locale === localeOption && `${baseLocaleClass}--active`,
];
const newParams = {
...searchParams,
locale: localeOption,
};
const search = qs.stringify(newParams);
if (localeOption !== locale) {
return (
<li
key={localeOption}
className={localeClasses}
>
<Link
to={{ search }}
onClick={close}
>
{localeOption}
</Link>
</li>
);
}
return null;
})}
</ul>
</div>
)}
/>
</div>
);
}
return null;
};
export default Localizer;

View File

@@ -0,0 +1,41 @@
@import '../../../scss/styles.scss';
.localizer {
position: relative;
button {
padding: base(.25) 0;
font-size: 1rem;
line-height: base(1);
background: transparent;
border: 0;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:active,
&:focus {
outline: none;
}
}
span {
color: $color-gray;
}
ul {
list-style: none;
padding: 0;
text-align: left;
margin: 0;
a {
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { NavLink, Link, useHistory } from 'react-router-dom';
import { useConfig } from '../../providers/Config';
import { useAuthentication } from '../../providers/Authentication';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import Chevron from '../../icons/Chevron';
import LogOut from '../../icons/LogOut';
import Menu from '../../icons/Menu';
import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
import Account from '../../graphics/Account';
import Localizer from '../Localizer';
import './index.scss';
const baseClass = 'nav';
const DefaultNav = () => {
const { permissions } = useAuthentication();
const [menuActive, setMenuActive] = useState(false);
const history = useHistory();
const {
collections,
globals,
routes: {
admin,
},
} = useConfig();
const classes = [
baseClass,
menuActive && `${baseClass}--menu-active`,
].filter(Boolean).join(' ');
useEffect(() => history.listen(() => {
setMenuActive(false);
}), [history]);
return (
<aside className={classes}>
<div className={`${baseClass}__scroll`}>
<header>
<Link
to={admin}
className={`${baseClass}__brand`}
>
<Icon />
</Link>
<button
type="button"
className={`${baseClass}__mobile-menu-btn`}
onClick={() => setMenuActive(!menuActive)}
>
{menuActive && (
<CloseMenu />
)}
{!menuActive && (
<Menu />
)}
</button>
</header>
<div className={`${baseClass}__wrap`}>
<span className={`${baseClass}__label`}>Collections</span>
<nav>
{collections && collections.map((collection, i) => {
const href = `${admin}/collections/${collection.slug}`;
if (permissions?.[collection.slug]?.read.permission) {
return (
<NavLink
activeClassName="active"
key={i}
to={href}
>
<Chevron />
{collection.labels.plural}
</NavLink>
);
}
return null;
})}
</nav>
{(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>
);
}
return null;
})}
</nav>
</React.Fragment>
)}
<div className={`${baseClass}__controls`}>
<Localizer />
<Link
to={`${admin}/account`}
className={`${baseClass}__account`}
>
<Account />
</Link>
<Link
to={`${admin}/logout`}
className={`${baseClass}__log-out`}
>
<LogOut />
</Link>
</div>
</div>
</div>
</aside>
);
};
const Nav = () => {
const {
admin: {
components: {
Nav: CustomNav,
} = {},
} = {},
} = useConfig();
return (
<RenderCustomComponent
CustomComponent={CustomNav}
DefaultComponent={DefaultNav}
/>
);
};
export default Nav;

View File

@@ -0,0 +1,178 @@
@import '../../../scss/styles.scss';
.nav {
flex-shrink: 0;
position: sticky;
top: 0;
left: 0;
height: 100vh;
width: base(9);
overflow: hidden;
header {
width: 100%;
display: flex;
margin-bottom: base(1.5);
a, button {
display: block;
padding: 0;
svg {
display: block;
}
}
}
&__brand {
margin-right: base(1);
}
&__mobile-menu-btn {
background: none;
border: 0;
opacity: 0;
visibility: hidden;
cursor: pointer;
&:active, &:focus {
outline: none;
}
}
&__scroll {
height: 100%;
display: flex;
flex-direction: column;
padding: base(1.5) base(1);
width: calc(100% + #{base(1)});
overflow-y: scroll;
}
&__wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
flex-grow: 1;
}
&__label {
color: $color-gray;
}
&__controls {
margin-top: auto;
margin-bottom: 0;
> * {
margin-top: base(1);
}
}
&__log-out {
&:hover {
g {
transform: translateX(- #{base(.125)});
}
}
}
a {
padding: base(.125) 0;
display: flex;
text-decoration: none;
&:focus {
box-shadow: none;
font-weight: 600;
}
&:active {
font-weight: normal;
}
}
nav {
margin: base(.25) 0 $baseline;
a {
position: relative;
svg {
opacity: 0;
position: absolute;
left: - base(.5);
transform: rotate(-90deg);
}
&:hover {
text-decoration: underline;
}
&.active {
padding-left: base(.6);
font-weight: 600;
svg {
opacity: 1;
}
}
}
}
@include large-break {
width: base(8);
}
@include mid-break {
background: rgba($color-background-gray, .8);
backdrop-filter: saturate(180%) blur(5px);
width: 100%;
height: base(3);
z-index: $z-nav;
&__scroll {
padding: 0;
overflow: hidden;
width: 100%;
}
header,
&__wrap {
padding: $baseline;
}
header {
justify-content: space-between;
margin: 0;
}
&__mobile-menu-btn {
opacity: 1;
visibility: visible;
}
&__wrap {
padding-top: 0;
visibility: hidden;
opacity: 0;
overflow-y: scroll;
}
&.nav--menu-active {
height: 100vh;
.nav__wrap {
visibility: visible;
opacity: 1;
}
}
nav a {
font-size: base(.875);
line-height: base(1.25);
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import Chevron from '../../../icons/Chevron';
import './index.scss';
const baseClass = 'clickable-arrow';
const ClickableArrow = (props) => {
const {
updatePage,
isDisabled,
direction,
} = props;
const classes = [
baseClass,
isDisabled && `${baseClass}--is-disabled`,
direction && `${baseClass}--${direction}`,
].filter(Boolean).join(' ');
return (
<button
className={classes}
onClick={!isDisabled ? updatePage : undefined}
type="button"
>
<Chevron />
</button>
);
};
ClickableArrow.defaultProps = {
updatePage: null,
isDisabled: false,
direction: 'right',
};
ClickableArrow.propTypes = {
updatePage: PropTypes.func,
isDisabled: PropTypes.bool,
direction: PropTypes.oneOf(['right', 'left']),
};
export default ClickableArrow;

View File

@@ -0,0 +1,18 @@
@import '../../../../scss/styles.scss';
.clickable-arrow {
cursor: pointer;
transform: rotate(-90deg);
&--left {
transform: rotate(90deg);
}
&--is-disabled {
cursor: default;
.icon .stroke{
stroke: $color-gray;
}
}
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
const baseClass = 'paginator__page';
const Page = ({
page, isCurrent, updatePage, isFirstPage, isLastPage,
}) => {
const classes = [
baseClass,
isCurrent && `${baseClass}--is-current`,
isFirstPage && `${baseClass}--is-first-page`,
isLastPage && `${baseClass}--is-last-page`,
].filter(Boolean).join(' ');
return (
<button
className={classes}
onClick={() => updatePage(page)}
type="button"
>
{page}
</button>
);
};
Page.defaultProps = {
page: 1,
isCurrent: false,
updatePage: null,
isFirstPage: false,
isLastPage: false,
};
Page.propTypes = {
page: PropTypes.number,
isCurrent: PropTypes.bool,
updatePage: PropTypes.func,
isFirstPage: PropTypes.bool,
isLastPage: PropTypes.bool,
};
export default Page;

View File

@@ -0,0 +1,5 @@
import React from 'react';
const Separator = () => <span className="paginator__separator">&mdash;</span>;
export default Separator;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory, useLocation } from 'react-router-dom';
import queryString from 'qs';
import Page from './Page';
import Separator from './Separator';
import ClickableArrow from './ClickableArrow';
import './index.scss';
const nodeTypes = {
Page,
Separator,
ClickableArrow,
};
const baseClass = 'paginator';
const Pagination = (props) => {
const history = useHistory();
const location = useLocation();
const {
totalPages,
page: currentPage,
hasPrevPage,
hasNextPage,
prevPage,
nextPage,
numberOfNeighbors,
disableHistoryChange,
onChange,
} = props;
if (!totalPages || totalPages <= 1) return null;
// uses react router to set the current page
const updatePage = (page) => {
if (!disableHistoryChange) {
const params = queryString.parse(location.search, { ignoreQueryPrefix: true });
params.page = page;
history.push({ search: queryString.stringify(params, { addQueryPrefix: true }) });
}
if (typeof onChange === 'function') onChange(page);
};
// Create array of integers for each page
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
// Assign indices for start and end of the range of pages that should be shown in paginator
let rangeStartIndex = (currentPage - 1) - numberOfNeighbors;
// Sanitize rangeStartIndex in case it is less than zero for safe split
if (rangeStartIndex <= 0) rangeStartIndex = 0;
const rangeEndIndex = (currentPage - 1) + numberOfNeighbors + 1;
// Slice out the range of pages that we want to render
const nodes = pages.slice(rangeStartIndex, rangeEndIndex);
// Add prev separator if necessary
if (currentPage - numberOfNeighbors - 1 >= 2) nodes.unshift({ type: 'Separator' });
// Add first page if necessary
if (currentPage > numberOfNeighbors + 1) {
nodes.unshift({
type: 'Page',
props: {
page: 1,
updatePage,
isFirstPage: true,
},
});
}
// Add next separator if necessary
if (currentPage + numberOfNeighbors + 1 < totalPages) nodes.push({ type: 'Separator' });
// Add last page if necessary
if (rangeEndIndex < totalPages) {
nodes.push({
type: 'Page',
props: {
page: totalPages,
updatePage,
isLastPage: true,
},
});
}
// Add prev and next arrows based on necessity
nodes.push({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(prevPage),
isDisabled: !hasPrevPage,
direction: 'left',
},
});
nodes.push({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(nextPage),
isDisabled: !hasNextPage,
direction: 'right',
},
});
return (
<div className={baseClass}>
{nodes.map((node, i) => {
if (typeof node === 'number') {
return (
<Page
key={i}
page={node}
updatePage={updatePage}
isCurrent={currentPage === node}
/>
);
}
const NodeType = nodeTypes[node.type];
return (
<NodeType
key={i}
{...node.props}
/>
);
})}
</div>
);
};
export default Pagination;
Pagination.defaultProps = {
limit: null,
totalPages: null,
page: 1,
hasPrevPage: false,
hasNextPage: false,
prevPage: null,
nextPage: null,
numberOfNeighbors: 1,
disableHistoryChange: false,
onChange: undefined,
};
Pagination.propTypes = {
limit: PropTypes.number,
totalPages: PropTypes.number,
page: PropTypes.number,
hasPrevPage: PropTypes.bool,
hasNextPage: PropTypes.bool,
prevPage: PropTypes.number,
nextPage: PropTypes.number,
numberOfNeighbors: PropTypes.number,
disableHistoryChange: PropTypes.bool,
onChange: PropTypes.func,
};

View File

@@ -0,0 +1,48 @@
@import '../../../scss/styles.scss';
.paginator {
display: flex;
margin-bottom: $baseline;
&__page {
cursor: pointer;
&--is-current {
background: $color-background-gray;
color: $color-gray;
cursor: default;
}
&--is-last-page {
margin-right: 0;
}
}
.clickable-arrow,
&__page {
@extend %btn-reset;
width: base(2);
height: base(2);
display: flex;
justify-content: center;
align-content: center;
outline: 0;
padding: base(.5);
color: $color-dark-gray;
line-height: base(1);
&:hover:not(.clickable-arrow--is-disabled) {
background: $color-background-gray;
}
}
&__page,
&__separator {
margin-right: base(.25);
}
&__separator {
align-self: center;
color: $color-gray;
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import './index.scss';
const baseClass = 'pill';
const Pill = ({
children, className, to, icon, alignIcon, onClick, pillStyle,
}) => {
const classes = [
baseClass,
`${baseClass}--style-${pillStyle}`,
className && className,
to && `${baseClass}--has-link`,
(to || onClick) && `${baseClass}--has-action`,
icon && `${baseClass}--has-icon`,
icon && `${baseClass}--align-icon-${alignIcon}`,
].filter(Boolean).join(' ');
let RenderedType = 'div';
if (onClick && !to) RenderedType = 'button';
if (to) RenderedType = Link;
return (
<RenderedType
className={classes}
onClick={onClick}
type={RenderedType === 'button' ? 'button' : undefined}
to={to || undefined}
>
{(icon && alignIcon === 'left') && (
<>
{icon}
</>
)}
{children}
{(icon && alignIcon === 'right') && (
<>
{icon}
</>
)}
</RenderedType>
);
};
Pill.defaultProps = {
children: undefined,
className: '',
to: undefined,
icon: undefined,
alignIcon: 'right',
onClick: undefined,
pillStyle: 'light',
};
Pill.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
to: PropTypes.string,
icon: PropTypes.node,
alignIcon: PropTypes.oneOf(['left', 'right']),
onClick: PropTypes.func,
pillStyle: PropTypes.oneOf(['light', 'dark', 'light-gray']),
};
export default Pill;

View File

@@ -0,0 +1,70 @@
@import '../../../scss/styles.scss';
.pill {
font-size: 1rem;
line-height: base(1);
border: 0;
display: inline-flex;
vertical-align: middle;
background: $color-light-gray;
color: $color-dark-gray;
border-radius: $style-radius-s;
padding: 0 base(.25);
padding-left: base(.0875 + .25);
&:active,
&:focus {
outline: none;
}
&--has-action {
cursor: pointer;
text-decoration: none;
}
&--has-icon {
svg {
display: block;
}
}
&--align-icon-left {
padding-left: base(.125);
}
&--align-icon-right {
padding-right: base(.125);
}
&--style-light {
&:hover {
background: lighten($color-light-gray, 3%);
}
&:active {
background: lighten($color-light-gray, 5%);
}
}
&--style-light-gray {
background: $color-background-gray;
color: $color-dark-gray;
}
&--style-dark {
background: $color-dark-gray;
color: white;
svg {
@include color-svg(white);
}
&:hover {
background: lighten($color-dark-gray, 3%);
}
&:active {
background: lighten($color-dark-gray, 5%);
}
}
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'popup-button';
const PopupButton = (props) => {
const {
buttonType,
button,
setActive,
active,
onToggleOpen,
} = props;
const classes = [
baseClass,
`${baseClass}--${buttonType}`,
].filter(Boolean).join(' ');
const handleClick = () => {
if (typeof onToggleOpen === 'function') onToggleOpen(!active);
setActive(!active);
};
if (buttonType === 'custom') {
return (
<div
role="button"
tabIndex="0"
onKeyDown={(e) => { if (e.key === 'Enter') handleClick(); }}
onClick={handleClick}
className={classes}
>
{button}
</div>
);
}
return (
<button
type="button"
onClick={() => setActive(!active)}
className={classes}
>
{button}
</button>
);
};
PopupButton.defaultProps = {
buttonType: null,
onToggleOpen: undefined,
};
PopupButton.propTypes = {
buttonType: PropTypes.oneOf(['custom', 'default']),
button: PropTypes.node.isRequired,
setActive: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
onToggleOpen: PropTypes.func,
};
export default PopupButton;

View File

@@ -0,0 +1,5 @@
@import '../../../../scss/styles.scss';
.popup-button {
display: inline-flex;
}

View File

@@ -0,0 +1,166 @@
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@faceless-ui/window-info';
import { useScrollInfo } from '@faceless-ui/scroll-info';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import PopupButton from './PopupButton';
import './index.scss';
const baseClass = 'popup';
const Popup = (props) => {
const {
render, align, size, color, button, buttonType, children, showOnHover, horizontalAlign, initActive, onToggleOpen,
} = props;
const buttonRef = useRef(null);
const contentRef = useRef(null);
const [active, setActive] = useState(initActive);
const [verticalAlign, setVerticalAlign] = useState('top');
const [forceHorizontalAlign, setForceHorizontalAlign] = useState(null);
const { y: scrollY } = useScrollInfo();
const { height: windowHeight } = useWindowInfo();
const handleClickOutside = (e) => {
if (contentRef.current.contains(e.target)) {
return;
}
setActive(false);
};
useThrottledEffect(() => {
if (contentRef.current && buttonRef.current) {
const {
height: contentHeight,
width: contentWidth,
right: contentRightEdge,
} = contentRef.current.getBoundingClientRect();
const { y: buttonYCoord } = buttonRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const distanceToRightEdge = windowWidth - contentRightEdge;
const distanceToLeftEdge = contentRightEdge - contentWidth;
if (horizontalAlign === 'left' && distanceToRightEdge <= 0) {
setForceHorizontalAlign('right');
} else if (horizontalAlign === 'right' && distanceToLeftEdge <= 0) {
setForceHorizontalAlign('left');
} else if (horizontalAlign === 'center' && (distanceToLeftEdge <= contentWidth / 2 || distanceToRightEdge <= contentWidth / 2)) {
if (distanceToRightEdge > distanceToLeftEdge) setForceHorizontalAlign('left');
else setForceHorizontalAlign('right');
} else {
setForceHorizontalAlign(null);
}
if (buttonYCoord > contentHeight) {
setVerticalAlign('top');
} else {
setVerticalAlign('bottom');
}
}
}, 500, [setVerticalAlign, contentRef, scrollY, windowHeight]);
useEffect(() => {
if (active) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [active]);
const classes = [
baseClass,
`${baseClass}--align-${align}`,
`${baseClass}--size-${size}`,
`${baseClass}--color-${color}`,
`${baseClass}--v-align-${verticalAlign}`,
`${baseClass}--h-align-${horizontalAlign}`,
forceHorizontalAlign && `${baseClass}--force-h-align-${forceHorizontalAlign}`,
active && `${baseClass}--active`,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div
ref={buttonRef}
className={`${baseClass}__wrapper`}
>
{showOnHover
? (
<div
className={`${baseClass}__on-hover-watch`}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
>
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
</div>
)
: (
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
active={active}
/>
)}
</div>
<div
className={`${baseClass}__content`}
ref={contentRef}
>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__scroll`}>
{render && render({ close: () => setActive(false) })}
{children && children}
</div>
</div>
</div>
</div>
);
};
Popup.defaultProps = {
align: 'center',
size: 'small',
color: 'light',
children: undefined,
render: undefined,
buttonType: 'default',
button: undefined,
showOnHover: false,
horizontalAlign: 'left',
initActive: false,
onToggleOpen: undefined,
};
Popup.propTypes = {
render: PropTypes.func,
children: PropTypes.node,
align: PropTypes.oneOf(['left', 'center', 'right']),
horizontalAlign: PropTypes.oneOf(['left', 'center', 'right']),
size: PropTypes.oneOf(['small', 'large', 'wide']),
color: PropTypes.oneOf(['light', 'dark']),
buttonType: PropTypes.oneOf(['default', 'custom']),
button: PropTypes.node,
showOnHover: PropTypes.bool,
initActive: PropTypes.bool,
onToggleOpen: PropTypes.func,
};
export default Popup;

View File

@@ -0,0 +1,279 @@
@import '../../../scss/styles.scss';
.popup {
position: relative;
&__content {
position: absolute;
background: white;
opacity: 0;
visibility: hidden;
z-index: $z-modal;
max-width: calc(100vw - #{$baseline});
&:after {
content: ' ';
position: absolute;
top: calc(100% - 1px);
border: 12px solid transparent;
border-top-color: white;
}
}
&__wrap {
overflow: hidden;
}
&__scroll {
padding: $baseline;
padding-right: calc(var(--scrollbar-width) + #{$baseline});
overflow-y: auto;
width: calc(100% + var(--scrollbar-width));
white-space: nowrap;
}
&:focus,
&:active {
outline: none;
}
////////////////////////////////
// SIZE
////////////////////////////////
&--size-small {
.popup__content {
@include shadow-sm;
}
&.popup--align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.425);
}
}
}
}
&--size-large {
.popup__content {
@include shadow-lg;
}
.popup__scroll {
padding: base(1) base(1.5);
}
}
&--size-wide {
.popup__content {
@include shadow-sm;
&:after {
border: 12px solid transparent;
border-top-color: white;
}
}
.popup__scroll {
padding: base(.25) base(.5);
}
&.popup--align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.425);
}
}
}
}
////////////////////////////////
// HORIZONTAL ALIGNMENT
////////////////////////////////
&--h-align-left {
.popup__content {
left: - base(1.75);
&:after {
left: base(1.75);
}
}
}
&--h-align-center {
.popup__content {
left: 50%;
transform: translateX(-50%);
&:after {
left: 50%;
transform: translateX(-50%);
}
}
}
&--h-align-right {
.popup__content {
right: - base(1.75);
&:after {
right: base(1.75);
}
}
}
&--force-h-align-left {
.popup__content {
left: - base(1.75);
&:after {
left: base(1.75);
right: unset;
transform: unset;
}
}
}
&--force-h-align-right {
.popup__content {
right: - base(1.75);
&:after {
right: base(1.75);
left: unset;
transform: unset;
}
}
}
////////////////////////////////
// VERTICAL ALIGNMENT
////////////////////////////////
&--v-align-top {
.popup__content {
bottom: calc(100% + #{$baseline});
}
}
&--v-align-bottom {
.popup__content {
@include shadow-lg-top;
top: calc(100% + #{base(.5)});
&:after {
top: unset;
bottom: calc(100% - 1px);
border-top-color: transparent !important;
}
}
&.popup--color-dark {
.popup__content {
&:after {
border-bottom-color: $color-dark-gray;
}
}
}
}
////////////////////////////////
// COLOR
////////////////////////////////
&--color-dark {
.popup__content {
background: $color-dark-gray;
color: white;
&:after {
border-top-color: $color-dark-gray;
}
}
}
////////////////////////////////
// ACTIVE
////////////////////////////////
&--active {
.popup__content {
opacity: 1;
visibility: visible;
}
}
@include mid-break {
&__scroll,
&--size-large .popup__scroll{
padding: base(.75);
padding-right: calc(var(--scrollbar-width) + #{base(.75)});
}
&--h-align-left {
.popup__content {
left: - base(.5);
&:after {
left: base(.5);
}
}
}
&--h-align-center {
.popup__content {
left: 50%;
transform: translateX(-50%);
&:after {
left: 50%;
transform: translateX(-50%);
}
}
}
&--h-align-right {
.popup__content {
right: - base(.5);
&:after {
right: base(.5);
}
}
}
&--force-h-align-left {
.popup__content {
left: - base(.5);
right: unset;
transform: unset;
&:after {
left: base(.5);
right: unset;
transform: unset;
}
}
}
&--force-h-align-right {
.popup__content {
right: - base(.5);
left: unset;
transform: unset;
&:after {
right: base(.5);
left: unset;
transform: unset;
}
}
}
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useForm } from '../../forms/Form/context';
import { useAuthentication } from '../../providers/Authentication';
import Button from '../Button';
const baseClass = 'preview-btn';
const PreviewButton = ({ generatePreviewURL }) => {
const { token } = useAuthentication();
const { getFields } = useForm();
const fields = getFields();
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const previewURL = generatePreviewURL(fields, token);
return (
<Button
el="anchor"
className={baseClass}
buttonStyle="secondary"
url={previewURL}
>
Preview
</Button>
);
}
return null;
};
PreviewButton.defaultProps = {
generatePreviewURL: null,
};
PreviewButton.propTypes = {
generatePreviewURL: PropTypes.func,
};
export default PreviewButton;

View File

@@ -0,0 +1,110 @@
import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-select';
import Chevron from '../../icons/Chevron';
import './index.scss';
const ReactSelect = (props) => {
const {
showError,
options,
isMulti,
onChange,
value,
disabled,
formatValue,
} = props;
const classes = [
'react-select',
showError && 'react-select--error',
].filter(Boolean).join(' ');
return (
<Select
{...props}
value={value}
onChange={(selected) => {
if (formatValue) {
onChange(formatValue(selected));
} else {
let valueToChange;
if (isMulti) {
if (selected) {
valueToChange = selected.map((selectedOption) => {
if (typeof selectedOption === 'string') {
return {
label: selectedOption,
value: selectedOption,
};
}
return selectedOption;
});
}
} else if (selected) {
valueToChange = selected.value;
}
onChange(valueToChange);
}
}}
disabled={disabled ? 'disabled' : undefined}
components={{ DropdownIndicator: Chevron }}
className={classes}
classNamePrefix="rs"
options={options.map((option) => {
const formattedOption = {
value: option,
label: option,
};
if (typeof option === 'object') {
formattedOption.value = option.value;
formattedOption.label = option.label;
if (option.options) formattedOption.options = option.options;
}
return formattedOption;
})}
/>
);
};
ReactSelect.defaultProps = {
isMulti: false,
value: undefined,
showError: false,
disabled: false,
formatValue: null,
options: [],
onChange: () => { },
};
ReactSelect.propTypes = {
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
PropTypes.shape({}),
]),
onChange: PropTypes.func,
disabled: PropTypes.bool,
showError: PropTypes.bool,
formatValue: PropTypes.func,
options: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.string,
),
PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
}),
),
]),
isMulti: PropTypes.bool,
};
export default ReactSelect;

View File

@@ -0,0 +1,107 @@
@import '../../../scss/styles';
div.react-select {
div.rs__control {
@include formInput;
height: auto;
padding-top: base(.25);
padding-bottom: base(.25);
}
.rs__value-container {
padding: 0;
> * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
&--is-multi {
margin-left: - base(.25);
}
}
.rs__indicators {
.arrow {
margin-left: base(.5);
transform: rotate(90deg);
width: base(.3);
}
}
.rs__indicator {
padding: 0px 4px;
}
.rs__indicator-separator {
display: none;
}
.rs__input {
margin-top: base(.25);
margin-bottom: base(.25);
input {
font-family: $font-body;
width: 100% !important;
}
}
.rs__menu {
z-index: 2;
border-radius: 0;
@include inputShadowActive;
}
.rs__group-heading {
color: $color-dark-gray;
padding-left: base(.5);
margin-bottom: base(.25);
}
.rs__option {
font-family: $font-body;
font-size: $baseline-body-size;
padding: base(.375) base(.75);
color: $color-dark-gray;
&--is-focused {
background-color: rgba($color-dark-gray, .1);
}
&--is-selected {
background-color: rgba($color-dark-gray, .5);
}
}
.rs__multi-value {
padding: 0;
background: transparent;
border: $style-stroke-width-s solid $color-dark-gray;
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
margin: base(.25) base(.5) base(.25) 0;
}
.rs__multi-value__label {
padding: 0 base(.125) 0 base(.25);
max-width: 150px;
}
.rs__multi-value__remove {
padding: 0 base(.125);
cursor: pointer;
&:hover {
color: $color-dark-gray;
background: rgba($color-red, .25);
}
}
&--error {
div.rs__control {
background-color: lighten($color-red, 20%);
}
}
}

View File

@@ -0,0 +1,59 @@
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,
} = props;
const titleFromForm = useTitle(useAsTitle);
const titleFromData = data && data[useAsTitle];
let title = titleFromData;
if (!title) title = titleFromForm;
if (!title) title = data?.id;
if (!title) title = fallback;
title = titleFromProps || 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 = {
title: undefined,
fallback: '[Untitled]',
useAsTitle: null,
data: null,
};
RenderTitle.propTypes = {
useAsTitle: PropTypes.string,
data: PropTypes.shape({
id: PropTypes.string,
}),
title: PropTypes.string,
fallback: PropTypes.string,
};
export default RenderTitle;

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

@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Search from '../../icons/Search';
import useDebounce from '../../../hooks/useDebounce';
import './index.scss';
const baseClass = 'search-filter';
const SearchFilter = (props) => {
const {
fieldName,
fieldLabel,
handleChange,
} = props;
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
if (typeof handleChange === 'function') {
handleChange(debouncedSearch ? {
[fieldName]: {
like: debouncedSearch,
},
} : null);
}
}, [debouncedSearch, handleChange, fieldName]);
return (
<div className={baseClass}>
<input
className={`${baseClass}__input`}
placeholder={`Search by ${fieldLabel}`}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<Search />
</div>
);
};
SearchFilter.defaultProps = {
fieldName: 'id',
fieldLabel: 'ID',
};
SearchFilter.propTypes = {
fieldName: PropTypes.string,
fieldLabel: PropTypes.string,
handleChange: PropTypes.func.isRequired,
};
export default SearchFilter;

View File

@@ -0,0 +1,16 @@
@import '../../../scss/styles';
.search-filter {
position: relative;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: base(.5);
}
&__input {
@include formInput;
}
}

View File

@@ -0,0 +1,67 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Chevron from '../../icons/Chevron';
import Button from '../Button';
import './index.scss';
const baseClass = 'sort-column';
const SortColumn = (props) => {
const {
label, handleChange, name, disable,
} = props;
const [sort, setSort] = useState(null);
useEffect(() => {
handleChange(sort);
}, [sort, handleChange]);
const desc = `-${name}`;
const asc = name;
const ascClasses = [`${baseClass}__asc`];
if (sort === asc) ascClasses.push(`${baseClass}--active`);
const descClasses = [`${baseClass}__desc`];
if (sort === desc) descClasses.push(`${baseClass}--active`);
return (
<div className={baseClass}>
<span className={`${baseClass}__label`}>{label}</span>
{!disable && (
<span className={`${baseClass}__buttons`}>
<Button
round
buttonStyle="none"
className={ascClasses.join(' ')}
onClick={() => setSort(asc)}
>
<Chevron />
</Button>
<Button
round
buttonStyle="none"
className={descClasses.join(' ')}
onClick={() => setSort(desc)}
>
<Chevron />
</Button>
</span>
)}
</div>
);
};
SortColumn.defaultProps = {
disable: false,
};
SortColumn.propTypes = {
label: PropTypes.string.isRequired,
handleChange: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
disable: PropTypes.bool,
};
export default SortColumn;

View File

@@ -0,0 +1,51 @@
@import '../../../scss/styles.scss';
.sort-column {
&__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__buttons {
flex-shrink: 0;
}
&__label,
&__buttons,
.btn {
vertical-align: middle;
display: inline-block;
}
&__label {
cursor: default;
}
button.btn {
margin: 0;
opacity: .3;
&.sort-column--active {
opacity: 1;
visibility: visible;
}
&:hover {
opacity: .7;
}
}
&__asc {
svg {
transform: rotate(180deg);
}
}
&:hover {
.btn {
opacity: .4;
visibility: visible;
}
}
}

View File

@@ -0,0 +1,101 @@
import React, {
useReducer, createContext, useContext, useEffect, useCallback,
} from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import X from '../../icons/X';
import reducer from './reducer';
import './index.scss';
const baseClass = 'status-list';
const Context = createContext({});
const useStatusList = () => useContext(Context);
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 clearStatus = useCallback(() => dispatchStatus({ type: 'CLEAR' }), []);
const replaceStatus = useCallback((status) => dispatchStatus({ type: 'REPLACE', payload: status }), []);
useEffect(() => {
if (state && state.status) {
if (Array.isArray(state.status)) {
replaceStatus(state.status);
} else {
replaceStatus([state.status]);
}
} else {
clearStatus();
}
}, [addStatus, replaceStatus, clearStatus, state, pathname]);
return (
<Context.Provider value={{
statusList,
removeStatus,
addStatus,
clearStatus,
replaceStatus,
}}
>
{children}
</Context.Provider>
);
};
StatusListProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
const StatusList = () => {
const { statusList, removeStatus } = useStatusList();
if (statusList.length > 0) {
return (
<ul className={baseClass}>
{statusList.map((status, i) => {
const classes = [
`${baseClass}__status`,
`${baseClass}__status--${status.type}`,
].join(' ');
return (
<li
className={classes}
key={i}
>
{status.message}
<button
type="button"
className="close"
onClick={(e) => {
e.preventDefault();
removeStatus(i);
}}
>
<X />
</button>
</li>
);
})}
</ul>
);
}
return null;
};
export {
StatusListProvider,
useStatusList,
};
export default StatusList;

View File

@@ -0,0 +1,47 @@
@import '../../../scss/styles.scss';
.status-list {
position: relative;
z-index: $z-status;
list-style: none;
padding: 0;
margin: 0;
li {
background: $color-green;
color: $color-dark-gray;
padding: base(.5) $baseline;
margin-bottom: 1px;
display: flex;
justify-content: space-between;
}
button {
@extend %btn-reset;
cursor: pointer;
svg {
width: base(1);
height: base(1);
}
&:hover {
opacity: .5;
}
&:active,
&:focus {
outline: none;
border: 0;
}
}
li.status-list__status--error {
background: $color-red;
color: white;
button svg {
@include color-svg(white);
}
}
}

View File

@@ -0,0 +1,32 @@
const statusReducer = (state, action) => {
switch (action.type) {
case 'ADD': {
const newState = [
...state,
action.payload,
];
return newState;
}
case 'REMOVE': {
const statusList = [...state];
statusList.splice(action.payload, 1);
return statusList;
}
case 'CLEAR': {
return [];
}
case 'REPLACE': {
return action.payload;
}
default:
return state;
}
};
export default statusReducer;

View File

@@ -0,0 +1,77 @@
import React, {
useState, createContext, useContext,
} from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Chevron from '../../icons/Chevron';
import './index.scss';
const Context = createContext({});
const StepNavProvider = ({ children }) => {
const [stepNav, setStepNav] = useState([]);
return (
<Context.Provider value={{
stepNav,
setStepNav,
}}
>
{children}
</Context.Provider>
);
};
StepNavProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
const useStepNav = () => useContext(Context);
const StepNav = () => {
const dashboardLabel = <span>Dashboard</span>;
const { stepNav } = useStepNav();
return (
<nav className="step-nav">
{stepNav.length > 0
? (
<Link to="/admin">
{dashboardLabel}
<Chevron />
</Link>
)
: dashboardLabel
}
{stepNav.map((item, i) => {
const StepLabel = <span key={i}>{item.label}</span>;
const Step = stepNav.length === i + 1
? StepLabel
: (
<Link
to={item.url}
key={i}
>
{StepLabel}
<Chevron />
</Link>
);
return Step;
})}
</nav>
);
};
export {
StepNavProvider,
useStepNav,
};
export default StepNav;

View File

@@ -0,0 +1,44 @@
@import '../../../scss/styles.scss';
.step-nav {
display: flex;
* {
display: block;
}
a {
margin-right: base(.25);
border: 0;
display: flex;
align-items: center;
font-weight: 600;
text-decoration: none;
svg {
margin-left: base(.25);
transform: rotate(-90deg);
}
label {
cursor: pointer;
}
&:hover {
text-decoration: underline;
}
}
span {
max-width: base(6);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
@include mid-break {
> *:first-child {
padding-left: $baseline;
}
}
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'table';
const Table = ({ columns, data }) => {
if (columns && columns.length > 0) {
return (
<div className={baseClass}>
<table
cellPadding="0"
cellSpacing="0"
border="0"
>
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>
{col.components.Heading}
</th>
))}
</tr>
</thead>
<tbody>
{data && data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((col, colIndex) => (
<td key={colIndex}>
{col.components.renderCell(row, row[col.accessor])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
return null;
};
Table.propTypes = {
columns: PropTypes.arrayOf(
PropTypes.shape({
accessor: PropTypes.string,
components: PropTypes.shape({
Heading: PropTypes.node,
renderCell: PropTypes.function,
}),
}),
).isRequired,
data: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
};
export default Table;

View File

@@ -0,0 +1,35 @@
@import '../../../scss/styles.scss';
.table {
margin-bottom: $baseline;
overflow: auto;
max-width: 100%;
thead {
color: $color-gray;
th {
font-weight: normal;
text-align: left;
}
}
th, td {
padding: base(.75);
min-width: 150px;
}
tbody {
tr {
&:nth-child(odd) {
background: $color-background-gray;
}
}
}
@include mid-break {
th, td {
max-width: 70vw;
}
}
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '../../providers/Config';
import FileGraphic from '../../graphics/File';
import getThumbnail from '../../../../uploads/getThumbnail';
import './index.scss';
const baseClass = 'thumbnail';
const Thumbnail = (props) => {
const {
filename, mimeType, staticURL, sizes, adminThumbnail, size,
} = props;
const { serverURL } = useConfig();
const thumbnail = getThumbnail(mimeType, staticURL, filename, sizes, adminThumbnail);
const classes = [
baseClass,
`${baseClass}--size-${size}`,
].join(' ');
return (
<div className={classes}>
{thumbnail && (
<img
src={`${serverURL}${thumbnail}`}
alt={filename}
/>
)}
{!thumbnail && (
<FileGraphic />
)}
</div>
);
};
Thumbnail.defaultProps = {
adminThumbnail: undefined,
sizes: undefined,
mimeType: undefined,
size: 'medium',
};
Thumbnail.propTypes = {
filename: PropTypes.string.isRequired,
sizes: PropTypes.shape({}),
adminThumbnail: PropTypes.string,
mimeType: PropTypes.string,
staticURL: PropTypes.string.isRequired,
size: PropTypes.oneOf(['small', 'medium', 'large', 'expand']),
};
export default Thumbnail;

View File

@@ -0,0 +1,47 @@
@import '../../../scss/styles.scss';
.thumbnail {
min-height: 100%;
flex-shrink: 0;
align-self: stretch;
overflow: hidden;
img, svg {
width: 100%;
height: 100%;
object-fit: cover;
}
&--size-expand {
max-height: 100%;
width: 100%;
padding-top: 100%;
position: relative;
img, svg {
position: absolute;
top: 0;
}
}
&--size-large {
max-height: base(9);
width: base(9);
}
&--size-medium {
max-height: base(7);
width: base(7);
}
&--size-small {
max-height: base(5);
width: base(5);
}
@include large-break {
.thumbnail {
width: base(5);
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const Tooltip = (props) => {
const { className, children } = props;
const classes = [
'tooltip',
className,
].filter(Boolean).join(' ');
return (
<aside className={classes}>
{children}
<span />
</aside>
);
};
Tooltip.defaultProps = {
className: undefined,
};
Tooltip.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
};
export default Tooltip;

View File

@@ -0,0 +1,26 @@
@import '../../../scss/styles.scss';
.tooltip {
background-color: $color-dark-gray;
position: absolute;
z-index: 2;
bottom: 100%;
left: 50%;
transform: translate3d(-50%, -20%, 0);
padding: base(.2) base(.4);
color: white;
line-height: base(.75);
font-weight: normal;
white-space: nowrap;
span {
position: absolute;
transform: translateX(-50%);
top: calc(100% - #{base(.0625)});
left: 50%;
height: 0;
width: 0;
border: 10px solid transparent;
border-top-color: $color-dark-gray;
}
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import Thumbnail from '../Thumbnail';
import './index.scss';
const baseClass = 'upload-card';
const UploadCard = (props) => {
const {
onClick,
mimeType,
sizes,
filename,
collection: {
upload: {
adminThumbnail,
staticURL,
} = {},
} = {},
} = props;
const classes = [
baseClass,
typeof onClick === 'function' && `${baseClass}--has-on-click`,
].filter(Boolean).join(' ');
return (
<div
className={classes}
onClick={typeof onClick === 'function' ? onClick : undefined}
>
<Thumbnail
size="expand"
{...{
mimeType, adminThumbnail, sizes, staticURL, filename,
}}
/>
<div className={`${baseClass}__filename`}>
{filename}
</div>
</div>
);
};
UploadCard.defaultProps = {
sizes: undefined,
onClick: undefined,
};
UploadCard.propTypes = {
collection: PropTypes.shape({
labels: PropTypes.shape({
singular: PropTypes.string,
}),
upload: PropTypes.shape({
adminThumbnail: PropTypes.string,
staticURL: PropTypes.string,
}),
}).isRequired,
id: PropTypes.string.isRequired,
filename: PropTypes.string.isRequired,
mimeType: PropTypes.string.isRequired,
sizes: PropTypes.shape({}),
onClick: PropTypes.func,
};
export default UploadCard;

View File

@@ -0,0 +1,19 @@
@import '../../../scss/styles.scss';
.upload-card {
@include shadow;
background: white;
max-width: base(9);
margin-bottom: base(.5);
&__filename {
padding: base(.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--has-on-click {
cursor: pointer;
}
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import UploadCard from '../UploadCard';
import './index.scss';
const baseClass = 'upload-gallery';
const UploadGallery = (props) => {
const { docs, onCardClick, collection } = props;
if (docs && docs.length > 0) {
return (
<ul className={baseClass}>
{docs.map((doc, i) => {
return (
<li key={i}>
<UploadCard
{...doc}
{...{ collection }}
onClick={() => onCardClick(doc)}
/>
</li>
);
})}
</ul>
);
}
return null;
};
UploadGallery.defaultProps = {
docs: undefined,
};
UploadGallery.propTypes = {
docs: PropTypes.arrayOf(
PropTypes.shape({}),
),
collection: PropTypes.shape({}).isRequired,
onCardClick: PropTypes.func.isRequired,
};
export default UploadGallery;

View File

@@ -0,0 +1,32 @@
@import '../../../scss/styles.scss';
.upload-gallery {
list-style: none;
padding: 0;
margin: base(2) -#{base(.5)};
width: calc(100% + #{$baseline});
display: flex;
flex-wrap: wrap;
li {
min-width: 0;
width: 25%;
}
.upload-card {
margin: base(.5);
max-width: initial;
}
@include large-break {
li {
width: 33.33%;
}
}
@include mid-break {
li {
width: 50%;
}
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import DatePicker from '../../../DatePicker';
const baseClass = 'condition-value-date';
const DateField = ({ onChange, value }) => (
<div className={baseClass}>
<DatePicker
onChange={onChange}
value={value}
/>
</div>
);
DateField.defaultProps = {
value: undefined,
};
DateField.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.instanceOf(Date),
};
export default DateField;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'condition-value-number';
const NumberField = ({ onChange, value }) => {
return (
<input
placeholder="Enter a value"
className={baseClass}
type="number"
onChange={e => onChange(e.target.value)}
value={value}
/>
);
};
NumberField.defaultProps = {
value: '',
};
NumberField.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default NumberField;

View File

@@ -0,0 +1,5 @@
@import '../../../../../scss/styles.scss';
.condition-value-number {
@include formInput;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import './index.scss';
const baseClass = 'condition-value-text';
const Text = ({ onChange, value }) => {
return (
<input
placeholder="Enter a value"
className={baseClass}
type="text"
onChange={e => onChange(e.target.value)}
value={value}
/>
);
};
Text.defaultProps = {
value: '',
};
Text.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default Text;

View File

@@ -0,0 +1,5 @@
@import '../../../../../scss/styles.scss';
.condition-value-text {
@include formInput;
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import ReactSelect from '../../ReactSelect';
import Button from '../../Button';
import Date from './Date';
import Number from './Number';
import Text from './Text';
import useDebounce from '../../../../hooks/useDebounce';
import './index.scss';
const valueFields = {
Date,
Number,
Text,
};
const baseClass = 'condition';
const Condition = (props) => {
const {
fields,
dispatch,
value,
orIndex,
andIndex,
} = props;
const [activeField, setActiveField] = useState({ operators: [] });
const [internalValue, setInternalValue] = useState(value.value);
const debouncedValue = useDebounce(internalValue, 300);
useEffect(() => {
const newActiveField = fields.find((field) => value.field === field.value);
if (newActiveField) {
setActiveField(newActiveField);
}
}, [value, fields]);
useEffect(() => {
dispatch({
type: 'update',
orIndex,
andIndex,
value: debouncedValue || '',
});
}, [debouncedValue, dispatch, orIndex, andIndex]);
const ValueComponent = valueFields[activeField.component] || valueFields.Text;
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__inputs`}>
<div className={`${baseClass}__field`}>
<ReactSelect
value={fields.find((field) => value.field === field.value)}
options={fields}
onChange={(field) => dispatch({
type: 'update',
orIndex,
andIndex,
field,
})}
/>
</div>
<div className={`${baseClass}__operator`}>
<ReactSelect
value={activeField.operators.find((operator) => value.operator === operator.value)}
options={activeField.operators}
onChange={(operator) => dispatch({
type: 'update',
orIndex,
andIndex,
operator,
})}
/>
</div>
<div className={`${baseClass}__value`}>
<RenderCustomComponent
CustomComponent={activeField?.props?.admin?.components?.Filter}
DefaultComponent={ValueComponent}
componentProps={{
...activeField.props,
value: internalValue,
onChange: setInternalValue,
}}
/>
</div>
</div>
<div className={`${baseClass}__actions`}>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => dispatch({
type: 'remove',
orIndex,
andIndex,
})}
/>
<Button
icon="plus"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => dispatch({
type: 'add',
relation: 'and',
orIndex,
andIndex: andIndex + 1,
})}
/>
</div>
</div>
</div>
);
};
Condition.propTypes = {
fields: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string,
value: PropTypes.string,
operators: PropTypes.arrayOf(
PropTypes.shape({}),
),
}),
).isRequired,
value: PropTypes.shape({
field: PropTypes.string,
operator: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.instanceOf(Date),
PropTypes.shape({}),
]),
}).isRequired,
dispatch: PropTypes.func.isRequired,
orIndex: PropTypes.number.isRequired,
andIndex: PropTypes.number.isRequired,
};
export default Condition;

View File

@@ -0,0 +1,52 @@
@import '../../../../scss/styles.scss';
.condition {
&__wrap {
display: flex;
align-items: center;
}
&__inputs {
display: flex;
flex-grow: 1;
> div {
flex-basis: 100%;
}
}
&__field,
&__operator {
margin-right: $baseline;
}
&__actions {
flex-shrink: 0;
}
.btn {
vertical-align: middle;
margin: 0 0 0 $baseline;
}
@include mid-break {
&__wrap {
align-items: initial;
}
&__inputs {
display: block;
}
&__actions {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__field,
&__operator {
margin: 0 0 base(.5);
}
}
}

View File

@@ -0,0 +1,96 @@
const boolean = [
{
label: 'equals',
value: 'equals',
},
{
label: 'is not equal to',
value: 'not_equals',
},
];
const base = [
...boolean,
{
label: 'is in',
value: 'in',
},
{
label: 'is not in',
value: 'not_in',
},
];
const numeric = [
...base,
{
label: 'is greater than',
value: 'greater_than',
},
{
label: 'is less than',
value: 'less_than',
},
{
label: 'is less than or equal to',
value: 'less_than_equals',
},
{
label: 'is greater than or equal to',
value: 'greater_than_equals',
},
];
const like = {
label: 'is like',
value: 'like',
};
const fieldTypeConditions = {
text: {
component: 'Text',
operators: [...base, like],
},
email: {
component: 'Text',
operators: [...base, like],
},
textarea: {
component: 'Text',
operators: [...base, like],
},
wysiwyg: {
component: 'Text',
operators: [...base, like],
},
code: {
component: 'Text',
operators: [...base, like],
},
number: {
component: 'Number',
operators: [...base, ...numeric],
},
date: {
component: 'Date',
operators: [...base, ...numeric],
},
upload: {
component: 'Text',
operators: [...base],
},
relationship: {
component: 'Text',
operators: [...base],
},
select: {
component: 'Text',
operators: [...base],
},
checkbox: {
component: 'Text',
operators: boolean,
},
};
export default fieldTypeConditions;

View File

@@ -0,0 +1,172 @@
import React, { useState, useReducer } from 'react';
import PropTypes from 'prop-types';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import Button from '../Button';
import reducer from './reducer';
import Condition from './Condition';
import fieldTypes from './field-types';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import './index.scss';
const baseClass = 'where-builder';
const validateWhereQuery = (query) => {
if (query.or.length > 0 && query.or[0].and && query.or[0].and.length > 0) {
return query;
}
return null;
};
const reduceFields = (fields) => flattenTopLevelFields(fields).reduce((reduced, field) => {
if (typeof fieldTypes[field.type] === 'object') {
const formattedField = {
label: field.label,
value: field.name,
...fieldTypes[field.type],
props: {
...field,
},
};
return [
...reduced,
formattedField,
];
}
return reduced;
}, []);
const WhereBuilder = (props) => {
const {
collection,
collection: {
labels: {
plural,
} = {},
} = {},
handleChange,
} = props;
const [where, dispatchWhere] = useReducer(reducer, []);
const [reducedFields] = useState(() => reduceFields(collection.fields));
useThrottledEffect(() => {
let whereQuery = {
or: [],
};
if (where) {
whereQuery.or = where.map((or) => or.reduce((conditions, condition) => {
const { field, operator, value } = condition;
if (field && operator && value) {
return {
and: [
...conditions.and,
{
[field]: {
[operator]: value,
},
},
],
};
}
return conditions;
}, {
and: [],
}));
}
whereQuery = validateWhereQuery(whereQuery);
if (typeof handleChange === 'function') handleChange(whereQuery);
}, 500, [where, handleChange]);
return (
<div className={baseClass}>
{where.length > 0 && (
<React.Fragment>
<div className={`${baseClass}__label`}>
Filter
{' '}
{plural}
{' '}
where
</div>
<ul className={`${baseClass}__or-filters`}>
{where.map((or, orIndex) => (
<li key={orIndex}>
{orIndex !== 0 && (
<div className={`${baseClass}__label`}>
Or
</div>
)}
<ul className={`${baseClass}__and-filters`}>
{or && or.map((_, andIndex) => (
<li key={andIndex}>
{andIndex !== 0 && (
<div className={`${baseClass}__label`}>
And
</div>
)}
<Condition
value={where[orIndex][andIndex]}
orIndex={orIndex}
andIndex={andIndex}
key={andIndex}
fields={reducedFields}
dispatch={dispatchWhere}
/>
</li>
))}
</ul>
</li>
))}
</ul>
<Button
className={`${baseClass}__add-or`}
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
>
Or
</Button>
</React.Fragment>
)}
{where.length === 0 && (
<div className={`${baseClass}__no-filters`}>
<div className={`${baseClass}__label`}>No filters set</div>
<Button
className={`${baseClass}__add-first-filter`}
icon="plus"
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
>
Add filter
</Button>
</div>
)}
</div>
);
};
WhereBuilder.propTypes = {
handleChange: PropTypes.func.isRequired,
collection: PropTypes.shape({
fields: PropTypes.arrayOf(
PropTypes.shape({}),
),
labels: PropTypes.shape({
plural: PropTypes.string,
}),
}).isRequired,
};
export default WhereBuilder;

View File

@@ -0,0 +1,26 @@
@import '../../../scss/styles.scss';
.where-builder {
background: $color-background-gray;
padding: base(.5) $baseline $baseline;
&__label {
margin: base(.5) 0;
}
&__or-filters,
&__and-filters {
list-style: none;
margin: 0;
padding: 0;
}
&__add-or,
&__add-first-filter {
margin-bottom: 0;
}
&__add-first-filter {
margin-top: 0;
}
}

View File

@@ -0,0 +1,63 @@
const reducer = (state, action = {}) => {
const newState = [...state];
const {
type,
relation,
orIndex,
andIndex,
field,
operator,
value,
} = action;
switch (type) {
case 'add': {
if (relation === 'and') {
newState[orIndex].splice(andIndex, 0, {});
return newState;
}
return [
...newState,
[{}],
];
}
case 'remove': {
newState[orIndex].splice(andIndex, 1);
if (newState[orIndex].length === 0) {
newState.splice(orIndex, 1);
}
return newState;
}
case 'update': {
newState[orIndex][andIndex] = {
...newState[orIndex][andIndex],
};
if (operator) {
newState[orIndex][andIndex].operator = operator;
}
if (field) {
newState[orIndex][andIndex].field = field;
}
if (value !== undefined) {
newState[orIndex][andIndex].value = value;
}
return newState;
}
default: {
return newState;
}
}
};
export default reducer;

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { render } from '@testing-library/react';
import Separator from './Paginator/Separator';
describe('Elements', () => {
describe('Paginator', () => {
it('separator - renders dash', () => {
const { getByText } = render(<Separator />);
const linkElement = getByText(/—/i); // &mdash;
expect(linkElement).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,125 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import Popup from '../../../elements/Popup';
import BlockSelector from '../../field-types/Blocks/BlockSelector';
import './index.scss';
const baseClass = 'action-panel';
const ActionPanel = (props) => {
const {
addRow,
removeRow,
label,
blockType,
blocks,
rowIndex,
isHovered,
} = props;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__remove-row`}
round
buttonStyle="none"
icon="x"
iconPosition="left"
iconStyle="with-border"
onClick={removeRow}
/>
)}
>
Remove&nbsp;
{label}
</Popup>
{blockType === 'blocks'
? (
<Popup
buttonType="custom"
size="large"
horizontalAlign="center"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
/>
)}
render={({ close }) => (
<BlockSelector
blocks={blocks}
addRow={addRow}
addRowIndex={rowIndex}
close={close}
parentIsHovered={isHovered}
watchParentHover
/>
)}
/>
)
: (
<Popup
showOnHover
size="wide"
color="dark"
horizontalAlign="center"
buttonType="custom"
button={(
<Button
className={`${baseClass}__add-row`}
round
buttonStyle="none"
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={addRow}
/>
)}
>
Add&nbsp;
{label}
</Popup>
)}
</div>
);
};
ActionPanel.defaultProps = {
label: 'Row',
blockType: null,
isHovered: false,
blocks: [],
};
ActionPanel.propTypes = {
label: PropTypes.string,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
blockType: PropTypes.oneOf(['blocks', 'array']),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
),
isHovered: PropTypes.bool,
rowIndex: PropTypes.number.isRequired,
};
export default ActionPanel;

View File

@@ -0,0 +1,12 @@
@import '../../../../scss/styles';
.action-panel {
&__remove-row {
margin: 0 0 base(.3);
}
&__add-row {
margin: base(.3) 0 0;
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../elements/Button';
import './index.scss';
const baseClass = 'position-panel';
const PositionPanel = (props) => {
const { moveRow, positionIndex, rowCount } = props;
const adjustedIndex = positionIndex + 1;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Button
className={`${baseClass}__move-backward ${positionIndex === 0 ? 'first-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex - 1)}
/>
{(adjustedIndex && typeof positionIndex === 'number')
&& <div className={`${baseClass}__current-position`}>{adjustedIndex >= 10 ? adjustedIndex : `0${adjustedIndex}`}</div>}
<Button
className={`${baseClass}__move-forward ${(positionIndex === rowCount - 1) ? 'last-row' : ''}`}
buttonStyle="none"
icon="chevron"
round
onClick={() => moveRow(positionIndex, positionIndex + 1)}
/>
</div>
);
};
PositionPanel.defaultProps = {
positionIndex: null,
};
PositionPanel.propTypes = {
positionIndex: PropTypes.number,
moveRow: PropTypes.func.isRequired,
rowCount: PropTypes.number.isRequired,
};
export default PositionPanel;

View File

@@ -0,0 +1,27 @@
@import '../../../../scss/styles';
.position-panel {
&__move-backward {
transform: rotate(.5turn);
margin: 0;
opacity: 0;
}
&__move-forward {
margin: 0;
opacity: 0;
}
&__current-position {
text-align: center;
color: $color-gray;
}
@include large-break {
padding-right: base(.5);
&__controls {
padding-right: base(.75);
}
}
}

View File

@@ -0,0 +1,63 @@
import React, { useRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import useFieldType from '../../../useFieldType';
import './index.scss';
const baseClass = 'editable-block-title';
const EditableBlockTitle = (props) => {
const { path } = props;
const inputRef = useRef(null);
const inputCloneRef = useRef(null);
const [inputWidth, setInputWidth] = useState(0);
const {
value,
setValue,
} = useFieldType({
path,
});
useEffect(() => {
setInputWidth(inputCloneRef.current.offsetWidth + 5);
}, [value]);
const onKeyDown = (e) => {
const blurKeys = [13, 27];
if (blurKeys.indexOf(e.keyCode) !== -1) inputRef.current.blur();
};
return (
<React.Fragment>
<div className={baseClass}>
<input
ref={inputRef}
id={path}
value={value || ''}
placeholder="Untitled"
type="text"
name={path}
onChange={setValue}
onKeyDown={onKeyDown}
style={{
width: `${inputWidth + 1}px`,
}}
/>
</div>
<span
ref={inputCloneRef}
className={`${baseClass}__input-clone`}
>
{value || 'Untitled'}
</span>
</React.Fragment>
);
};
EditableBlockTitle.propTypes = {
path: PropTypes.string.isRequired,
};
export default EditableBlockTitle;

View File

@@ -0,0 +1,46 @@
@import '../../../../../scss/styles.scss';
.editable-block-title {
position: relative;
z-index: 1;
display: flex;
max-width: 100%;
overflow-x: auto;
&__input-clone {
position: absolute;
top: 0; left: 0;
visibility: hidden;
white-space: pre;
}
&__input-clone,
input {
padding: base(.1) base(.2);
font-family: $font-body;
font-weight: 600;
margin-right: base(.5);
font-size: base(.75);
color: $color-dark-gray
}
input {
border: none;
width: 100%;
margin-left: base(.5);
background-color: transparent;
&:hover {
box-shadow: inset 0px -2px 0px -1px $color-light-gray;
}
&:hover,
&:focus {
outline: 0;
}
&:focus {
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import PropTypes from 'prop-types';
import EditableBlockTitle from './EditableBlockTitle';
import Pill from '../../../elements/Pill';
import './index.scss';
const baseClass = 'section-title';
const SectionTitle = (props) => {
const { label, ...remainingProps } = props;
const classes = [
baseClass,
].filter(Boolean).join(' ');
return (
<div className={classes}>
<Pill pillStyle="light-gray">{label}</Pill>
<EditableBlockTitle {...remainingProps} />
</div>
);
};
SectionTitle.defaultProps = {
label: '',
};
SectionTitle.propTypes = {
label: PropTypes.string,
};
export default SectionTitle;

View File

@@ -0,0 +1,6 @@
@import '../../../../scss/styles';
.section-title {
display: flex;
align-items: center;
}

View File

@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import { Draggable } from 'react-beautiful-dnd';
import ActionPanel from './ActionPanel';
import SectionTitle from './SectionTitle';
import PositionPanel from './PositionPanel';
import Button from '../../elements/Button';
import { NegativeFieldGutterProvider } from '../FieldTypeGutter/context';
import FieldTypeGutter from '../FieldTypeGutter';
import RenderFields from '../RenderFields';
import './index.scss';
const baseClass = 'draggable-section';
const DraggableSection = (props) => {
const {
moveRow,
addRow,
removeRow,
rowIndex,
rowCount,
parentPath,
fieldSchema,
label,
blockType,
fieldTypes,
toggleRowCollapse,
id,
positionPanelVerticalAlignment,
actionPanelVerticalAlignment,
permissions,
isOpen,
readOnly,
} = props;
const [isHovered, setIsHovered] = useState(false);
const classes = [
baseClass,
isOpen ? 'is-open' : 'is-closed',
isHovered && 'is-hovered',
].filter(Boolean).join(' ');
return (
<Draggable
draggableId={id}
index={rowIndex}
isDropDisabled={readOnly}
>
{(providedDrag) => (
<div
ref={providedDrag.innerRef}
className={classes}
onMouseLeave={() => setIsHovered(false)}
onMouseOver={() => setIsHovered(true)}
onFocus={() => setIsHovered(true)}
{...providedDrag.draggableProps}
>
<div className={`${baseClass}__content-wrapper`}>
<FieldTypeGutter
variant="left"
dragHandleProps={providedDrag.dragHandleProps}
>
<PositionPanel
moveRow={moveRow}
rowCount={rowCount}
positionIndex={rowIndex}
verticalAlignment={positionPanelVerticalAlignment}
/>
</FieldTypeGutter>
<div className={`${baseClass}__render-fields-wrapper`}>
{blockType === 'blocks' && (
<div className={`${baseClass}__section-header`}>
<SectionTitle
label={label}
path={`${parentPath}.${rowIndex}.blockName`}
readOnly={readOnly}
/>
<Button
icon="chevron"
onClick={toggleRowCollapse}
buttonStyle="icon-label"
className={`toggle-collapse toggle-collapse--is-${isOpen ? 'open' : 'closed'}`}
round
/>
</div>
)}
<AnimateHeight
height={isOpen ? 'auto' : 0}
duration={0}
>
<NegativeFieldGutterProvider allow={false}>
<RenderFields
readOnly={readOnly}
fieldTypes={fieldTypes}
key={rowIndex}
permissions={permissions}
fieldSchema={fieldSchema.map((field) => ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
}))}
/>
</NegativeFieldGutterProvider>
</AnimateHeight>
</div>
<FieldTypeGutter
variant="right"
className="actions"
dragHandleProps={providedDrag.dragHandleProps}
>
{!readOnly && (
<ActionPanel
rowIndex={rowIndex}
addRow={addRow}
removeRow={removeRow}
label={label}
verticalAlignment={actionPanelVerticalAlignment}
isHovered={isHovered}
{...props}
/>
)}
</FieldTypeGutter>
</div>
</div>
)}
</Draggable>
);
};
DraggableSection.defaultProps = {
toggleRowCollapse: undefined,
rowCount: null,
initialData: undefined,
label: '',
blockType: '',
isOpen: true,
positionPanelVerticalAlignment: 'sticky',
actionPanelVerticalAlignment: 'sticky',
permissions: {},
readOnly: false,
};
DraggableSection.propTypes = {
moveRow: PropTypes.func.isRequired,
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
toggleRowCollapse: PropTypes.func,
rowIndex: PropTypes.number.isRequired,
parentPath: PropTypes.string.isRequired,
label: PropTypes.string,
fieldSchema: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
rowCount: PropTypes.number,
initialData: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]),
isOpen: PropTypes.bool,
blockType: PropTypes.string,
fieldTypes: PropTypes.shape({}).isRequired,
id: PropTypes.string.isRequired,
positionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
actionPanelVerticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
permissions: PropTypes.shape({}),
readOnly: PropTypes.bool,
};
export default DraggableSection;

View File

@@ -0,0 +1,111 @@
@import '../../../scss/styles.scss';
//////////////////////
// COMPONENT STYLES
//////////////////////
.draggable-section {
padding-bottom: base(.5);
.draggable-section {
padding-bottom: 0;
}
&__content-wrapper {
display: flex;
position: relative;
margin-bottom: $baseline;
}
&__section-header {
@include blur-bg(white);
display: flex;
position: sticky;
top: $top-header-offset;
z-index: 1;
padding: base(.75) base(.75);
margin-left: - base(.75);
margin-right: - base(.75);
width: calc(100% + #{base(1.5)});
.toggle-collapse {
margin: 0 0 0 auto;
transform: rotate(.5turn);
.btn__icon {
background-color: white;
}
&--is-closed {
transform: rotate(0turn);
}
}
}
&__render-fields-wrapper {
width: 100%;
}
&.is-hovered > div {
> .field-type-gutter {
&.actions {
.field-type-gutter__content {
&:hover {
z-index: $z-nav;
}
}
.field-type-gutter__content-container {
box-shadow: none;
}
}
.field-type-gutter__content-container {
box-shadow: #{$style-stroke-width-m} 0px 0px 0px $color-dark-gray;
}
.position-panel__move-forward,
.position-panel__move-backward {
opacity: 1;
&.first-row,
&.last-row {
opacity: .15;
pointer-events: none;
}
}
.position-panel__current-position {
color: $color-dark-gray;
}
}
.toggle-collapse {
@include color-svg(white);
.btn__icon {
background-color: $color-gray;
&:hover {
background-color: $color-dark-gray;
}
}
}
}
label.field-label {
line-height: 1;
padding-bottom: base(.75)
}
@include mid-break {
.position-panel__move-forward,
.position-panel__move-backward {
opacity: 1;
}
&__section-header {
top: $top-header-offset-m;
}
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import Tooltip from '../../elements/Tooltip';
import './index.scss';
const Error = (props) => {
const { showError, message } = props;
if (showError) {
return (
<Tooltip className="error-message">
{message}
</Tooltip>
);
}
return null;
};
Error.defaultProps = {
showError: false,
message: 'Please complete this field.',
};
Error.propTypes = {
showError: PropTypes.bool,
message: PropTypes.string,
};
export default Error;

View File

@@ -0,0 +1,13 @@
@import '../../../scss/styles';
.error-message {
left: auto;
right: base(.5);
transform: none;
background-color: $color-red;
span {
border-top-color: $color-red;
}
}

View File

@@ -0,0 +1,27 @@
import React, { createContext, useContext } from 'react';
import PropTypes from 'prop-types';
import { useWindowInfo } from '@faceless-ui/window-info';
const context = createContext(false);
const { Provider } = context;
export const NegativeFieldGutterProvider = ({ children, allow }) => {
const { breakpoints: { m: midBreak } } = useWindowInfo();
return (
<Provider value={allow && !midBreak}>
{children}
</Provider>
);
};
export const useNegativeFieldGutter = () => useContext(context);
NegativeFieldGutterProvider.defaultProps = {
allow: false,
};
NegativeFieldGutterProvider.propTypes = {
children: PropTypes.node.isRequired,
allow: PropTypes.bool,
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useNegativeFieldGutter } from './context';
import './index.scss';
const baseClass = 'field-type-gutter';
const FieldTypeGutter = (props) => {
const { children, variant, verticalAlignment, className, dragHandleProps } = props;
const allowNegativeGutter = useNegativeFieldGutter();
const classes = [
baseClass,
`${baseClass}--${variant}`,
`${baseClass}--v-align-${verticalAlignment}`,
allowNegativeGutter && `${baseClass}--negative-gutter`,
className && className,
].filter(Boolean).join(' ');
return (
<div
className={classes}
{...dragHandleProps}
>
<div className={`${baseClass}__content-container`}>
<div className={`${baseClass}__content`}>
{children}
</div>
</div>
</div>
);
};
const { oneOf, shape, string, node } = PropTypes;
FieldTypeGutter.defaultProps = {
variant: 'left',
verticalAlignment: 'sticky',
dragHandleProps: {},
className: null,
children: null,
};
FieldTypeGutter.propTypes = {
variant: oneOf(['left', 'right']),
verticalAlignment: PropTypes.oneOf(['top', 'center', 'sticky']),
dragHandleProps: shape({}),
className: string,
children: node,
};
export default FieldTypeGutter;

View File

@@ -0,0 +1,85 @@
@import '../../../scss/styles.scss';
$controls-top-adjustment: base(.1);
@mixin nestedStickyOffsets ($loopCount, $currentCount: 0) {
.field-type {
@if $loopCount > $currentCount {
.field-type-gutter--v-align-sticky .field-type-gutter__content {
top: calc(#{$top-header-offset} + (#{base(2.75)} * #{$currentCount}));
}
@include nestedStickyOffsets($loopCount, $currentCount + 1);
}
}
}
@include nestedStickyOffsets(4);
.field-type-gutter {
&--left {
margin-right: base(1.25);
}
&--right {
padding-right: 0;
padding-left: base(1.25);
.field-type-gutter__content {
margin-bottom: base(1);
}
.field-type-gutter__content-container {
padding-right: 0;
box-shadow: none !important;
}
}
&--v-align-top {
.field-type-gutter__content {
justify-content: flex-start;
}
}
&--v-align-sticky {
.field-type-gutter__content {
position: sticky;
top: $top-header-offset;
height: unset;
}
}
&__content-container {
padding-right: base(.75);
position: relative;
min-height: 100%;
box-shadow: #{$style-stroke-width-s} 0px 0px 0px $color-light-gray;
}
&__content {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
&--negative-gutter {
&.field-type-gutter--left {
position: absolute;
top: 0; right: 100%; bottom: 0;
}
}
@include mid-break {
&--left {
.field-type-gutter__content-container {
padding-right: $style-stroke-width-m;
padding-left: 0;
}
}
&--right {
padding-right: 0;
}
}
}

View File

@@ -0,0 +1,16 @@
const buildInitialState = (data) => {
if (data) {
return Object.entries(data).reduce((state, [path, value]) => ({
...state,
[path]: {
value,
initialValue: value,
valid: true,
},
}), {});
}
return undefined;
};
export default buildInitialState;

View File

@@ -0,0 +1,120 @@
const buildValidationPromise = async (fieldState, field) => {
const validatedFieldState = fieldState;
validatedFieldState.valid = typeof field.validate === 'function' ? await field.validate(fieldState.value, field) : true;
if (typeof validatedFieldState.valid === 'string') {
validatedFieldState.errorMessage = validatedFieldState.valid;
validatedFieldState.valid = false;
}
};
const buildStateFromSchema = async (fieldSchema, fullData = {}) => {
if (fieldSchema) {
const validationPromises = [];
const structureFieldState = (field, data = {}) => {
const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue;
const fieldState = {
value,
initialValue: value,
};
validationPromises.push(buildValidationPromise(fieldState, field));
return fieldState;
};
const iterateFields = (fields, data, path = '') => fields.reduce((state, field) => {
let initialData = data;
if (field.name && field.defaultValue && typeof initialData?.[field.name] === 'undefined') {
initialData = { [field.name]: field.defaultValue };
}
if (field.name) {
if (field.type === 'relationship' && initialData?.[field.name] === null) {
initialData[field.name] = 'null';
}
if (field.type === 'array' || field.type === 'blocks') {
if (Array.isArray(initialData?.[field.name])) {
if (field.type === 'array') {
return {
...state,
...initialData[field.name].reduce((rowState, row, i) => ({
...rowState,
...iterateFields(field.fields, row, `${path}${field.name}.${i}.`),
}), {}),
};
}
if (field.type === 'blocks') {
return {
...state,
...initialData[field.name].reduce((rowState, row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType);
const rowPath = `${path}${field.name}.${i}.`;
return {
...rowState,
[`${rowPath}blockType`]: {
value: row.blockType,
initialValue: row.blockType,
valid: true,
},
[`${rowPath}blockName`]: {
value: row.blockName,
initialValue: row.blockName,
valid: true,
},
...(block?.fields ? iterateFields(block.fields, row, rowPath) : {}),
};
}, {}),
};
}
}
return state;
}
// Handle non-array-based nested fields (group, etc)
if (field.fields) {
return {
...state,
...iterateFields(field.fields, initialData?.[field.name], `${path}${field.name}.`),
};
}
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}
// Handle field types that do not use names (row, etc)
if (field.fields) {
return {
...state,
...iterateFields(field.fields, data, path),
};
}
// Handle normal fields
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, data),
};
}, {});
const resultingState = iterateFields(fieldSchema, fullData);
await Promise.all(validationPromises);
return resultingState;
}
return {};
};
export default buildStateFromSchema;

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