merge with master

This commit is contained in:
Jarrod Flesch
2020-03-30 10:28:00 -04:00
60 changed files with 1723 additions and 12672 deletions

View File

@@ -7,17 +7,14 @@ const authRoutes = require('./routes');
const initUsers = (User, config, router) => {
passport.use(User.createStrategy());
const { user: userConfig } = config;
passport.use(jwtStrategy(User, config));
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());
if (userConfig.auth.strategy === 'jwt') {
passport.use(jwtStrategy(User));
passport.serializeUser(User.serializeUser());
passport.deserializeUser(User.deserializeUser());
}
passport.use(new AnonymousStrategy.Strategy());
router.use('', initRoutes(User));
router.use('', authRoutes(userConfig, User));
router.use('', authRoutes(config, User));
};
module.exports = initUsers;

View File

@@ -3,14 +3,16 @@ const passportJwt = require('passport-jwt');
const JwtStrategy = passportJwt.Strategy;
const { ExtractJwt } = passportJwt;
const opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderWithScheme('JWT');
opts.secretOrKey = process.env.secret || 'SECRET_KEY';
module.exports = (User, config) => {
const opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeaderWithScheme('JWT');
opts.secretOrKey = config.user.auth.secretKey;
module.exports = User => new JwtStrategy(opts, (token, done) => {
console.log(`Token authenticated for user: ${token.email}`);
User.findByUsername(token.email, (err, user) => {
if (err || !user) done(null, false);
return done(null, user);
return new JwtStrategy(opts, (token, done) => {
console.log(`Token authenticated for user: ${token.email}`);
User.findByUsername(token.email, (err, user) => {
if (err || !user) done(null, false);
return done(null, user);
});
});
});
};

View File

@@ -4,159 +4,153 @@ const nodemailer = require('nodemailer');
const crypto = require('crypto');
const router = express.Router();
const passwordResetRoutes = (emailConfig, User) => {
const mockEmailHandler = async () => {
const testAccount = await nodemailer.createTestAccount();
const smtpOptions = {
...emailConfig,
host: 'smtp.ethereal.email',
port: 587,
secure: false,
fromName: 'John Doe',
fromAddress: 'john.doe@payloadcms.com',
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
return nodemailer.createTransport(smtpOptions);
};
const sendResetEmail = async (req, res, next) => {
let emailHandler;
try {
switch (emailConfig.provider) {
case 'mock':
emailHandler = await mockEmailHandler();
break;
case 'smtp':
emailHandler = nodemailer.createTransport(emailConfig);
break;
default:
emailHandler = await mockEmailHandler(req, res, next, emailConfig);
}
const generateToken = () => new Promise(resolve => crypto.randomBytes(20, (err, buffer) => resolve(buffer.toString('hex'))));
const token = await generateToken();
User.findOne({ email: req.body.userEmail }, (err, user) => {
if (!user) {
const message = `No account with email ${req.body.userEmail} address exists.`;
return res.status(400).json({ message });
}
user.resetPasswordToken = token;
user.resetPasswordExpiration = Date.now() + 3600000; // 1 hour
user.save(async (saveError) => {
if (saveError) {
const message = 'Error saving temporary reset token';
console.error(message, saveError);
res.status(500).json({ message });
}
console.log('Temporary reset token created and saved to DB');
const emailText = `You are receiving this because you (or someone else) have requested the reset of the password for your account.
Please click on the following link, or paste this into your browser to complete the process:
${req.protocol}://${req.headers.host}/reset/${token}
If you did not request this, please ignore this email and your password will remain unchanged.`;
await emailHandler.sendMail({
from: `"${emailConfig.fromName}" <${emailConfig.fromAddress}>`,
to: req.body.userEmail,
subject: req.body.subject || 'Password Reset',
text: emailText,
});
res.sendStatus(200);
});
});
} catch (e) {
console.error(e);
res.status(500).json({ success: false, error: 'Unable to send e-mail' });
}
};
const validateForgotRequestBody = (req, res, next) => {
if (Object.prototype.hasOwnProperty.call(req.body, 'userEmail')) {
next();
} else {
return res.status(400).json({
message: 'Missing userEmail in request body',
});
}
};
const validateResetPasswordBody = (req, res, next) => {
if (Object.prototype.hasOwnProperty.call(req.body, 'token') && Object.prototype.hasOwnProperty.call(req.body, 'password')) {
next();
} else {
return res.status(400).json({
message: 'Invalid request body',
});
}
};
const resetPassword = (req, res) => {
User.findOne(
{
resetPasswordToken: req.body.token,
resetPasswordExpiration: { $gt: Date.now() },
},
async (err, user) => {
if (!user) {
const message = 'Password reset token is invalid or has expired.';
console.error(message);
return res.status(400).json({ message });
}
const errorMessage = 'Error setting new user password';
try {
await user.setPassword(req.body.password);
user.resetPasswordExpiration = Date.now();
await user.save((saveError) => {
if (saveError) {
console.error(errorMessage);
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: 'Password successfully reset' });
});
} catch (e) {
return res.status(500).json({ message: errorMessage });
}
},
);
};
router
.route('/forgot')
.post(
passport.authenticate('jwt', { session: false }),
(req, res, next) => validateForgotRequestBody(req, res, next),
(req, res, next) => sendResetEmail(req, res, next, emailConfig, User)
validateForgotRequestBody,
sendResetEmail,
);
router
.route('/reset')
.post(
(req, res, next) => validateResetPasswordBody(req, res, next),
(req, res) => resetPassword(req, res, User)
(req, res) => resetPassword(req, res, User),
);
return router;
};
const sendResetEmail = async (req, res, next, emailConfig, User) => {
let emailHandler;
try {
switch (emailConfig.provider) {
case 'mock':
emailHandler = await mockEmailHandler(req, res, next, emailConfig);
break;
case 'smtp':
emailHandler = smtpEmailHandler(req, res, next, emailConfig);
break;
default:
emailHandler = await mockEmailHandler(req, res, next, emailConfig);
}
const generateToken = () => new Promise(resolve =>
crypto.randomBytes(20, (err, buffer) =>
resolve(buffer.toString('hex'))
));
let token = await generateToken();
User.findOne({ email: req.body.userEmail }, (err, user) => {
if (!user) {
const message = `No account with email ${req.body.userEmail} address exists.`;
return res.status(400).json({ message: message })
}
user.resetPasswordToken = token;
user.resetPasswordExpiration = Date.now() + 3600000; // 1 hour
user.save(async (saveError) => {
if (saveError) {
const message = 'Error saving temporary reset token';
console.error(message, saveError);
res.status(500).json({ message });
}
console.log('Temporary reset token created and saved to DB');
const emailText = `You are receiving this because you (or someone else) have requested the reset of the password for your account.
Please click on the following link, or paste this into your browser to complete the process:
${req.protocol}://${req.headers.host}/reset/${token}
If you did not request this, please ignore this email and your password will remain unchanged.`;
await emailHandler.sendMail({
from: `"${emailConfig.fromName}" <${emailConfig.fromAddress}>`,
to: req.body.userEmail,
subject: req.body.subject || 'Password Reset',
text: emailText,
});
res.sendStatus(200);
});
});
} catch (e) {
console.error(e);
res.status(500).json({ success: false, error: 'Unable to send e-mail' });
}
};
const validateForgotRequestBody = (req, res, next) => {
if (req.body.hasOwnProperty('userEmail')) {
next();
} else {
return res.status(400).json({
message: 'Missing userEmail in request body'
})
}
};
const validateResetPasswordBody = (req, res, next) => {
if (req.body.hasOwnProperty('token') && req.body.hasOwnProperty('password')) {
next();
} else {
return res.status(400).json({
message: 'Invalid request body'
})
}
};
const resetPassword = (req, res, User) => {
User.findOne(
{
resetPasswordToken: req.body.token,
resetPasswordExpiration: { $gt: Date.now() }
},
async (err, user) => {
if (!user) {
const message = 'Password reset token is invalid or has expired.';
console.error(message);
return res.status(400).json({ message });
}
const errorMessage = 'Error setting new user password';
try {
await user.setPassword(req.body.password);
user.resetPasswordExpiration = Date.now();
await user.save(saveError => {
if (saveError) {
console.error(errorMessage);
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: 'Password successfully reset' });
});
} catch (e) {
return res.status(500).json({ message: errorMessage });
}
})
};
const mockEmailHandler = async (req, res, next, emailConfig) => {
let testAccount = await nodemailer.createTestAccount();
process.env.EMAIL_USER = testAccount.user;
process.env.EMAIL_PASS = testAccount.pass;
emailConfig.host = 'smtp.ethereal.email';
emailConfig.port = 587;
emailConfig.secure = false;
emailConfig.fromName = 'John Doe';
emailConfig.fromAddress = 'john.doe@payloadcms.com';
return smtpEmailHandler(req, res, next, emailConfig);
};
const smtpEmailHandler = (req, res, next, emailConfig) => {
return nodemailer.createTransport({
host: emailConfig.host,
port: emailConfig.port,
secure: emailConfig.secure, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
};
module.exports = passwordResetRoutes;

View File

@@ -2,8 +2,9 @@ const jwt = require('jsonwebtoken');
const passport = require('passport');
const httpStatus = require('http-status');
const APIError = require('../errors/APIError');
const formatErrorResponse = require('../responses/formatError');
module.exports = (userConfig, User) => ({
module.exports = (config, User) => ({
/**
* Returns User when succesfully registered
* @param req
@@ -12,7 +13,7 @@ module.exports = (userConfig, User) => ({
* @returns {*}
*/
register: (req, res, next) => {
const usernameField = userConfig.useAsUsername || 'email';
const usernameField = config.user.auth.useAsUsername;
User.register(new User({ usernameField: req.body[usernameField] }), req.body.password, (err, user) => {
if (err) {
@@ -20,33 +21,39 @@ module.exports = (userConfig, User) => ({
return next(error);
}
return passport.authenticate('local')(req, res, () => {
return res.json({ [usernameField]: user[usernameField], role: user.role, createdAt: user.createdAt });
return res.json({
[usernameField]: user[usernameField],
role: user.role,
createdAt: user.createdAt,
});
});
});
},
/**
* Returns passport login response (cookie) when valid username and password is provided
* Returns passport login response (JWT) when valid username and password is provided
* @param req
* @param res
* @returns {*}
*/
login: (req, res) => {
const usernameField = userConfig.useAsUsername || 'email';
const usernameField = config.user.auth.useAsUsername;
const username = req.body[usernameField];
const { password } = req.body;
User.findByUsername(username, (err, user) => {
if (err || !user) return res.status(401).json({ message: 'Auth Failed' });
if (err || !user) {
return res.status(httpStatus.UNAUTHORIZED).json(formatErrorResponse(err, 'mongoose'));
}
return user.authenticate(password, (authErr, model, passwordError) => {
if (authErr || passwordError) return res.status(401).json({ message: 'Auth Failed' });
if (authErr || passwordError) return new APIError('Authentication Failed', httpStatus.UNAUTHORIZED);
const opts = {};
opts.expiresIn = process.env.tokenExpiration || 7200;
const secret = process.env.secret || 'SECRET_KEY';
opts.expiresIn = config.user.auth.tokenExpiration;
const secret = config.user.auth.secretKey;
const fieldsToSign = userConfig.fields.reduce((acc, field) => {
const fieldsToSign = config.user.fields.reduce((acc, field) => {
if (field.saveToJWT) acc[field.name] = user[field.name];
return acc;
}, {
@@ -54,14 +61,40 @@ module.exports = (userConfig, User) => ({
});
const token = jwt.sign(fieldsToSign, secret, opts);
return res.status(200).json({
message: 'Auth Passed',
token,
});
return res.status(200)
.json({
message: 'Auth Passed',
token,
});
});
});
},
/**
* Refresh an expired or soon to be expired auth token
* @param req
* @param res
* @param next
*/
refresh: (req, res, next) => {
const { token } = req.body;
const secret = config.user.auth.secretKey;
const opts = {};
opts.expiresIn = config.user.auth.tokenExpiration;
try {
jwt.verify(token, secret, {});
const refreshToken = jwt.sign(token, secret);
res.status(200)
.json({
message: 'Token Refresh Successful',
refreshToken,
});
} catch (e) {
next(new APIError('Authentication error', httpStatus.UNAUTHORIZED));
}
},
/**
* Returns User if user session is still open
* @param req
@@ -69,7 +102,8 @@ module.exports = (userConfig, User) => ({
* @returns {*}
*/
me: (req, res) => {
return res.status(200).send(req.user);
return res.status(200)
.send(req.user);
},
/**

View File

@@ -6,45 +6,45 @@ const authRequestHandlers = require('./requestHandlers');
const passwordResetRoutes = require('./passwordResets/routes');
const router = express.Router();
const authRoutes = (userConfig, User) => {
const auth = authRequestHandlers(userConfig, User);
const authRoutes = (config, User) => {
const auth = authRequestHandlers(config, User);
router
.route('/login')
.post(auth.login);
router
.route('/refresh')
.post(auth.refresh);
router
.route('/me')
.post(passport.authenticate(userConfig.auth.strategy, { session: false }), auth.me);
.post(passport.authenticate('jwt', { session: false }), auth.me);
if (userConfig.auth.passwordResets) {
router.use('', passwordResetRoutes(userConfig.email, User));
}
router.use('', passwordResetRoutes(config.email, User));
if (userConfig.auth.registration) {
router
.route(`${userConfig.slug}/register`) // TODO: not sure how to incorporate url params like `:pageId`
.post(auth.register);
router
.route(`${config.user.slug}/register`)
.post(auth.register);
router
.route('/first-register')
.post((req, res, next) => {
User.countDocuments({}, (err, count) => {
if (err) res.status(500).json({ error: err });
if (count >= 1) return res.status(403).json({ initialized: true });
return next();
});
}, (req, res, next) => {
User.register(new User(req.body), req.body.password, (err) => {
if (err) {
const error = new APIError('Authentication error', httpStatus.UNAUTHORIZED);
return res.status(httpStatus.UNAUTHORIZED).json(error);
}
router
.route('/first-register')
.post((req, res, next) => {
User.countDocuments({}, (err, count) => {
if (err) res.status(500).json({ error: err });
if (count >= 1) return res.status(403).json({ initialized: true });
return next();
});
}, (req, res, next) => {
User.register(new User(req.body), req.body.password, (err) => {
if (err) {
const error = new APIError('Authentication error', httpStatus.UNAUTHORIZED);
return res.status(httpStatus.UNAUTHORIZED).json(error);
}
return next();
});
}, auth.login);
}
return next();
});
}, auth.login);
return router;
};

View File

@@ -15,6 +15,7 @@ import Edit from './views/collections/Edit';
import EditGlobal from './views/globals/Edit';
import { requests } from '../api';
import customComponents from './custom-components';
import RedirectToLogin from './utilities/RedirectToLogin';
const Routes = () => {
const [initialized, setInitialized] = useState(null);
@@ -151,7 +152,7 @@ const Routes = () => {
</Switch>
);
}
return <Redirect to={`${match.url}/login`} />;
return <RedirectToLogin />;
}}
/>
</Switch>

View File

@@ -1,58 +1,65 @@
import React, { Component } from 'react';
import React from 'react';
import { Link } from 'react-router-dom';
import './index.scss';
class Button extends Component {
constructor(props) {
super(props);
const baseClass = 'btn';
let classes = this.props.className ? `btn ${this.props.className}` : 'btn';
const Button = (props) => {
const {
className, type, size, icon, el, to, url, children, onClick,
} = props;
if (this.props.type) {
classes += ` btn-${this.props.type}`;
}
const classes = [
baseClass,
className && className,
type && `btn-${type}`,
size && `btn-${size}`,
icon && 'btn-icon',
].filter(Boolean).join(' ');
if (this.props.size) {
classes += ` btn-${this.props.size}`;
}
if (this.props.icon) {
classes += ' btn-icon';
}
this.buttonProps = {
...this.props,
className: classes,
onClick: this.props.onClick,
disabled: this.props.disabled
};
function handleClick(event) {
if (type !== 'submit' && onClick) event.preventDefault();
if (onClick) onClick();
}
render() {
switch (this.props.el) {
case 'link':
return (
<Link {...this.buttonProps} to={this.props.to ? this.props.to : this.props.url}>
{this.props.children}
</Link>
);
const buttonProps = {
...props,
className: classes,
onClick: handleClick,
};
case 'anchor':
return (
<a {...this.buttonProps} href={this.props.url}>
{this.props.children}
</a>
);
switch (el) {
case 'link':
return (
<Link
{...buttonProps}
to={to || url}
>
{children}
</Link>
);
default:
return (
<button {...this.buttonProps}>
{this.props.children}
</button>
);
}
case 'anchor':
return (
<a
{...buttonProps}
href={url}
>
{children}
</a>
);
default:
return (
<button
type="button"
{...buttonProps}
>
{children}
</button>
);
}
}
};
export default Button;

View File

@@ -32,6 +32,18 @@
}
}
&.btn-error {
border: $stroke-width solid $error;
color: $error;
background: none;
&:hover {
background: lighten($error, 22.5%);
border: $stroke-width solid $error;
color: $black;
}
}
&.btn-small {
padding: base(0) base(.25) base(.04);
border-radius: 3px;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../Button';
import Crosshair from '../../graphics/Crosshair';
import Arrow from '../../graphics/Arrow';
import './index.scss';
const baseClass = 'icon-button';
const IconButton = React.forwardRef(({ iconName, className, ...rest }, ref) => {
const classes = [
baseClass,
className && className,
`${baseClass}--${iconName}`,
].filter(Boolean).join(' ');
const icons = {
crosshair: Crosshair,
crossOut: Crosshair,
arrow: Arrow,
};
const Icon = icons[iconName] || icons.arrow;
return (
<span ref={ref}>
<Button
className={classes}
{...rest}
>
<Icon />
</Button>
</span>
);
});
IconButton.defaultProps = {
className: '',
};
IconButton.propTypes = {
iconName: PropTypes.oneOf(['arrow', 'crossOut', 'crosshair']).isRequired,
className: PropTypes.string,
};
export default IconButton;

View File

@@ -0,0 +1,28 @@
@import '../../../scss/styles.scss';
.icon-button {
line-height: 0;
background: transparent;
border: 1px solid $black;
margin-top: 0;
&:hover {
background: lighten($black, 50%);
border-color: lighten($black, .2%);
@include color-svg(lighten($black, 5%));
}
&.btn {
padding: 5px;
}
.icon {
@include color-svg($black);
}
&--crossOut {
svg {
transform: rotate(45deg);
}
}
}

View File

@@ -9,14 +9,26 @@ const cookies = new Cookies();
const Context = createContext({});
const UserProvider = ({ children }) => {
const cookieToken = cookies.get('token');
const [token, setToken] = useState('');
const [user, setUser] = useState(cookieToken ? jwtDecode(cookieToken) : null);
const [user, setUser] = useState(null);
useEffect(() => {
const cookieToken = cookies.get('token');
if (cookieToken) {
const decoded = jwtDecode(cookieToken);
if (decoded.exp > Date.now() / 1000) {
setUser(decoded);
}
}
}, []);
useEffect(() => {
if (token) {
setUser(jwtDecode(token));
cookies.set('token', token, { path: '/' });
const decoded = jwtDecode(token);
if (decoded.exp > Date.now() / 1000) {
setUser(decoded);
cookies.set('token', token, { path: '/' });
}
}
}, [token]);

View File

@@ -0,0 +1,118 @@
import React from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import { Draggable } from 'react-beautiful-dnd';
import RenderFields from '../RenderFields'; // eslint-disable-line import/no-cycle
import IconButton from '../../controls/IconButton';
import './index.scss';
const baseClass = 'draggable-section';
const DraggableSection = (props) => {
const {
addRow,
removeRow,
rowIndex,
parentName,
renderFields,
defaultValue,
dispatchCollapsibleStates,
collapsibleStates,
singularName,
} = props;
const handleCollapseClick = () => {
dispatchCollapsibleStates({
type: 'UPDATE_COLLAPSIBLE_STATUS',
collapsibleIndex: rowIndex,
});
};
return (
<Draggable
draggableId={`row-${rowIndex}`}
index={rowIndex}
>
{(providedDrag) => {
return (
<div
ref={providedDrag.innerRef}
className={baseClass}
{...providedDrag.draggableProps}
>
<div className={`${baseClass}__header`}>
<div
className={`${baseClass}__header__drag-handle`}
{...providedDrag.dragHandleProps}
onClick={handleCollapseClick}
role="button"
tabIndex={0}
/>
<div className={`${baseClass}__header__row-index`}>
{`${singularName} ${rowIndex + 1}`}
</div>
<div className={`${baseClass}__header__controls`}>
<IconButton
iconName="crosshair"
onClick={addRow}
size="small"
/>
<IconButton
iconName="crossOut"
onClick={removeRow}
size="small"
/>
</div>
</div>
<AnimateHeight
className={`${baseClass}__content`}
height={collapsibleStates[rowIndex] ? 'auto' : 0}
duration={0}
>
<RenderFields
key={rowIndex}
fields={renderFields.map((field) => {
const fieldName = `${parentName}.${rowIndex}.${field.name}`;
return ({
...field,
name: fieldName,
defaultValue: defaultValue?.[field.name],
});
})}
/>
</AnimateHeight>
</div>
);
}}
</Draggable>
);
};
DraggableSection.defaultProps = {
rowCount: null,
defaultValue: null,
collapsibleStates: [],
singularName: '',
};
DraggableSection.propTypes = {
addRow: PropTypes.func.isRequired,
removeRow: PropTypes.func.isRequired,
rowIndex: PropTypes.number.isRequired,
parentName: PropTypes.string.isRequired,
singularName: PropTypes.string,
renderFields: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
rowCount: PropTypes.number,
defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]),
dispatchCollapsibleStates: PropTypes.func.isRequired,
collapsibleStates: PropTypes.arrayOf(PropTypes.bool),
};
export default DraggableSection;

View File

@@ -0,0 +1,107 @@
@import '../../../scss/styles.scss';
.draggable-section {
background: $light-gray;
flex-direction: column;
position: relative;
margin-top: base(.5);
.field-type,
.missing-field {
padding-left: 0;
padding-right: 0;
}
&:hover {
@include shadow-sm;
}
&__collapse__icon {
&--open {
svg {
transform: rotate(90deg);
}
}
&--closed {
svg {
transform: rotate(-90deg);
}
}
}
&__header {
padding: base(.75) base(1);
display: flex;
align-items: center;
position: relative;
&__drag-handle {
width: 100%;
height: 100%;
position: absolute;
left: 0;
z-index: 1;
}
// elements above the drag handle
&__controls,
&__header__row-index {
position: relative;
z-index: 2;
}
&__row-index {
font-family: $font-body;
font-size: base(.5);
}
&__heading {
font-family: $font-body;
margin: 0;
font-size: base(.65);
}
&__controls {
margin-left: auto;
.btn {
margin-top: 0;
margin-bottom: 0;
}
.icon-button--crossOut,
.icon-button--crosshair {
margin-right: base(.25);
}
.icon-button--crosshair {
border-color: $primary;
@include color-svg($primary);
&:hover {
background: $primary;
@include color-svg($black);
}
}
.icon-button--crossOut {
border-color: $black;
@include color-svg($black);
&:hover {
background: $black;
@include color-svg(white);
}
}
}
}
&__content {
box-shadow: inset 0px 1px 0px white;
> div {
padding: base(.75) base(1);
}
}
}

View File

@@ -1,31 +1,20 @@
import React, { useState, useReducer } from 'react';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { unflatten } from 'flat';
import { unflatten } from 'flatley';
import FormContext from './Context';
import { useLocale } from '../../utilities/Locale';
import { useStatusList } from '../../modules/Status';
import HiddenInput from '../field-types/HiddenInput';
import { requests } from '../../../api';
import fieldReducer from './reducer';
import './index.scss';
const baseClass = 'form';
const initialFieldState = {};
function fieldReducer(state, action) {
return {
...state,
[action.name]: {
value: action.value,
valid: action.valid,
},
};
}
const Form = (props) => {
const [fields, setField] = useReducer(fieldReducer, initialFieldState);
const [fields, dispatchFields] = useReducer(fieldReducer, {});
const [submitted, setSubmitted] = useState(false);
const [processing, setProcessing] = useState(false);
const history = useHistory();
@@ -62,6 +51,7 @@ const Form = (props) => {
// If submit handler comes through via props, run that
} else if (onSubmit) {
e.preventDefault();
onSubmit(fields);
// If form is AJAX, fetch data
@@ -143,7 +133,7 @@ const Form = (props) => {
className={classes}
>
<FormContext.Provider value={{
setField,
dispatchFields,
fields,
processing,
submitted,

View File

@@ -7,6 +7,10 @@
width: 100%;
}
.sticky-header {
@include gutter;
}
> .btn {
@include gutter;
}

View File

@@ -0,0 +1,119 @@
import { unflatten, flatten } from 'flatley';
const splitRowsFromState = (state, name) => {
// Take a copy of state
const remainingState = { ...state };
const rowsFromStateObject = {};
const namePrefixToRemove = name.substring(0, name.lastIndexOf('.') + 1);
// Loop over all keys from state
// If the key begins with the name of the parent field,
// Add value to rowsFromStateObject and delete it from remaining state
Object.keys(state).forEach((key) => {
if (key.indexOf(`${name}.`) === 0) {
rowsFromStateObject[key.replace(namePrefixToRemove, '')] = state[key];
delete remainingState[key];
}
});
const rowsFromState = unflatten(rowsFromStateObject);
return {
rowsFromState: rowsFromState[name.replace(namePrefixToRemove, '')] || [],
remainingState,
};
};
const flattenFilters = [{
test: (_, value) => {
const hasValidProperty = Object.prototype.hasOwnProperty.call(value, 'valid');
const hasValueProperty = Object.prototype.hasOwnProperty.call(value, 'value');
return (hasValidProperty && hasValueProperty);
},
}];
function fieldReducer(state, action) {
switch (action.type) {
case 'REPLACE_ALL':
return {
...action.value,
};
case 'REMOVE_ROW': {
const { rowIndex, name } = action;
const { rowsFromState, remainingState } = splitRowsFromState(state, name);
rowsFromState.splice(rowIndex, 1);
return {
...remainingState,
...(flatten({ [name]: rowsFromState }, { filters: flattenFilters })),
};
}
case 'ADD_ROW': {
const {
rowIndex, name, fields, blockType,
} = action;
const { rowsFromState, remainingState } = splitRowsFromState(state, name);
// Get names of sub fields
const subFields = fields.reduce((acc, field) => {
if (field.type === 'flexible' || field.type === 'repeater') {
return acc;
}
return {
...acc,
[field.name]: {},
};
}, {});
if (blockType) {
subFields.blockType = {
value: blockType,
valid: true,
};
}
// Add new object containing subfield names to rowsFromState array
rowsFromState.splice(rowIndex + 1, 0, subFields);
return {
...remainingState,
...(flatten({ [name]: rowsFromState }, { filters: flattenFilters })),
};
}
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, name } = action;
const { rowsFromState, remainingState } = splitRowsFromState(state, name);
// copy the row to move
const copyOfMovingRow = rowsFromState[moveFromIndex];
// delete the row by index
rowsFromState.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
rowsFromState.splice(moveToIndex, 0, copyOfMovingRow);
return {
...remainingState,
...(flatten({ [name]: rowsFromState }, { filters: flattenFilters })),
};
}
default:
return {
...state,
[action.name]: {
value: action.value,
valid: action.valid,
},
};
}
}
export default fieldReducer;

View File

@@ -0,0 +1,35 @@
RenderFieldsNotes
slides.0.meta.title
slides.1.heroInfo.title
fields: [
{
name: slides,
type: repeater,
fields: [
{
type: group,
name: meta,
fields: [
{
name: title,
type: text,
component: require.resolve('/aslifjawelifjaew)
}
]
},
{
type: group,
name: heroInfo,
fields: [
{
name: title,
type: text,
component: require.resolve('/aslifjawelifjaew)
}
]
}
]
}
]

View File

@@ -4,13 +4,13 @@ import fieldTypes from '../field-types';
import './index.scss';
const RenderFields = ({ fields, initialData }) => {
const RenderFields = ({ fields, initialData, customComponents }) => {
if (fields) {
return (
<>
{fields.map((field, i) => {
const { defaultValue } = field;
const FieldComponent = field.component || fieldTypes[field.type];
const FieldComponent = customComponents?.[field.name] || fieldTypes[field.type];
if (FieldComponent) {
return (
@@ -44,6 +44,7 @@ const RenderFields = ({ fields, initialData }) => {
RenderFields.defaultProps = {
initialData: {},
customComponents: {},
};
RenderFields.propTypes = {
@@ -51,6 +52,7 @@ RenderFields.propTypes = {
PropTypes.shape({}),
).isRequired,
initialData: PropTypes.shape({}),
customComponents: PropTypes.shape({}),
};
export default RenderFields;

View File

@@ -11,7 +11,10 @@ const FormSubmit = ({ children }) => {
const formContext = useContext(FormContext);
return (
<div className={baseClass}>
<Button disabled={formContext.processing ? 'disabled' : ''}>
<Button
disabled={formContext.processing ? 'disabled' : ''}
type="submit"
>
{children}
</Button>
</div>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import { asModal } from '@trbl/react-modal';
import './index.scss';
const baseClass = 'add-content-block-modal';
const AddContentBlockModal = (props) => {
const {
addRow,
blocks,
rowIndexBeingAdded,
closeAllModals,
} = props;
const handleAddRow = (blockType) => {
addRow(rowIndexBeingAdded, blockType);
closeAllModals();
};
return (
<div className={baseClass}>
<h2>Add a layout block</h2>
<ul>
{blocks.map((block, i) => {
return (
<li key={i}>
<button
onClick={() => handleAddRow(block.slug)}
type="button"
>
{block.labels.singular}
</button>
</li>
);
})}
</ul>
</div>
);
};
AddContentBlockModal.defaultProps = {
rowIndexBeingAdded: null,
};
AddContentBlockModal.propTypes = {
addRow: PropTypes.func.isRequired,
closeAllModals: PropTypes.func.isRequired,
blocks: PropTypes.arrayOf(
PropTypes.shape({
labels: PropTypes.shape({
singular: PropTypes.string,
}),
previewImage: PropTypes.string,
slug: PropTypes.string,
}),
).isRequired,
rowIndexBeingAdded: PropTypes.number,
};
export default asModal(AddContentBlockModal);

View File

@@ -0,0 +1,10 @@
@import '../../../../../scss/styles';
.add-content-block-modal {
height: 100%;
background: rgba(white, .875);
&__wrap {
padding: base(1);
}
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { asModal } from '@trbl/react-modal';
const baseClass = 'flexible-add-row-modal';
const AddRowModal = (props) => {
const {
addRow,
blocks,
rowIndexBeingAdded,
closeAllModals,
} = props;
const handleAddRow = (blockType) => {
addRow(rowIndexBeingAdded, blockType);
closeAllModals();
};
return (
<div className={baseClass}>
<ul>
{blocks.map((block, i) => {
return (
<li key={i}>
<button
onClick={() => handleAddRow(block.slug)}
type="button"
>
{block.labels.singular}
</button>
</li>
);
})}
</ul>
</div>
);
};
AddRowModal.defaultProps = {
rowIndexBeingAdded: null,
};
AddRowModal.propTypes = {
addRow: PropTypes.func.isRequired,
closeAllModals: PropTypes.func.isRequired,
blocks: PropTypes.arrayOf(
PropTypes.shape({
labels: PropTypes.shape({
singular: PropTypes.string,
}),
previewImage: PropTypes.string,
slug: PropTypes.string,
}),
).isRequired,
rowIndexBeingAdded: PropTypes.number,
};
export default asModal(AddRowModal);

View File

@@ -0,0 +1,188 @@
import React, {
useContext, useEffect, useReducer, useState, Fragment,
} from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { ModalContext } from '@trbl/react-modal';
import FormContext from '../../Form/Context';
import Section from '../../../layout/Section';
import AddRowModal from './AddRowModal';
import collapsibleReducer from './reducer';
import DraggableSection from '../../DraggableSection'; // eslint-disable-line import/no-cycle
import './index.scss';
const baseClass = 'field-type flexible';
const Flexible = (props) => {
const {
label,
name,
blocks,
defaultValue,
} = props;
const { toggle: toggleModal, closeAll: closeAllModals } = useContext(ModalContext);
const [rowIndexBeingAdded, setRowIndexBeingAdded] = useState(null);
const [hasModifiedRows, setHasModifiedRows] = useState(false);
const [rowCount, setRowCount] = useState(0);
const [collapsibleStates, dispatchCollapsibleStates] = useReducer(collapsibleReducer, []);
const formContext = useContext(FormContext);
const modalSlug = `flexible-${name}`;
const { fields: fieldState, dispatchFields } = formContext;
const addRow = (rowIndex, blockType) => {
const blockToAdd = blocks.find(block => block.slug === blockType);
dispatchFields({
type: 'ADD_ROW', rowIndex, name, fields: blockToAdd.fields, blockType,
});
dispatchCollapsibleStates({
type: 'ADD_COLLAPSIBLE', collapsibleIndex: rowIndex,
});
setRowCount(rowCount + 1);
setHasModifiedRows(true);
};
const removeRow = (rowIndex) => {
dispatchFields({
type: 'REMOVE_ROW', rowIndex, name,
});
dispatchCollapsibleStates({
type: 'REMOVE_COLLAPSIBLE',
collapsibleIndex: rowIndex,
});
setRowCount(rowCount - 1);
setHasModifiedRows(true);
};
const moveRow = (moveFromIndex, moveToIndex) => {
dispatchFields({
type: 'MOVE_ROW', moveFromIndex, moveToIndex, name,
});
dispatchCollapsibleStates({
type: 'MOVE_COLLAPSIBLE', collapsibleIndex: moveFromIndex, moveToIndex,
});
setHasModifiedRows(true);
};
useEffect(() => {
setRowCount(defaultValue.length);
dispatchCollapsibleStates({
type: 'SET_ALL_COLLAPSIBLES',
payload: Array.from(Array(defaultValue.length).keys()).reduce(acc => ([...acc, true]), []), // sets all collapsibles to open on first load
});
}, [defaultValue]);
const openAddRowModal = (rowIndex) => {
setRowIndexBeingAdded(rowIndex);
toggleModal(modalSlug);
};
const onDragEnd = (result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
};
return (
<Fragment>
<DragDropContext onDragEnd={onDragEnd}>
<div className={baseClass}>
<Section
heading={label}
className="flexible"
rowCount={rowCount}
addRow={() => openAddRowModal(0)}
useAddRowButton
>
<Droppable droppableId="flexible-drop">
{provided => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rowCount !== 0
&& Array.from(Array(rowCount).keys()).map((_, rowIndex) => {
let blockType = fieldState[`${name}.${rowIndex}.blockType`]?.value;
if (!hasModifiedRows && !blockType) {
blockType = defaultValue?.[rowIndex]?.blockType;
}
const blockToRender = blocks.find(block => block.slug === blockType);
if (blockToRender) {
return (
<DraggableSection
key={rowIndex}
parentName={name}
addRow={() => openAddRowModal(rowIndex)}
removeRow={() => removeRow(rowIndex)}
rowIndex={rowIndex}
fieldState={fieldState}
renderFields={[
...blockToRender.fields,
{
name: 'blockType',
type: 'hidden',
}, {
name: 'blockName',
type: 'hidden',
},
]}
defaultValue={hasModifiedRows ? undefined : defaultValue[rowIndex]}
dispatchCollapsibleStates={dispatchCollapsibleStates}
collapsibleStates={collapsibleStates}
/>
);
}
return null;
})
}
{provided.placeholder}
</div>
)}
</Droppable>
</Section>
</div>
</DragDropContext>
<AddRowModal
closeAllModals={closeAllModals}
addRow={addRow}
rowIndexBeingAdded={rowIndexBeingAdded}
slug={modalSlug}
blocks={blocks}
/>
</Fragment>
);
};
Flexible.defaultProps = {
label: '',
defaultValue: [],
};
Flexible.propTypes = {
defaultValue: PropTypes.arrayOf(
PropTypes.shape({}),
),
blocks: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
name: PropTypes.string.isRequired,
};
export default Flexible;

View File

@@ -0,0 +1,5 @@
.field-type.flexible {
> .section {
background: white;
}
}

View File

@@ -0,0 +1,35 @@
const collapsibleReducer = (currentState, action) => {
const {
type, collapsibleIndex, moveToIndex, payload,
} = action;
const stateCopy = [...currentState];
const movingCollapsibleState = stateCopy[collapsibleIndex];
switch (type) {
case 'SET_ALL_COLLAPSIBLES':
return payload;
case 'ADD_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex + 1, 0, true);
return stateCopy;
case 'REMOVE_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex, 1);
return stateCopy;
case 'UPDATE_COLLAPSIBLE_STATUS':
stateCopy[collapsibleIndex] = !movingCollapsibleState;
return stateCopy;
case 'MOVE_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex, 1);
stateCopy.splice(moveToIndex, 0, movingCollapsibleState);
return stateCopy;
default:
return currentState;
}
};
export default collapsibleReducer;

View File

@@ -6,21 +6,26 @@ import RenderFields from '../../RenderFields';
import './index.scss';
const Group = (props) => {
const { label, fields, name, defaultValue } = props;
const {
label, fields, name, defaultValue,
} = props;
return (
<Section
heading={label}
className="field-group"
>
<RenderFields fields={fields.map((subField) => {
return {
...subField,
name: `${name}.${subField.name}`,
defaultValue: defaultValue[subField.name],
};
})} />
</Section>
<div className="field-type group">
<Section
heading={label}
className="field-group"
>
<RenderFields fields={fields.map((subField) => {
return {
...subField,
name: `${name}.${subField.name}`,
defaultValue: defaultValue[subField.name],
};
})}
/>
</Section>
</div>
);
};

View File

@@ -1,37 +1,137 @@
import React from 'react';
import React, {
useContext, useState, useEffect, useReducer,
} from 'react';
import PropTypes from 'prop-types';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import FormContext from '../../Form/Context';
import Section from '../../../layout/Section';
import RenderFields from '../../RenderFields';
import DraggableSection from '../../DraggableSection'; // eslint-disable-line import/no-cycle
import collapsibleReducer from './reducer';
import './index.scss';
const baseClass = 'field-type repeater';
const Repeater = (props) => {
const [collapsibleStates, dispatchCollapsibleStates] = useReducer(collapsibleReducer, []);
const formContext = useContext(FormContext);
const [rowCount, setRowCount] = useState(0);
const [hasModifiedRows, setHasModifiedRows] = useState(false);
const { fields: fieldState, dispatchFields } = formContext;
const {
label, fields, name, defaultValue,
label,
name,
fields,
defaultValue,
singularName,
} = props;
let rows = defaultValue.length > 0 ? defaultValue : [{}];
const addRow = (rowIndex) => {
dispatchFields({
type: 'ADD_ROW', rowIndex, name, fields,
});
dispatchCollapsibleStates({
type: 'ADD_COLLAPSIBLE', collapsibleIndex: rowIndex,
});
setRowCount(rowCount + 1);
setHasModifiedRows(true);
};
const removeRow = (rowIndex) => {
dispatchFields({
type: 'REMOVE_ROW', rowIndex, name, fields,
});
dispatchCollapsibleStates({
type: 'REMOVE_COLLAPSIBLE',
collapsibleIndex: rowIndex,
});
setRowCount(rowCount - 1);
setHasModifiedRows(true);
};
const moveRow = (moveFromIndex, moveToIndex) => {
dispatchFields({
type: 'MOVE_ROW', moveFromIndex, moveToIndex, name,
});
dispatchCollapsibleStates({
type: 'MOVE_COLLAPSIBLE', collapsibleIndex: moveFromIndex, moveToIndex,
});
setHasModifiedRows(true);
};
useEffect(() => {
setRowCount(defaultValue.length);
dispatchCollapsibleStates({
type: 'SET_ALL_COLLAPSIBLES',
payload: Array.from(Array(defaultValue.length).keys()).reduce(acc => ([...acc, true]), []), // sets all collapsibles to open on first load
});
}, [defaultValue]);
const onDragEnd = (result) => {
if (!result.destination) return;
const sourceIndex = result.source.index;
const destinationIndex = result.destination.index;
moveRow(sourceIndex, destinationIndex);
};
return (
<div className="field-repeater">
<Section heading={label}>
{rows.map((row, i) => {
return (
<RenderFields
key={i}
fields={fields.map((subField) => ({
...subField,
name: `${name}.${i}.${subField.name}`,
defaultValue: row[subField.name] || null,
}))}
/>
)
})}
</Section>
</div>
<DragDropContext onDragEnd={onDragEnd}>
<div className={baseClass}>
<Section
heading={label}
rowCount={rowCount}
addRow={() => addRow(0)}
useAddRowButton
>
<Droppable droppableId="repeater-drop">
{provided => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{rowCount !== 0
&& Array.from(Array(rowCount).keys()).map((_, rowIndex) => {
return (
<DraggableSection
key={rowIndex}
parentName={name}
singularName={singularName}
addRow={() => addRow(rowIndex)}
removeRow={() => removeRow(rowIndex)}
rowIndex={rowIndex}
fieldState={fieldState}
renderFields={fields}
rowCount={rowCount}
defaultValue={hasModifiedRows ? undefined : defaultValue[rowIndex]}
dispatchCollapsibleStates={dispatchCollapsibleStates}
collapsibleStates={collapsibleStates}
/>
);
})
}
{provided.placeholder}
</div>
)}
</Droppable>
</Section>
</div>
</DragDropContext>
);
};
Repeater.defaultProps = {
label: '',
singularName: '',
defaultValue: [],
};
@@ -43,6 +143,7 @@ Repeater.propTypes = {
PropTypes.shape({}),
).isRequired,
label: PropTypes.string,
singularName: PropTypes.string,
name: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,5 @@
@import '../../../../scss/styles.scss';
.field-type.repeater {
background: white;
}

View File

@@ -0,0 +1,35 @@
const collapsibleReducer = (currentState, action) => {
const {
type, collapsibleIndex, moveToIndex, payload,
} = action;
const stateCopy = [...currentState];
const movingCollapsibleState = stateCopy[collapsibleIndex];
switch (type) {
case 'SET_ALL_COLLAPSIBLES':
return payload;
case 'ADD_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex + 1, 0, true);
return stateCopy;
case 'REMOVE_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex, 1);
return stateCopy;
case 'UPDATE_COLLAPSIBLE_STATUS':
stateCopy[collapsibleIndex] = !movingCollapsibleState;
return stateCopy;
case 'MOVE_COLLAPSIBLE':
stateCopy.splice(collapsibleIndex, 1);
stateCopy.splice(moveToIndex, 0, movingCollapsibleState);
return stateCopy;
default:
return currentState;
}
};
export default collapsibleReducer;

View File

@@ -7,6 +7,7 @@ import date from './DateTime';
import relationship from './Relationship';
import password from './Password';
import repeater from './Repeater';
import flexible from './Flexible';
import textarea from './Textarea';
import select from './Select';
import number from './Number';
@@ -20,6 +21,7 @@ export default {
date,
relationship,
// upload,
flexible,
number,
password,
repeater,

View File

@@ -5,7 +5,7 @@ import './index.scss';
const useFieldType = (options) => {
const formContext = useContext(FormContext);
const { setField, submitted, processing } = formContext;
const { dispatchFields, submitted, processing } = formContext;
const {
name,
@@ -16,17 +16,17 @@ const useFieldType = (options) => {
} = options;
const sendField = useCallback((valueToSend) => {
setField({
dispatchFields({
name,
value: valueToSend,
valid: required && validate
? validate(valueToSend || '')
: true,
});
}, [name, required, setField, validate]);
}, [name, required, dispatchFields, validate]);
useEffect(() => {
sendField(defaultValue);
if (defaultValue != null) sendField(defaultValue);
}, [defaultValue, sendField]);
const valid = formContext.fields[name] ? formContext.fields[name].valid : true;

View File

@@ -0,0 +1,38 @@
import React from 'react';
const Crosshair = () => {
return (
<svg
width="10px"
height="10px"
viewBox="0 0 10 10"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className="icon crosshair"
>
<g
stroke="none"
strokeWidth="1"
fill="none"
fillRule="evenodd"
strokeLinecap="square"
className="stroke"
>
<line
x1="-1.11022302e-16"
y1="5"
x2="10"
y2="5"
/>
<line
x1="5"
y1="0"
x2="5"
y2="10"
/>
</g>
</svg>
);
};
export default Crosshair;

View File

@@ -1,6 +1,7 @@
import React, { Suspense } from 'react';
import { render } from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { ModalProvider, ModalContainer } from '@trbl/react-modal';
import Loading from './views/Loading';
import { SearchParamsProvider } from './utilities/SearchParams';
import { LocaleProvider } from './utilities/Locale';
@@ -14,15 +15,21 @@ const Index = () => {
return (
<UserProvider>
<Router>
<StatusListProvider>
<SearchParamsProvider>
<LocaleProvider>
<Suspense fallback={<Loading />}>
<Routes />
</Suspense>
</LocaleProvider>
</SearchParamsProvider>
</StatusListProvider>
<ModalProvider
classPrefix="payload"
transTime={0}
>
<StatusListProvider>
<SearchParamsProvider>
<LocaleProvider>
<Suspense fallback={<Loading />}>
<Routes />
</Suspense>
</LocaleProvider>
</SearchParamsProvider>
</StatusListProvider>
<ModalContainer />
</ModalProvider>
</Router>
</UserProvider>
);

View File

@@ -1,20 +1,84 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import AnimateHeight from 'react-animate-height';
import './index.scss';
import IconButton from '../../controls/IconButton';
import Button from '../../controls/Button';
const baseClass = 'section';
const Section = (props) => {
const {
className, heading, children, rowCount, addRow, useAddRowButton,
} = props;
const classes = [
baseClass,
className && className,
].filter(Boolean).join(' ');
const [isSectionOpen, setIsSectionOpen] = useState(true);
const addInitialRow = () => {
addRow();
setIsSectionOpen(true);
};
const Section = props => {
return (
<section className={`section${props.className ? ` ${props.className}` : ''}`}>
{props.heading &&
<header>
<h2>{props.heading}</h2>
</header>
}
<div className="content">
{props.children}
</div>
<section className={classes}>
{heading
&& (
<header
className={`${baseClass}__collapsible-header`}
onClick={() => setIsSectionOpen(state => !state)}
role="button"
tabIndex={0}
>
<h2 className={`${baseClass}__heading`}>{heading}</h2>
</header>
)}
{children
&& (
<AnimateHeight
className={`${baseClass}__content ${baseClass}__content--is-${isSectionOpen ? 'open' : 'closed'}`}
height={isSectionOpen ? 'auto' : 0}
duration={0}
>
{(rowCount === 0 && useAddRowButton)
&& (
<div className={`${baseClass}__add-button-wrap`}>
<Button
onClick={addInitialRow}
type="secondary"
>
Add Row
</Button>
</div>
)}
{children}
</AnimateHeight>
)}
</section>
)
}
);
};
Section.defaultProps = {
className: '',
heading: '',
children: undefined,
rowCount: 0,
addRow: undefined,
useAddRowButton: false,
};
Section.propTypes = {
className: PropTypes.string,
heading: PropTypes.string,
children: PropTypes.node,
rowCount: PropTypes.number,
addRow: PropTypes.func,
useAddRowButton: PropTypes.bool,
};
export default Section;

View File

@@ -1,24 +1,75 @@
@import '../../../scss/styles.scss';
section.section {
@include shadow;
@include inputShadow;
margin: base(1) 0;
header,
.content {
@include pad;
}
header {
.section__collapsible-header {
border-bottom: 1px solid $light-gray;
display: flex;
align-items: center;
@include pad;
outline: 0;
&:hover {
cursor: pointer;
}
* {
margin-bottom: 0;
}
}
.content {
.section__add-button-wrap {
.btn {
margin: 0;
margin-top: base(.5);
}
}
&__content {
display: flex;
flex-wrap: wrap;
}
}
.section {
&__heading {
margin-bottom: 0;
margin-right: base(1);
}
&__content {
> div {
@include pad;
padding-top: base(.5);
}
}
&__controls {
margin-left: auto;
}
&__collapse-icon {
svg {
transition: 150ms linear;
}
&--open {
svg {
transform: rotate(90deg);
}
}
&--closed {
svg {
transform: rotate(-90deg);
}
}
}
&__add-row-button {
margin-right: base(.5);
}
}

View File

@@ -1,102 +0,0 @@
///////////////////////////////////////////////////////
// Takes a modal component and
// a slug to match against a 'modal' URL param
///////////////////////////////////////////////////////
import React, { Component } from 'react';
import { createPortal } from 'react-dom';
import { withRouter } from 'react-router';
import queryString from 'qs';
import Close from '../../graphics/Close';
import Button from '../../controls/Button';
import './index.scss';
const asModal = (PassedComponent, modalSlug) => {
class AsModal extends Component {
constructor(props) {
super(props);
this.state = {
open: false,
el: null
}
}
bindEsc = event => {
if (event.keyCode === 27) {
const params = { ...this.props.searchParams };
delete params.modal;
this.props.history.push({
search: queryString.stringify(params)
})
}
}
isOpen = () => {
// Slug can come from either a HOC or from a prop
const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug;
if (this.props.searchParams.modal === slug) {
return true;
}
return false;
}
componentDidMount() {
document.addEventListener('keydown', this.bindEsc, false);
if (this.isOpen()) {
this.setState({ open: true })
}
// Slug can come from either a HOC or from a prop
const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug;
this.setState({
el: document.querySelector(`#${slug}`)
})
}
componentWillUnmount() {
document.removeEventListener('keydown', this.bindEsc, false);
}
componentDidUpdate(prevProps, prevState) {
let open = this.isOpen();
if (open !== prevState.open && open) {
this.setState({ open: true })
} else if (open !== prevState.open) {
this.setState({ open: false })
}
}
render() {
// Slug can come from either a HOC or from a prop
const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug;
const modalDomNode = document.getElementById('portal');
return createPortal(
<div className={`modal${this.state.open ? ' open' : ''}`}>
<Button el="link" type="icon" className="close" to={{ search: '' }}>
<Close />
</Button>
<PassedComponent id={slug} {...this.props} isOpen={this.state.open} />
</div>,
modalDomNode
);
}
}
return withRouter(connect(mapStateToProps)(AsModal));
}
export default asModal;

View File

@@ -1,21 +0,0 @@
@import '../../../scss/styles.scss';
.modal {
transform: translateZ(0);
opacity: 0;
visibility: hidden;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: -1;
height: 100vh;
background-color: rgba(white, .96);
&.open {
opacity: 1;
visibility: visible;
z-index: 100;
}
}

View File

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

View File

@@ -0,0 +1,10 @@
@import '../../../scss/styles.scss';
.pill {
@extend %uppercase-label;
color: $black;
border: 1px solid $gray;
border-radius: 3px;
padding: 0 base(.25);
padding-left: base(.0875 + .25); // %uppercase-label's (letter-spacing + padding)
}

View File

@@ -1,7 +1,6 @@
@import '../../../scss/styles.scss';
.sticky-header {
@include gutter;
.sticky-header-wrap {
display: flex;

View File

@@ -0,0 +1,23 @@
import React, { useEffect } from 'react';
import {
Redirect,
} from 'react-router-dom';
import { useStatusList } from '../../modules/Status';
import config from '../../../config/sanitizedClientConfig';
const RedirectToLogin = () => {
const { addStatus } = useStatusList();
useEffect(() => {
addStatus({
message: 'You need to log in to be able to do that.',
type: 'error',
});
}, [addStatus]);
return (
<Redirect to={`${config.routes.admin}/login`} />
);
};
export default RedirectToLogin;

View File

@@ -49,8 +49,8 @@ const CreateFirstUser = (props) => {
const fields = [...config.user.fields];
if (config.user.passwordIndex) {
fields.splice(config.user.passwordIndex, 0, passwordField);
if (config.user.auth.passwordIndex) {
fields.splice(config.user.auth.passwordIndex, 0, passwordField);
} else {
fields.push(passwordField);
}

View File

@@ -10,6 +10,7 @@ import APIURL from '../../../modules/APIURL';
import Button from '../../../controls/Button';
import FormSubmit from '../../../forms/Submit';
import RenderFields from '../../../forms/RenderFields';
import customComponents from '../../../custom-components';
import './index.scss';
@@ -77,7 +78,7 @@ const EditView = (props) => {
&& (
<h1>
Create New
{' '}
{' '}
{collection.labels.singular}
</h1>
)
@@ -102,6 +103,7 @@ const EditView = (props) => {
)}
/>
<RenderFields
customComponents={customComponents?.[collection.slug]?.fields}
fields={collection.fields}
initialData={data}
/>

View File

@@ -56,7 +56,11 @@ $stroke-width : 1px;
}
@mixin shadow {
box-shadow: 0 22px 65px rgba(0,0,0,.07);
box-shadow: 0 12px 45px rgba(0,0,0,.03);
&:hover {
box-shadow: 0 12px 45px rgba(0,0,0,.07);
}
}
@mixin inputShadowActive {

View File

@@ -0,0 +1,8 @@
const bindGlobalMiddleware = (global) => {
return (req, res, next) => {
req.global = global;
next();
};
};
module.exports = bindGlobalMiddleware;

View File

@@ -1,23 +1,25 @@
const requestHandlers = require('./requestHandlers');
const setModelLocaleMiddleware = require('../localization/setModelLocale');
const bindModelMiddleware = require('../mongoose/bindModel');
const loadPolicy = require('../auth/loadPolicy');
const bindGlobalMiddleware = require('../globals/bindGlobalMiddleware');
const { upsert, fetch } = requestHandlers;
const { upsert, findOne } = requestHandlers;
const registerGlobals = (globals, router) => {
router.all('/globals*',
bindModelMiddleware(globals),
bindModelMiddleware(globals.model),
setModelLocaleMiddleware());
router
.route('/globals')
.get(fetch);
globals.config.forEach((global) => {
router.all(`/globals/${global.slug}`, bindGlobalMiddleware(global));
router
.route('/globals/:slug')
.get(fetch)
.post(upsert)
.put(upsert);
router
.route(`/globals/${global.slug}`)
.get(loadPolicy(global.policies.read), findOne)
.post(loadPolicy(global.policies.create), upsert)
.put(loadPolicy(global.policies.update), upsert);
});
};
module.exports = registerGlobals;

View File

@@ -1,36 +1,36 @@
const mongoose = require('mongoose');
const autopopulate = require('mongoose-autopopulate');
const mongooseHidden = require('mongoose-hidden');
const fieldToSchemaMap = require('../mongoose/schema/fieldToSchemaMap');
const buildSchema = require('../mongoose/schema/buildSchema');
const localizationPlugin = require('../localization/plugin');
const registerSchema = (globalConfigs, config) => {
const globalFields = {};
const globalSchemaGroups = {};
const globals = {
config: {},
config: globalConfigs,
model: {},
};
if (globalConfigs && globalConfigs.length > 0) {
Object.values(globalConfigs).forEach((globalConfig) => {
globals.config[globalConfig.label] = globalConfig;
globalFields[globalConfig.slug] = {};
globalConfig.fields.forEach((field) => {
const fieldSchema = fieldToSchemaMap[field.type];
if (fieldSchema) globalFields[globalConfig.slug][field.name] = fieldSchema(field, config);
});
globalSchemaGroups[globalConfig.slug] = globalFields[globalConfig.slug];
});
}
globals.model = mongoose.model(
'globals',
new mongoose.Schema({ ...globalSchemaGroups, timestamps: false })
const globalsSchema = new mongoose.Schema({}, { discriminatorKey: 'globalType', timestamps: false })
.plugin(localizationPlugin, config.localization)
.plugin(autopopulate)
.plugin(mongooseHidden()),
);
.plugin(mongooseHidden());
const Globals = mongoose.model('globals', globalsSchema);
Object.values(globalConfigs).forEach((globalConfig) => {
const globalSchema = buildSchema(globalConfig.fields, config);
globalSchema
.plugin(localizationPlugin, config.localization)
.plugin(autopopulate)
.plugin(mongooseHidden());
Globals.discriminator(globalConfig.slug, globalSchema);
});
globals.model = Globals;
}
return globals;
};

View File

@@ -1,23 +1,16 @@
const httpStatus = require('http-status');
const { findOne } = require('../mongoose/resolvers');
const { NotFound } = require('../errors');
const formatErrorResponse = require('../responses/formatError');
const upsert = (req, res) => {
if (!req.model.schema.tree[req.params.slug]) {
return res.status(httpStatus.NOT_FOUND).json(formatErrorResponse(new NotFound(), 'APIError'));
}
const { slug } = req.global;
req.model.findOne({}, (findErr, doc) => {
let global = {};
req.model.findOne({ globalType: slug }, (findErr, doc) => {
if (!doc) {
if (req.params.slug) {
global[req.params.slug] = req.body;
} else {
global = req.body;
}
return req.model.create(global, (createErr, result) => {
return req.model.create({
...req.body,
globalType: slug,
}, (createErr, result) => {
if (createErr) return res.status(httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(createErr, 'mongoose'));
return res.status(httpStatus.CREATED)
@@ -28,11 +21,11 @@ const upsert = (req, res) => {
});
}
if (!doc[req.params.slug]) {
Object.assign(doc[req.params.slug], {});
if (req.query.locale && doc.setLocale) {
doc.setLocale(req.query.locale, req.query['fallback-locale']);
}
Object.assign(doc[req.params.slug], req.body);
Object.assign(doc, req.body);
return doc.save((err) => {
if (err) return res.status(httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(err, 'mongoose'));
@@ -45,29 +38,34 @@ const upsert = (req, res) => {
});
};
const fetch = (req, res) => {
const query = {
Model: req.model,
locale: req.locale,
fallback: req.query['fallback-locale'],
depth: req.query.depth,
};
const findOne = (req, res) => {
const { slug } = req.global;
findOne(query)
.then((doc) => {
if (doc[req.params.slug]) {
return res.json(doc[req.params.slug]);
} if (req.params.slug) {
return res.status(httpStatus.NOT_FOUND).json(formatErrorResponse(new NotFound(), 'APIError'));
}
return res.json(doc);
})
.catch((err) => {
return res.status(httpStatus.NOT_FOUND).json(formatErrorResponse(err, 'APIError'));
});
const options = {};
if (req.query.depth) {
options.autopopulate = {
maxDepth: req.query.depth,
};
}
req.model.findOne({ globalType: slug }, null, options, (findErr, doc) => {
if (!doc) {
return res.status(httpStatus.NOT_FOUND).json(formatErrorResponse(new NotFound(), 'APIError'));
}
let result = doc;
if (req.query.locale && doc.setLocale) {
doc.setLocale(req.query.locale, req.query['fallback-locale']);
result = doc.toJSON({ virtuals: true });
}
return res.status(httpStatus.OK).json(result);
});
};
module.exports = {
fetch,
findOne,
upsert,
};

View File

@@ -25,6 +25,7 @@ class Payload {
this.registerUser.bind(this);
this.registerUpload.bind(this);
this.registerGlobals.bind(this);
this.registerCollections.bind(this);
this.getCollections.bind(this);
this.getGlobals.bind(this);
@@ -44,20 +45,11 @@ class Payload {
this.registerUser();
this.registerUpload();
// Register custom collections
this.config.collections.forEach((collection) => {
validateCollection(collection, this.collections);
this.collections[collection.slug] = {
model: mongoose.model(collection.slug, buildCollectionSchema(collection, this.config)),
config: collection,
};
registerCollectionRoutes(this.collections[collection.slug], this.router);
});
// Register collections
this.registerCollections();
// Register globals
this.registerGlobals(this.config.globals);
this.registerGlobals();
// Enable client webpack
if (!this.config.disableAdmin) initWebpack(this.app, this.config);
@@ -66,7 +58,7 @@ class Payload {
registerUser() {
this.config.user.fields.push(...baseUserFields);
const userSchema = buildCollectionSchema(this.config.user, this.config);
userSchema.plugin(passportLocalMongoose, { usernameField: this.config.user.useAsUsername });
userSchema.plugin(passportLocalMongoose, { usernameField: this.config.user.auth.useAsUsername });
this.User = mongoose.model(this.config.user.labels.singular, userSchema);
initUserAuth(this.User, this.config, this.router);
@@ -102,10 +94,23 @@ class Payload {
}, this.router);
}
registerGlobals(globals) {
validateGlobals(globals);
this.globals = registerGlobalSchema(globals, this.config);
registerGlobalRoutes(this.globals.model, this.router);
registerCollections() {
this.config.collections.forEach((collection) => {
validateCollection(collection, this.collections);
this.collections[collection.slug] = {
model: mongoose.model(collection.slug, buildCollectionSchema(collection, this.config)),
config: collection,
};
registerCollectionRoutes(this.collections[collection.slug], this.router);
});
}
registerGlobals() {
validateGlobals(this.config.globals);
this.globals = registerGlobalSchema(this.config.globals, this.config);
registerGlobalRoutes(this.globals, this.router);
}
getCollections() {

View File

@@ -142,7 +142,6 @@ module.exports = function localizationPlugin(schema, options) {
const { locales } = this.schema.options.localization;
locales.forEach((locale) => {
if (!value[locale]) {
// this.set(`${path}.${locale}`, value);
return;
}
this.set(`${path}.${locale}`, value[locale]);
@@ -152,17 +151,19 @@ module.exports = function localizationPlugin(schema, options) {
// embedded and sub-documents will use locale methods from the top level document
const owner = this.ownerDocument ? this.ownerDocument() : this;
const locale = owner.getLocale();
this.set(`${path}.${owner.getLocale()}`, value);
this.set(`${path}.${locale}`, value);
});
// localized option is not needed for the current path any more,
// and is unwanted for all child locale-properties
// delete schemaType.options.localized; // This was removed to allow viewing inside query parser
const localizedObject = {};
// TODO: setting equal to object is good for hasMany: false, but breaking for hasMany: true;
localizedObject[key] = {};
const localizedObject = {
[key]: {},
};
pluginOptions.locales.forEach(function (locale) {
const localeOptions = Object.assign({}, schemaType.options);
if (locale !== options.defaultLocale) {
@@ -265,8 +266,8 @@ module.exports = function localizationPlugin(schema, options) {
// define a global method to change the locale for all models (and their schemas)
// created for the current mongo connection
model.db.setDefaultLocale = function (locale) {
let modelToUpdate; let
modelName;
let modelToUpdate;
let modelName;
for (modelName in this.models) {
if (this.models.hasOwnProperty(modelName)) {
modelToUpdate = this.models[modelName];

View File

@@ -1,6 +1,8 @@
const setModelLocaleMiddleware = () => {
return (req, res, next) => {
if (req.locale && req.model.setDefaultLocale) req.model.setDefaultLocale(req.locale);
if (req.locale && req.model.setDefaultLocale) {
req.model.setDefaultLocale(req.locale);
}
next();
};
};

View File

@@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const validOperators = ['like', 'in', 'all', 'nin', 'gte', 'gt', 'lte', 'lt', 'ne'];
// This plugin asynchronously builds a list of Mongoose query constraints
@@ -12,6 +13,7 @@ function buildQueryPlugin(schema) {
if (cb) {
model
.find(params.searchParams)
.sort(params.sort)
.exec(cb);
}
@@ -36,21 +38,25 @@ class ParamParser {
// Entry point to the ParamParser class
async parse() {
for (let key of Object.keys(this.rawParams)) {
for (const key of Object.keys(this.rawParams)) {
// If rawParams[key] is an object, that means there are operators present.
// Need to loop through keys on rawParams[key] to call addSearchParam on each operator found
if (typeof this.rawParams[key] === 'object') {
Object.keys(this.rawParams[key]).forEach(async (operator) => {
const [searchParamKey, searchParamValue] = await this.buildSearchParam(this.model.schema, key, this.rawParams[key][operator], operator);
this.query.searchParams = this.addSearchParam(searchParamKey, searchParamValue, this.query.searchParams, this.model.schema);
})
Object.keys(this.rawParams[key])
.forEach(async (operator) => {
const [searchParamKey, searchParamValue] = await this.buildSearchParam(this.model.schema, key, this.rawParams[key][operator], operator);
this.query.searchParams = this.addSearchParam(searchParamKey, searchParamValue, this.query.searchParams, this.model.schema);
});
// Otherwise there are no operators present
} else {
const [searchParamKey, searchParamValue] = await this.buildSearchParam(this.model.schema, key, this.rawParams[key]);
this.query.searchParams = this.addSearchParam(searchParamKey, searchParamValue, this.query.searchParams, this.model.schema);
if (searchParamKey === 'sort') {
this.query.sort = searchParamValue;
} else {
this.query.searchParams = this.addSearchParam(searchParamKey, searchParamValue, this.query.searchParams, this.model.schema);
}
}
};
}
return this.query;
}
@@ -72,7 +78,7 @@ class ParamParser {
if (path) {
// If the path is an ObjectId with a direct ref,
// Grab it
let ref = path.options.ref;
let { ref } = path.options;
// If the path is an Array, grab the ref of the first index type
if (path.instance === 'Array') {
@@ -89,7 +95,7 @@ class ParamParser {
Object.keys(val).forEach(async (operator) => {
const [searchParamKey, searchParamValue] = await this.buildSearchParam(subModel.schema, localizedSubKey, val[operator], operator);
subQuery = this.addSearchParam(searchParamKey, searchParamValue, subQuery, subModel.schema);
})
});
} else {
const [searchParamKey, searchParamValue] = await this.buildSearchParam(subModel.schema, localizedSubKey, val);
subQuery = this.addSearchParam(searchParamKey, searchParamValue, subQuery, subModel.schema);
@@ -98,7 +104,7 @@ class ParamParser {
const matchingSubDocuments = await subModel.find(subQuery);
return [localizedPath, {
$in: matchingSubDocuments.map(subDoc => subDoc.id)
$in: matchingSubDocuments.map(subDoc => subDoc.id),
}];
}
}
@@ -129,8 +135,8 @@ class ParamParser {
case 'like':
formattedValue = {
'$regex': val,
'$options': '-i',
$regex: val,
$options: '-i',
};
break;
@@ -148,8 +154,8 @@ class ParamParser {
[key]: {
...searchParams[key],
...value,
}
}
},
};
}
return {

View File

@@ -9,6 +9,7 @@ const query = (req, res) => {
if (req.query.page) paginateQuery.page = req.query.page;
if (req.query.limit) paginateQuery.limit = req.query.limit;
if (req.query.sort) paginateQuery.sort = req.query.sort;
if (req.query.depth) {
paginateQuery.options.autopopulate = {

View File

@@ -11,7 +11,7 @@ const update = (req, res) => {
Object.assign(doc, req.body);
doc.save((saveError) => {
return doc.save((saveError) => {
if (saveError) {
return res.status(httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(saveError, 'mongoose'));
}

View File

@@ -1,33 +0,0 @@
const { NotFound } = require('../../errors');
const findOne = ({
Model, locale, fallback, depth,
}) => {
const options = {};
if (depth) {
options.autopopulate = {
maxDepth: depth,
};
}
return new Promise((resolve, reject) => {
Model.findOne(null, null, options, (err, doc) => {
if (err || !doc) {
reject(new NotFound());
return;
}
let result = doc;
if (locale) {
doc.setLocale(locale, fallback);
result = doc.toJSON({ virtuals: true });
}
resolve(result);
});
});
};
module.exports = findOne;

View File

@@ -1,11 +1,9 @@
const modelById = require('./modelById');
const find = require('./find');
const findOne = require('./findOne');
const create = require('./create');
module.exports = {
modelById,
find,
findOne,
create,
};

View File

@@ -21,7 +21,7 @@ const buildSchema = (configFields, config, options = {}, additionalBaseFields =
if (flexiblefields.length > 0) {
flexiblefields.forEach((field) => {
if (field.length > 0) {
if (field.blocks && field.blocks.length > 0) {
field.blocks.forEach((block) => {
const blockSchemaFields = {};
@@ -31,7 +31,7 @@ const buildSchema = (configFields, config, options = {}, additionalBaseFields =
});
const blockSchema = new Schema(blockSchemaFields, { _id: false });
schema.path(field.name).discriminator(block.labels.singular, blockSchema);
schema.path(field.name).discriminator(block.slug, blockSchema);
});
}
});

View File

@@ -5,7 +5,7 @@ const formatBaseSchema = (field) => {
hide: field.hide || false,
localized: field.localized || false,
unique: field.unique || false,
required: field.required || false,
required: (field.required && !field.localized) || false,
default: field.defaultValue || undefined,
};
};