merge with master
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
48
src/client/components/controls/IconButton/index.js
Normal file
48
src/client/components/controls/IconButton/index.js
Normal 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;
|
||||
28
src/client/components/controls/IconButton/index.scss
Normal file
28
src/client/components/controls/IconButton/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
118
src/client/components/forms/DraggableSection/index.js
Normal file
118
src/client/components/forms/DraggableSection/index.js
Normal 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;
|
||||
107
src/client/components/forms/DraggableSection/index.scss
Normal file
107
src/client/components/forms/DraggableSection/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
@include gutter;
|
||||
}
|
||||
|
||||
> .btn {
|
||||
@include gutter;
|
||||
}
|
||||
|
||||
119
src/client/components/forms/Form/reducer.js
Normal file
119
src/client/components/forms/Form/reducer.js
Normal 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;
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
@import '../../../../../scss/styles';
|
||||
|
||||
.add-content-block-modal {
|
||||
height: 100%;
|
||||
background: rgba(white, .875);
|
||||
|
||||
&__wrap {
|
||||
padding: base(1);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
188
src/client/components/forms/field-types/Flexible/index.js
Normal file
188
src/client/components/forms/field-types/Flexible/index.js
Normal 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.field-type.flexible {
|
||||
> .section {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
35
src/client/components/forms/field-types/Flexible/reducer.js
Normal file
35
src/client/components/forms/field-types/Flexible/reducer.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.field-type.repeater {
|
||||
background: white;
|
||||
}
|
||||
35
src/client/components/forms/field-types/Repeater/reducer.js
Normal file
35
src/client/components/forms/field-types/Repeater/reducer.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
38
src/client/components/graphics/Crosshair/index.js
Normal file
38
src/client/components/graphics/Crosshair/index.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
31
src/client/components/modules/Pill/index.js
Normal file
31
src/client/components/modules/Pill/index.js
Normal 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;
|
||||
10
src/client/components/modules/Pill/index.scss
Normal file
10
src/client/components/modules/Pill/index.scss
Normal 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)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.sticky-header {
|
||||
@include gutter;
|
||||
|
||||
.sticky-header-wrap {
|
||||
display: flex;
|
||||
|
||||
23
src/client/components/utilities/RedirectToLogin/index.js
Normal file
23
src/client/components/utilities/RedirectToLogin/index.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
8
src/globals/bindGlobalMiddleware.js
Normal file
8
src/globals/bindGlobalMiddleware.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const bindGlobalMiddleware = (global) => {
|
||||
return (req, res, next) => {
|
||||
req.global = global;
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = bindGlobalMiddleware;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
39
src/index.js
39
src/index.js
@@ -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() {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user