renames client to admin, sets up component library
This commit is contained in:
232
src/admin/components/Routes.js
Normal file
232
src/admin/components/Routes.js
Normal 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);
|
||||
69
src/admin/components/elements/Banner/index.js
Normal file
69
src/admin/components/elements/Banner/index.js
Normal 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;
|
||||
74
src/admin/components/elements/Banner/index.scss
Normal file
74
src/admin/components/elements/Banner/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/admin/components/elements/Button/index.js
Normal file
171
src/admin/components/elements/Button/index.js
Normal 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;
|
||||
177
src/admin/components/elements/Button/index.scss
Normal file
177
src/admin/components/elements/Button/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/admin/components/elements/Card/index.js
Normal file
49
src/admin/components/elements/Card/index.js
Normal 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;
|
||||
41
src/admin/components/elements/Card/index.scss
Normal file
41
src/admin/components/elements/Card/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
95
src/admin/components/elements/ColumnSelector/index.js
Normal file
95
src/admin/components/elements/ColumnSelector/index.js
Normal 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;
|
||||
11
src/admin/components/elements/ColumnSelector/index.scss
Normal file
11
src/admin/components/elements/ColumnSelector/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
75
src/admin/components/elements/CopyToClipboard/index.js
Normal file
75
src/admin/components/elements/CopyToClipboard/index.js
Normal 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;
|
||||
35
src/admin/components/elements/CopyToClipboard/index.scss
Normal file
35
src/admin/components/elements/CopyToClipboard/index.scss
Normal 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;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/admin/components/elements/DatePicker/DatePicker.js
Normal file
112
src/admin/components/elements/DatePicker/DatePicker.js
Normal 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;
|
||||
5
src/admin/components/elements/DatePicker/date-picker.md
Normal file
5
src/admin/components/elements/DatePicker/date-picker.md
Normal 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)
|
||||
10
src/admin/components/elements/DatePicker/index.js
Normal file
10
src/admin/components/elements/DatePicker/index.js
Normal 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>
|
||||
);
|
||||
234
src/admin/components/elements/DatePicker/index.scss
Normal file
234
src/admin/components/elements/DatePicker/index.scss
Normal 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)});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/admin/components/elements/DeleteDocument/index.js
Normal file
156
src/admin/components/elements/DeleteDocument/index.js
Normal 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}
|
||||
{' '}
|
||||
"
|
||||
<strong>
|
||||
{titleToRender}
|
||||
</strong>
|
||||
". 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;
|
||||
16
src/admin/components/elements/DeleteDocument/index.scss
Normal file
16
src/admin/components/elements/DeleteDocument/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/admin/components/elements/DuplicateDocument/index.js
Normal file
43
src/admin/components/elements/DuplicateDocument/index.js
Normal 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;
|
||||
26
src/admin/components/elements/Eyebrow/index.js
Normal file
26
src/admin/components/elements/Eyebrow/index.js
Normal 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;
|
||||
19
src/admin/components/elements/Eyebrow/index.scss
Normal file
19
src/admin/components/elements/Eyebrow/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
72
src/admin/components/elements/FileDetails/Meta/index.js
Normal file
72
src/admin/components/elements/FileDetails/Meta/index.js
Normal 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>
|
||||
-
|
||||
{width}
|
||||
x
|
||||
{height}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{mimeType && (
|
||||
<React.Fragment>
|
||||
-
|
||||
{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;
|
||||
23
src/admin/components/elements/FileDetails/Meta/index.scss
Normal file
23
src/admin/components/elements/FileDetails/Meta/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
117
src/admin/components/elements/FileDetails/index.js
Normal file
117
src/admin/components/elements/FileDetails/index.js
Normal 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;
|
||||
97
src/admin/components/elements/FileDetails/index.scss
Normal file
97
src/admin/components/elements/FileDetails/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/admin/components/elements/GenerateConfirmation/index.js
Normal file
86
src/admin/components/elements/GenerateConfirmation/index.js
Normal 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;
|
||||
@@ -0,0 +1,12 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.generate-confirmation {
|
||||
@include blur-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.btn {
|
||||
margin-right: $baseline;
|
||||
}
|
||||
}
|
||||
145
src/admin/components/elements/ListControls/index.js
Normal file
145
src/admin/components/elements/ListControls/index.js
Normal 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;
|
||||
75
src/admin/components/elements/ListControls/index.scss
Normal file
75
src/admin/components/elements/ListControls/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/admin/components/elements/Loading/index.js
Normal file
5
src/admin/components/elements/Loading/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const Loading = () => <div>Loading</div>;
|
||||
|
||||
export default Loading;
|
||||
74
src/admin/components/elements/Localizer/index.js
Normal file
74
src/admin/components/elements/Localizer/index.js
Normal 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;
|
||||
41
src/admin/components/elements/Localizer/index.scss
Normal file
41
src/admin/components/elements/Localizer/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/admin/components/elements/Nav/index.js
Normal file
147
src/admin/components/elements/Nav/index.js
Normal 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;
|
||||
178
src/admin/components/elements/Nav/index.scss
Normal file
178
src/admin/components/elements/Nav/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/admin/components/elements/Paginator/Page/index.js
Normal file
43
src/admin/components/elements/Paginator/Page/index.js
Normal 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const Separator = () => <span className="paginator__separator">—</span>;
|
||||
|
||||
export default Separator;
|
||||
163
src/admin/components/elements/Paginator/index.js
Normal file
163
src/admin/components/elements/Paginator/index.js
Normal 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,
|
||||
};
|
||||
48
src/admin/components/elements/Paginator/index.scss
Normal file
48
src/admin/components/elements/Paginator/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/admin/components/elements/Pill/index.js
Normal file
69
src/admin/components/elements/Pill/index.js
Normal 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;
|
||||
70
src/admin/components/elements/Pill/index.scss
Normal file
70
src/admin/components/elements/Pill/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/admin/components/elements/Popup/PopupButton/index.js
Normal file
65
src/admin/components/elements/Popup/PopupButton/index.js
Normal 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.popup-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
166
src/admin/components/elements/Popup/index.js
Normal file
166
src/admin/components/elements/Popup/index.js
Normal 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;
|
||||
279
src/admin/components/elements/Popup/index.scss
Normal file
279
src/admin/components/elements/Popup/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/admin/components/elements/PreviewButton/index.js
Normal file
40
src/admin/components/elements/PreviewButton/index.js
Normal 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;
|
||||
110
src/admin/components/elements/ReactSelect/index.js
Normal file
110
src/admin/components/elements/ReactSelect/index.js
Normal 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;
|
||||
107
src/admin/components/elements/ReactSelect/index.scss
Normal file
107
src/admin/components/elements/ReactSelect/index.scss
Normal 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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/admin/components/elements/RenderTitle/index.js
Normal file
59
src/admin/components/elements/RenderTitle/index.js
Normal 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:
|
||||
</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;
|
||||
12
src/admin/components/elements/RenderTitle/index.scss
Normal file
12
src/admin/components/elements/RenderTitle/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/admin/components/elements/SearchFilter/index.js
Normal file
55
src/admin/components/elements/SearchFilter/index.js
Normal 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;
|
||||
16
src/admin/components/elements/SearchFilter/index.scss
Normal file
16
src/admin/components/elements/SearchFilter/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/admin/components/elements/SortColumn/index.js
Normal file
67
src/admin/components/elements/SortColumn/index.js
Normal 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;
|
||||
51
src/admin/components/elements/SortColumn/index.scss
Normal file
51
src/admin/components/elements/SortColumn/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/admin/components/elements/Status/index.js
Normal file
101
src/admin/components/elements/Status/index.js
Normal 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;
|
||||
47
src/admin/components/elements/Status/index.scss
Normal file
47
src/admin/components/elements/Status/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/admin/components/elements/Status/reducer.js
Normal file
32
src/admin/components/elements/Status/reducer.js
Normal 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;
|
||||
77
src/admin/components/elements/StepNav/index.js
Normal file
77
src/admin/components/elements/StepNav/index.js
Normal 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;
|
||||
44
src/admin/components/elements/StepNav/index.scss
Normal file
44
src/admin/components/elements/StepNav/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/admin/components/elements/Table/index.js
Normal file
60
src/admin/components/elements/Table/index.js
Normal 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;
|
||||
35
src/admin/components/elements/Table/index.scss
Normal file
35
src/admin/components/elements/Table/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/admin/components/elements/Thumbnail/index.js
Normal file
56
src/admin/components/elements/Thumbnail/index.js
Normal 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;
|
||||
47
src/admin/components/elements/Thumbnail/index.scss
Normal file
47
src/admin/components/elements/Thumbnail/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/admin/components/elements/Tooltip/index.js
Normal file
31
src/admin/components/elements/Tooltip/index.js
Normal 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;
|
||||
26
src/admin/components/elements/Tooltip/index.scss
Normal file
26
src/admin/components/elements/Tooltip/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/admin/components/elements/UploadCard/index.js
Normal file
69
src/admin/components/elements/UploadCard/index.js
Normal 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;
|
||||
19
src/admin/components/elements/UploadCard/index.scss
Normal file
19
src/admin/components/elements/UploadCard/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
46
src/admin/components/elements/UploadGallery/index.js
Normal file
46
src/admin/components/elements/UploadGallery/index.js
Normal 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;
|
||||
32
src/admin/components/elements/UploadGallery/index.scss
Normal file
32
src/admin/components/elements/UploadGallery/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.condition-value-number {
|
||||
@include formInput;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.condition-value-text {
|
||||
@include formInput;
|
||||
}
|
||||
148
src/admin/components/elements/WhereBuilder/Condition/index.js
Normal file
148
src/admin/components/elements/WhereBuilder/Condition/index.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/admin/components/elements/WhereBuilder/field-types.js
Normal file
96
src/admin/components/elements/WhereBuilder/field-types.js
Normal 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;
|
||||
172
src/admin/components/elements/WhereBuilder/index.js
Normal file
172
src/admin/components/elements/WhereBuilder/index.js
Normal 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;
|
||||
26
src/admin/components/elements/WhereBuilder/index.scss
Normal file
26
src/admin/components/elements/WhereBuilder/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/admin/components/elements/WhereBuilder/reducer.js
Normal file
63
src/admin/components/elements/WhereBuilder/reducer.js
Normal 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;
|
||||
13
src/admin/components/elements/elements.spec.js
Normal file
13
src/admin/components/elements/elements.spec.js
Normal 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); // —
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
125
src/admin/components/forms/DraggableSection/ActionPanel/index.js
Normal file
125
src/admin/components/forms/DraggableSection/ActionPanel/index.js
Normal 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
|
||||
{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
|
||||
{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;
|
||||
@@ -0,0 +1,12 @@
|
||||
@import '../../../../scss/styles';
|
||||
|
||||
.action-panel {
|
||||
|
||||
&__remove-row {
|
||||
margin: 0 0 base(.3);
|
||||
}
|
||||
|
||||
&__add-row {
|
||||
margin: base(.3) 0 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '../../../../scss/styles';
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
173
src/admin/components/forms/DraggableSection/index.js
Normal file
173
src/admin/components/forms/DraggableSection/index.js
Normal 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;
|
||||
111
src/admin/components/forms/DraggableSection/index.scss
Normal file
111
src/admin/components/forms/DraggableSection/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/admin/components/forms/Error/index.js
Normal file
31
src/admin/components/forms/Error/index.js
Normal 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;
|
||||
13
src/admin/components/forms/Error/index.scss
Normal file
13
src/admin/components/forms/Error/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/admin/components/forms/FieldTypeGutter/context.js
Normal file
27
src/admin/components/forms/FieldTypeGutter/context.js
Normal 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,
|
||||
};
|
||||
53
src/admin/components/forms/FieldTypeGutter/index.js
Normal file
53
src/admin/components/forms/FieldTypeGutter/index.js
Normal 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;
|
||||
85
src/admin/components/forms/FieldTypeGutter/index.scss
Normal file
85
src/admin/components/forms/FieldTypeGutter/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/admin/components/forms/Form/buildInitialState.js
Normal file
16
src/admin/components/forms/Form/buildInitialState.js
Normal 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;
|
||||
120
src/admin/components/forms/Form/buildStateFromSchema.js
Normal file
120
src/admin/components/forms/Form/buildStateFromSchema.js
Normal 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
Reference in New Issue
Block a user