├── index.js ├── .npmignore ├── .babelrc ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .gitignore ├── src ├── AppLib │ ├── ReadManyLib │ │ ├── defaultCellFormatter.js │ │ ├── Paginate.js │ │ ├── Search.js │ │ ├── TdAction.js │ │ ├── getReadManyInputQueryString.js │ │ ├── Tr.js │ │ └── ThField.js │ ├── CreateOrReadOne.js │ ├── formLib │ │ ├── formatLabel.js │ │ ├── Label.js │ │ ├── TitleField.js │ │ ├── CurrencyWidget.js │ │ ├── HasOneWidget.js │ │ ├── NumberWidget.js │ │ ├── renderValue.js │ │ ├── MultiSelectField.js │ │ ├── FieldTemplate.js │ │ ├── withOptionItems.js │ │ ├── HasManyField.js │ │ ├── TagIdsField.js │ │ └── WysiwygWidget.js │ ├── NavBar.js │ ├── IndexRoute.js │ ├── Login.js │ ├── LoginLib │ │ ├── CodeLogin.js │ │ └── EmailPasswordLogin.js │ ├── Create.js │ ├── Form.js │ ├── ReadOne.js │ ├── Update.js │ └── ReadMany.js ├── App.test.js ├── GqlCmsConfigLib │ ├── ensureUniqKey.js │ ├── getCRUDSchemaFromResource.js │ └── jsonSchemaToGqlQuery.js ├── stylesheets │ ├── react-jsonschemaform.css │ ├── bootstrap-override.css │ ├── react-toastify.css │ └── react-selectize.css ├── alertFirstGqlMsg.js ├── removeTypename.js ├── formLib │ ├── nullToUndefined.js │ └── undefinedToNull.js ├── graphqlWithoutCache.js ├── StateHOF.js ├── App.js └── index.js ├── .eslintrc ├── package.json └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/App') 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | 3 | node_modules/ 4 | build/ 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-2"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poetic/byob-cms/master/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log* 2 | yarn-debug.log* 3 | yarn-error.log* 4 | 5 | node_modules/ 6 | build/ 7 | dist/ 8 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/defaultCellFormatter.js: -------------------------------------------------------------------------------- 1 | function defaultCellFormatter (value) { 2 | return value 3 | } 4 | 5 | export default defaultCellFormatter 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/GqlCmsConfigLib/ensureUniqKey.js: -------------------------------------------------------------------------------- 1 | function ensureUniqKey (jsonSchema, uniqKey) { 2 | return { 3 | ...jsonSchema, 4 | properties: { 5 | [uniqKey]: { type: 'string' }, 6 | ...jsonSchema.properties, 7 | } 8 | } 9 | } 10 | 11 | export default ensureUniqKey 12 | -------------------------------------------------------------------------------- /src/stylesheets/react-jsonschemaform.css: -------------------------------------------------------------------------------- 1 | /* react-jsonschema-form use text instead of icon in buttons */ 2 | i.glyphicon { display: none; } 3 | .btn-add::after { content: '+'; } 4 | .array-item-move-up::after { content: '▲'; } 5 | .array-item-move-down::after { content: '▼'; } 6 | .array-item-remove::after { content: '✕'; } 7 | -------------------------------------------------------------------------------- /src/alertFirstGqlMsg.js: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | 3 | function alertFirstGqlMsg (e) { 4 | let message 5 | try { 6 | message = e.graphQLErrors[0].message 7 | } catch (discardedError) { 8 | message = e.message 9 | } 10 | console.error(e) 11 | toast.error(message) 12 | } 13 | 14 | export default alertFirstGqlMsg 15 | -------------------------------------------------------------------------------- /src/AppLib/CreateOrReadOne.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Create from './Create' 3 | import ReadOne from './ReadOne' 4 | 5 | function CreateOrReadOne (props) { 6 | const uniqKeyValue = props.match.params[props.resource.uniqKey] 7 | return uniqKeyValue === 'new' ? : 8 | } 9 | 10 | export default CreateOrReadOne 11 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/GqlCmsConfigLib/getCRUDSchemaFromResource.js: -------------------------------------------------------------------------------- 1 | function getCRUDSchemaFromResource ({ config, resource, crudType }) { 2 | const key = crudType + 'Schema' 3 | const schema = { 4 | ...config.defaultSchema, 5 | ...config[key], 6 | ...resource.defaultSchema, 7 | ...resource[key], 8 | } 9 | return schema 10 | } 11 | 12 | export default getCRUDSchemaFromResource 13 | -------------------------------------------------------------------------------- /src/AppLib/formLib/formatLabel.js: -------------------------------------------------------------------------------- 1 | import { endsWith, startCase } from 'lodash' 2 | import pluralize from 'pluralize' 3 | 4 | function formatLabel (label) { 5 | const startCased = startCase(label) 6 | if (endsWith(startCased, ' Ids')) { 7 | return pluralize(startCased.replace(/ Ids$/, '')) 8 | } else if (endsWith(startCased, ' Id')) { 9 | return startCased.replace(/ Id$/, '') 10 | } else { 11 | return startCased 12 | } 13 | } 14 | 15 | export default formatLabel 16 | -------------------------------------------------------------------------------- /src/removeTypename.js: -------------------------------------------------------------------------------- 1 | function removeTypename (data) { 2 | if (Array.isArray(data)) { 3 | return data.map(removeTypename) 4 | } else if (data && typeof data === 'object') { 5 | const convertedData = {} 6 | for (const key in data) { 7 | if (key !== '__typename') { 8 | convertedData[key] = removeTypename(data[key]) 9 | } 10 | } 11 | return convertedData 12 | } else { 13 | return data 14 | } 15 | } 16 | 17 | export default removeTypename 18 | -------------------------------------------------------------------------------- /src/formLib/nullToUndefined.js: -------------------------------------------------------------------------------- 1 | function nullToUndefined (data) { 2 | if (data === null) { 3 | return undefined 4 | } else if (Array.isArray(data)) { 5 | return data.map(nullToUndefined) 6 | } else if (typeof data === 'object') { 7 | return Object.keys(data).reduce((acc, key) => { 8 | return { 9 | ...acc, 10 | [key]: nullToUndefined(data[key]) 11 | } 12 | }, {}) 13 | } else { 14 | return data 15 | } 16 | } 17 | 18 | export default nullToUndefined 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb" 4 | ], 5 | "parser": "babel-eslint", 6 | "parserOptions": { 7 | "ecmaVersion": 8, 8 | "ecmaFeatures": { 9 | "experimentalObjectRestSpread": true, 10 | "experimentalDecorators": true 11 | } 12 | }, 13 | "plugins": [ 14 | "react" 15 | ], 16 | "rules": { 17 | "semi": 0, 18 | "react/forbid-prop-types": 0, 19 | "react/jsx-filename-extension": 0 20 | }, 21 | "globals": { 22 | "alert": true, 23 | "document": true, 24 | "localStorage": true, 25 | "window": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/AppLib/formLib/Label.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import formatLabel from './formatLabel'; 3 | 4 | const REQUIRED_FIELD_SYMBOL = "*"; 5 | 6 | function Label(props) { 7 | const { label, required, id } = props; 8 | if (!label) { 9 | // See #312: Ensure compatibility with old versions of React. 10 | return
; 11 | } 12 | const formattedLabel = formatLabel(label) 13 | return ( 14 | 17 | ); 18 | } 19 | 20 | 21 | export default Label; 22 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/Paginate.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactPaginate from 'react-paginate' 3 | 4 | function Paginate ({ skip, limit, total, onSkipChange }) { 5 | const pageCount = Math.ceil(total / limit) 6 | const forcePage = Math.ceil(skip / limit) 7 | return onSkipChange(limit * selected)} 12 | pageCount={pageCount} 13 | pageRangeDisplayed={5} 14 | marginPagesDisplayed={3} 15 | /> 16 | } 17 | 18 | export default Paginate 19 | -------------------------------------------------------------------------------- /src/AppLib/formLib/TitleField.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import formatLabel from './formatLabel'; 4 | 5 | const REQUIRED_FIELD_SYMBOL = "*"; 6 | 7 | function TitleField(props) { 8 | const { id, title, required } = props; 9 | const formattedLabel = formatLabel(title) 10 | const legend = required ? formattedLabel + REQUIRED_FIELD_SYMBOL : formattedLabel; 11 | return {legend}; 12 | } 13 | 14 | if (process.env.NODE_ENV !== "production") { 15 | TitleField.propTypes = { 16 | id: PropTypes.string, 17 | title: PropTypes.string, 18 | required: PropTypes.bool, 19 | }; 20 | } 21 | 22 | export default TitleField; 23 | -------------------------------------------------------------------------------- /src/formLib/undefinedToNull.js: -------------------------------------------------------------------------------- 1 | function undefinedToNull (data) { 2 | if (data === undefined) { 3 | return null 4 | } else if (Array.isArray(data)) { 5 | // remove null in array 6 | return data.filter((value) => value != null) 7 | } else if (typeof data === 'object') { 8 | const obj = Object.keys(data).reduce((acc, key) => { 9 | return { 10 | ...acc, 11 | [key]: undefinedToNull(data[key]) 12 | } 13 | }, {}) 14 | // use null to replace empty object 15 | const isPresent = Object.values(obj).some((value) => value !== null) 16 | return isPresent ? obj : null 17 | } else { 18 | return data 19 | } 20 | } 21 | 22 | export default undefinedToNull 23 | -------------------------------------------------------------------------------- /src/AppLib/formLib/CurrencyWidget.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import NumerWidget from './NumberWidget'; 4 | 5 | const CurrencyWidget = ({ options, ...otherProps }) => { 6 | const mergedOptions = { 7 | ...options, 8 | props: { 9 | decimalScale: 2, 10 | placeholder: '$', 11 | prefix: '$', 12 | ...options.props, 13 | }, 14 | }; 15 | 16 | return ( 17 | 21 | ); 22 | }; 23 | 24 | CurrencyWidget.propTypes = { 25 | options: PropTypes.object, 26 | }; 27 | 28 | CurrencyWidget.defaultProps = { 29 | options: {}, 30 | }; 31 | 32 | export default CurrencyWidget; 33 | -------------------------------------------------------------------------------- /src/AppLib/formLib/HasOneWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SimpleSelect } from 'react-selectize' 3 | import withOptionItems from './withOptionItems' 4 | import { sortBy } from 'lodash' 5 | 6 | function SimpleSelectWidget (props) { 7 | const { optionItems } = props 8 | const valueItem = optionItems.find(option => option.value === props.value) 9 | 10 | return label.toUpperCase())} 13 | value={valueItem} 14 | onValueChange={v => props.onChange(v && v.value)} 15 | /> 16 | } 17 | 18 | const HasOneWidget = withOptionItems(SimpleSelectWidget, 'widget') 19 | 20 | export default HasOneWidget 21 | -------------------------------------------------------------------------------- /src/AppLib/formLib/NumberWidget.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import NumberFormat from 'react-number-format'; 4 | 5 | const NumberWidget = ({ onChange, value, options }) => { 6 | const { props = {} } = options; 7 | 8 | return ( 9 | { 12 | onChange(floatValue || undefined); 13 | }} 14 | value={value} 15 | thousandSeparator 16 | {...props} 17 | /> 18 | ); 19 | }; 20 | 21 | NumberWidget.propTypes = { 22 | onChange: PropTypes.func.isRequired, 23 | options: PropTypes.object, 24 | value: PropTypes.number, 25 | }; 26 | 27 | NumberWidget.defaultProps = { 28 | options: {}, 29 | value: undefined, 30 | }; 31 | 32 | export default NumberWidget; 33 | -------------------------------------------------------------------------------- /src/GqlCmsConfigLib/jsonSchemaToGqlQuery.js: -------------------------------------------------------------------------------- 1 | function jsonSchemaToGqlQuery (jsonSchema) { 2 | const { type, properties, items } = jsonSchema 3 | switch (type) { 4 | case 'string': 5 | case 'number': 6 | case 'integer': 7 | case 'boolean': 8 | return ''; 9 | case 'object': 10 | const fields = Object 11 | .keys(properties) 12 | .map(key => ({ key, query: jsonSchemaToGqlQuery(properties[key]) })) 13 | .map(({ key, query }) => query ? `${key} ${query}` : key) 14 | .join('\n') 15 | return `{ 16 | ${fields} 17 | }` 18 | case 'array': 19 | const query = jsonSchemaToGqlQuery(items) 20 | return query 21 | default: 22 | throw new Error(`jsonSchemaToGqlQuery unrecognized type: ${type}`) 23 | } 24 | } 25 | 26 | export default jsonSchemaToGqlQuery 27 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | class Search extends React.Component { 4 | render() { 5 | const { search, onSearchChange } = this.props 6 | const style = { 7 | maxWidth: "500px", 8 | margin: "30px 0", 9 | } 10 | return
11 | onSearchChange(e.target.value)} 18 | /> 19 | { 20 | search 21 | ? onSearchChange("")}> 22 | X 23 | 24 | : null 25 | } 26 |
27 | } 28 | } 29 | 30 | export default Search 31 | -------------------------------------------------------------------------------- /src/AppLib/formLib/renderValue.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // add a "x" so that the user can remove that item directly 4 | function renderValue(item, sortable, items, parent) { 5 | return ( 6 |
7 |
8 | {sortable && item.index > 0 && 9 | { 12 | parent.reorder(item.value, '<') 13 | }} 14 | > 15 | {'<'} 16 | 17 | } 18 | { 21 | parent.removeByValue(item.value) 22 | }} 23 | > 24 | x 25 | 26 | {item.label} 27 | {sortable && item.index < items.length - 1 && 28 | { 31 | parent.reorder(item.value, '>') 32 | }} 33 | > 34 | {'>'} 35 | 36 | } 37 |
38 |
39 | ); 40 | } 41 | 42 | export default renderValue 43 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/TdAction.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom'; 3 | import { get } from 'lodash' 4 | 5 | function TdAction ({ resource, row, handleDelete }) { 6 | const resourceName = resource.displayName || resource.name 7 | 8 | return 9 | { 10 | resource.crudMapping.update 11 | ? 15 | Edit 16 | 17 | : null 18 | } 19 | { 20 | resource.crudMapping.delete 21 | ? 27 | : null 28 | } 29 | { 30 | (resource.crudMapping.readOne && get(resource, 'readOneSchema.show')) 31 | ? 35 | View 36 | 37 | : null 38 | } 39 | 40 | } 41 | 42 | export default TdAction 43 | -------------------------------------------------------------------------------- /src/graphqlWithoutCache.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withApollo } from 'react-apollo'; 3 | 4 | class DataFetchingComponent extends React.Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | loading: true, 9 | } 10 | } 11 | componentDidMount() { 12 | const { client, Query, queryOptions } = this.props 13 | client 14 | .query({ ...queryOptions, query: Query, fetchPolicy: 'network-only' }) 15 | .then((res) => { 16 | this.setState({ 17 | loading: false, 18 | ...res.data, 19 | }) 20 | }) 21 | .catch((e) => { 22 | throw e; 23 | }) 24 | } 25 | render() { 26 | const { Component } = this.props 27 | return 31 | } 32 | } 33 | 34 | function graphqlWithoutCache (Query, queryOptionsContainer={}) { 35 | return (Component) => { 36 | return withApollo((props) => ) 42 | } 43 | } 44 | 45 | export default graphqlWithoutCache 46 | -------------------------------------------------------------------------------- /src/AppLib/NavBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { connect } from 'react-redux' 4 | import { setAccessToken } from '../StateHOF' 5 | 6 | function NavBar (props) { 7 | const { accessToken, setAccessToken, config } = props 8 | const brand = config.brand || 'CMS' 9 | 10 | function handleClick () { 11 | setAccessToken(null) 12 | window.location = '/' 13 | } 14 | 15 | return 36 | } 37 | 38 | const NavBarWithState = connect( 39 | (state) => ({ accessToken: state.accessToken }), 40 | { setAccessToken } 41 | )(NavBar) 42 | 43 | 44 | export default NavBarWithState 45 | -------------------------------------------------------------------------------- /src/stylesheets/bootstrap-override.css: -------------------------------------------------------------------------------- 1 | /* https://bootsnipp.com/snippets/featured/bootstrap-outline-buttons */ 2 | .btn-outline { 3 | background-color: transparent; 4 | color: inherit; 5 | transition: all .5s; 6 | } 7 | 8 | .btn-primary.btn-outline { 9 | color: #428bca; 10 | } 11 | 12 | .btn-success.btn-outline { 13 | color: #5cb85c; 14 | } 15 | 16 | .btn-info.btn-outline { 17 | color: #5bc0de; 18 | } 19 | 20 | .btn-warning.btn-outline { 21 | color: #f0ad4e; 22 | } 23 | 24 | .btn-danger.btn-outline { 25 | color: #d9534f; 26 | } 27 | 28 | .btn-primary.btn-outline:hover, 29 | .btn-success.btn-outline:hover, 30 | .btn-info.btn-outline:hover, 31 | .btn-warning.btn-outline:hover, 32 | .btn-danger.btn-outline:hover { 33 | color: #fff; 34 | } 35 | 36 | /* change columns structure of the first level fields */ 37 | @media (min-width: 1200px) { 38 | .rjsf { 39 | width: 50%; 40 | } 41 | /* .rjsf > .form-group.field.field-object fieldset > .form-group { */ 42 | /* width: 50%; */ 43 | /* } */ 44 | } 45 | 46 | /* selectize */ 47 | .rjsf .react-selectize.simple-select, .rjsf .react-selectize.multi-select { 48 | width: 100%; 49 | } 50 | 51 | /* form add padding to end */ 52 | .rjsf { 53 | margin-bottom: 200px; 54 | } 55 | -------------------------------------------------------------------------------- /src/AppLib/IndexRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Link, Redirect } from 'react-router-dom'; 3 | import { startCase } from 'lodash' 4 | import pluralize from 'pluralize' 5 | 6 | function IndexRoute (props) { 7 | const { config, history } = props 8 | if (history.location.pathname === '/' && config.initialPath) { 9 | return 10 | } 11 | 12 | const indexRouteElement = { 16 | const resourceLinks = config 17 | .resources 18 | .reduce((acc, resource) => { 19 | if (resource.crudMapping.readMany) { 20 | const resourceName = resource.displayName || resource.name 21 | 22 | acc.push( 23 | 24 | 25 | {startCase(pluralize(resourceName))} 26 | 27 | 28 | ); 29 | } 30 | return acc; 31 | }, []) 32 | 33 | return
34 |

Site Administration

35 | 36 | 37 | {resourceLinks} 38 | 39 |
40 |
41 | }} 42 | /> 43 | 44 | return indexRouteElement 45 | } 46 | 47 | export default IndexRoute 48 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/getReadManyInputQueryString.js: -------------------------------------------------------------------------------- 1 | function getReadManyInputQueryString (schema, variables={}) { 2 | const { paginationStrategy, sortStrategy, searchStrategy } = schema 3 | const inputPairs = [] 4 | if (paginationStrategy) { 5 | inputPairs.push({ key: 'skip', value: variables.skip }) 6 | inputPairs.push({ key: 'limit', value: variables.limit }) 7 | } 8 | 9 | if (sortStrategy) { 10 | // note: the replace is used to remove quotes from properties 11 | const sortValue = [].concat(variables.sort) 12 | const shouldAddDefaultSort = sortStrategy.type === 'SINGLE' 13 | && sortValue.length === 0 14 | && sortStrategy.defaultSortField 15 | if (shouldAddDefaultSort) { 16 | sortValue.push({ 17 | field: sortStrategy.defaultSortField, 18 | order: 'ASC', 19 | }) 20 | } 21 | inputPairs.push({ 22 | key: 'sort', 23 | value: JSON.stringify(sortValue).replace(/"([^(")"]+)":/g,"$1:"), 24 | }) 25 | } 26 | 27 | if (searchStrategy) { 28 | inputPairs.push({ 29 | key: 'search', 30 | value: `"${variables.search}"` 31 | }) 32 | } 33 | 34 | if (Object.keys(inputPairs).length === 0) { 35 | return '' 36 | } 37 | 38 | const string = inputPairs 39 | .map(({ key, value }) => { 40 | return [key, value].join(': ') 41 | }) 42 | .join(', ') 43 | 44 | return `(${string})` 45 | } 46 | 47 | export default getReadManyInputQueryString 48 | -------------------------------------------------------------------------------- /src/AppLib/Login.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { graphql } from 'react-apollo' 3 | import LoginMutation from '../resolvers/Mutation/LoginMutation' 4 | import alertFirstGqlMsg from '../alertFirstGqlMsg' 5 | 6 | class Login extends React.Component { 7 | constructor(props) { 8 | super(props) 9 | this.state = { 10 | code: '' 11 | } 12 | } 13 | 14 | render() { 15 | const onSubmit = async (e) => { 16 | e.preventDefault(); 17 | try { 18 | const response = await this.props.mutate({ 19 | variables: { 20 | loginInput: { 21 | code: this.state.code 22 | } 23 | } 24 | }) 25 | const token = response.data.login 26 | this.props.setAccessToken(token) 27 | } catch (e) { 28 | alertFirstGqlMsg(e) 29 | } 30 | } 31 | 32 | const containerStyle = { 33 | display: 'flex', 34 | flexDirection: 'column', 35 | justifyContent: 'center', 36 | alignItems: 'center' 37 | } 38 | 39 | return
40 |

Login

41 |
42 | this.setState({ code: e.target.value })} 46 | /> 47 | 48 |
49 |
50 | } 51 | } 52 | 53 | const LoginFormWithData = graphql(LoginMutation)(Login) 54 | 55 | export default LoginFormWithData 56 | -------------------------------------------------------------------------------- /src/AppLib/formLib/MultiSelectField.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MultiSelect } from 'react-selectize'; 3 | import Label from './Label'; 4 | import renderValue from './renderValue'; 5 | 6 | class MultiSelectWidget extends React.Component { 7 | render() { 8 | const { 9 | loading, 10 | optionItems, 11 | name, 12 | uiSchema, 13 | idSchema, 14 | required, 15 | onChange, 16 | formData, 17 | disabled, 18 | readonly, 19 | } = this.props 20 | 21 | if (loading) { 22 | return null; 23 | } 24 | 25 | const id = idSchema.$id; 26 | const label = uiSchema["ui:title"] || name; 27 | const valueObjects = optionItems 28 | .filter(option => (formData || []).includes(option.value)) 29 | 30 | // NOTE: the reason we do this is to fix a bug in react selectize 31 | // that package caches the renderValue function and it will use the 32 | // up to date closure data for the function, we use a instance method 33 | // to bypass this problem 34 | this.removeByValue = (value) => { 35 | const nextFormData = formData.filter(v => v !== value) 36 | onChange(nextFormData) 37 | } 38 | 39 | return
40 |
51 | } 52 | } 53 | 54 | export default MultiSelectWidget 55 | -------------------------------------------------------------------------------- /src/AppLib/formLib/FieldTemplate.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Label from './Label'; 4 | 5 | function FieldTemplate(props) { 6 | const { 7 | id, 8 | classNames, 9 | label, 10 | children, 11 | errors, 12 | help, 13 | description, 14 | hidden, 15 | required, 16 | displayLabel, 17 | } = props; 18 | if (hidden) { 19 | return children; 20 | } 21 | 22 | return ( 23 |
24 | {displayLabel &&
30 | ); 31 | } 32 | 33 | if (process.env.NODE_ENV !== "production") { 34 | FieldTemplate.propTypes = { 35 | id: PropTypes.string, 36 | classNames: PropTypes.string, 37 | label: PropTypes.string, 38 | children: PropTypes.node.isRequired, 39 | errors: PropTypes.element, 40 | rawErrors: PropTypes.arrayOf(PropTypes.string), 41 | help: PropTypes.element, 42 | rawHelp: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 43 | description: PropTypes.element, 44 | rawDescription: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 45 | hidden: PropTypes.bool, 46 | required: PropTypes.bool, 47 | readonly: PropTypes.bool, 48 | displayLabel: PropTypes.bool, 49 | fields: PropTypes.object, 50 | formContext: PropTypes.object, 51 | }; 52 | } 53 | 54 | FieldTemplate.defaultProps = { 55 | hidden: false, 56 | readonly: false, 57 | required: false, 58 | displayLabel: true, 59 | }; 60 | 61 | export default FieldTemplate 62 | -------------------------------------------------------------------------------- /src/AppLib/LoginLib/CodeLogin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { gql, graphql } from 'react-apollo' 3 | import alertFirstGqlMsg from '../../alertFirstGqlMsg' 4 | import { setAccessToken } from '../../StateHOF'; 5 | import { connect } from 'react-redux' 6 | 7 | const LoginMutation = gql` 8 | mutation LoginMutation($loginInput: LoginInput!) { 9 | login(loginInput: $loginInput) 10 | } 11 | ` 12 | 13 | class Login extends React.Component { 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | code: '' 18 | } 19 | } 20 | 21 | render() { 22 | const onSubmit = async (e) => { 23 | e.preventDefault(); 24 | try { 25 | const response = await this.props.mutate({ 26 | variables: { 27 | loginInput: { 28 | code: this.state.code 29 | } 30 | } 31 | }) 32 | const token = response.data.login 33 | this.props.setAccessToken(token) 34 | } catch (e) { 35 | alertFirstGqlMsg(e) 36 | } 37 | } 38 | 39 | const containerStyle = { 40 | display: 'flex', 41 | flexDirection: 'column', 42 | justifyContent: 'center', 43 | alignItems: 'center' 44 | } 45 | 46 | return
47 |

Login

48 |
49 | this.setState({ code: e.target.value })} 53 | /> 54 | 55 |
56 |
57 | } 58 | } 59 | 60 | const LoginFormWithData = graphql(LoginMutation)(Login) 61 | 62 | const LoginFormWithState = connect( 63 | (state) => ({ accessToken: state.accessToken }), 64 | { setAccessToken } 65 | )(LoginFormWithData) 66 | 67 | export default LoginFormWithState 68 | -------------------------------------------------------------------------------- /src/AppLib/Create.js: -------------------------------------------------------------------------------- 1 | import { upperFirst, startCase } from 'lodash'; 2 | import React from 'react' 3 | import { gql, graphql } from 'react-apollo' 4 | import { toast } from 'react-toastify' 5 | import Form from './Form'; 6 | import getCRUDSchemaFromResource from '../GqlCmsConfigLib/getCRUDSchemaFromResource' 7 | import alertFirstGqlMsg from '../alertFirstGqlMsg' 8 | import undefinedToNull from '../formLib/undefinedToNull' 9 | 10 | function Create (props) { 11 | const { resource, mutate, config, history } = props 12 | 13 | const resourceName = resource.displayName || resource.name 14 | 15 | const onSubmit = async ({ formData }) => { 16 | const input = undefinedToNull(formData) 17 | try { 18 | await mutate({ variables: { input } }) 19 | history.push(`/${resourceName}`) 20 | toast.success('Create Success') 21 | } catch (e) { 22 | alertFirstGqlMsg(e) 23 | } 24 | } 25 | const onCancel = () => { 26 | history.push(`/${resourceName}`) 27 | } 28 | const createSchema = getCRUDSchemaFromResource({ 29 | config, 30 | resource, 31 | crudType: 'create' 32 | }) 33 | return
34 |

Create {startCase(resourceName)}

35 |
43 |
44 | } 45 | 46 | function CreateWithData (props) { 47 | const { resource } = props; 48 | const { crudMapping } = resource; 49 | let Component = Create 50 | const CreateQuery = gql` 51 | mutation ${crudMapping.create}($input: ${upperFirst(resource.name + 'Input')}!) { 52 | ${crudMapping.create}(${resource.name} : $input) 53 | } 54 | `; 55 | Component = graphql(CreateQuery)(Component) 56 | 57 | return 58 | } 59 | 60 | export default CreateWithData 61 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/Tr.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { gql } from 'react-apollo'; 4 | import { toast } from 'react-toastify'; 5 | import TdAction from './TdAction'; 6 | import alertFirstGqlMsg from '../../alertFirstGqlMsg'; 7 | 8 | function Tr(props) { 9 | const { 10 | row, 11 | resource, 12 | cellFormatter, 13 | mutate, 14 | changeUrl, 15 | columnNames, 16 | } = props; 17 | 18 | const handleDelete = async () => { 19 | // eslint-disable-next-line no-alert 20 | const confirm = window.confirm('Are you sure you want to delete?'); 21 | 22 | if (!confirm) { 23 | return; 24 | } 25 | 26 | const { crudMapping, uniqKey } = resource; 27 | const uniqKeyQuery = { [uniqKey]: row[uniqKey] }; 28 | 29 | const deleteMutation = gql` 30 | mutation ${crudMapping.delete}($${uniqKey}: String!) { 31 | ${crudMapping.delete}(${uniqKey}: $${uniqKey}) 32 | } 33 | `; 34 | 35 | try { 36 | await mutate({ 37 | mutation: deleteMutation, 38 | variables: uniqKeyQuery, 39 | }); 40 | 41 | changeUrl(); 42 | toast.success('Delete Success'); 43 | } catch (error) { 44 | alertFirstGqlMsg(error); 45 | } 46 | } 47 | 48 | const tdFieldElements = columnNames.map(columnName => ( 49 | 50 | {cellFormatter(row[columnName], row, columnName)} 51 | 52 | )); 53 | 54 | return ( 55 | 56 | {tdFieldElements} 57 | 63 | 64 | ); 65 | } 66 | 67 | Tr.propTypes = { 68 | row: PropTypes.object.isRequired, 69 | resource: PropTypes.object.isRequired, 70 | cellFormatter: PropTypes.func.isRequired, 71 | mutate: PropTypes.func.isRequired, 72 | changeUrl: PropTypes.func.isRequired, 73 | columnNames: PropTypes.arrayOf(PropTypes.string).isRequired, 74 | }; 75 | 76 | export default Tr; 77 | -------------------------------------------------------------------------------- /src/AppLib/Form.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Form from 'react-jsonschema-form'; 3 | import HasOneWidget from './formLib/HasOneWidget'; 4 | import WysiwygWidget from './formLib/WysiwygWidget'; 5 | import HasManyField from './formLib/HasManyField'; 6 | import FieldTemplate from './formLib/FieldTemplate'; 7 | // NOTE: TitleField is used for ArrayField 8 | import TitleField from './formLib/TitleField'; 9 | import CurrencyWidget from './formLib/CurrencyWidget'; 10 | import NumberWidget from './formLib/NumberWidget'; 11 | 12 | function FormButtons(props) { 13 | const { onCancel, readOnly } = props; 14 | 15 | if (readOnly) { 16 | return ( 17 |
18 | 21 | 24 |
25 | ); 26 | } 27 | 28 | return ( 29 |
30 | 33 | 36 |
37 | ); 38 | } 39 | 40 | const defaultWidgets = { 41 | currencyWidget: CurrencyWidget, 42 | hasOneWidget: HasOneWidget, 43 | numberWidget: NumberWidget, 44 | wysiwygWidget: WysiwygWidget, 45 | }; 46 | 47 | const defaultFields = { 48 | hasManyField: HasManyField, 49 | TitleField, 50 | }; 51 | 52 | function ExtendedForm(props) { 53 | const jsonSchemaFormExtensions = props.jsonSchemaFormExtensions || {}; 54 | const { 55 | widgets = {}, 56 | fields = {}, 57 | } = jsonSchemaFormExtensions; 58 | 59 | return ( 60 | 66 | 70 | 71 | ); 72 | } 73 | 74 | export default ExtendedForm; 75 | -------------------------------------------------------------------------------- /src/AppLib/ReadOne.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { gql } from 'react-apollo' 3 | import Form from './Form'; 4 | import jsonSchemaToGqlQuery from '../GqlCmsConfigLib/jsonSchemaToGqlQuery' 5 | import getCRUDSchemaFromResource from '../GqlCmsConfigLib/getCRUDSchemaFromResource' 6 | import removeTypename from '../removeTypename' 7 | import graphqlWithoutCache from '../graphqlWithoutCache' 8 | 9 | function ReadOne(props) { 10 | const { config, resource, data, history } = props; 11 | const purifiedFormData = removeTypename(data[resource.crudMapping.readOne]) 12 | if (data.loading) { 13 | return null; 14 | } 15 | 16 | const resourceName = resource.displayName || resource.name 17 | 18 | const onCancel = () => { 19 | history.push(`/${resourceName}`) 20 | } 21 | const readOneSchema = getCRUDSchemaFromResource({ 22 | config, 23 | resource, 24 | crudType: 'readOne' 25 | }) 26 | const uiSchema = { 27 | 'ui:disabled': true, 28 | ...readOneSchema.uiSchema 29 | } 30 | return
31 |
40 |
41 | } 42 | 43 | function ReadOneWithData (props) { 44 | const { config, resource } = props; 45 | const { crudMapping, uniqKey } = resource; 46 | const readOneSchema = getCRUDSchemaFromResource({ 47 | config, 48 | resource, 49 | crudType: 'readOne' 50 | }) 51 | 52 | let Component = ReadOne 53 | const fieldsQuery = jsonSchemaToGqlQuery(readOneSchema.jsonSchema) 54 | const ReadOneQuery = gql` 55 | query ${crudMapping.readOne}($${uniqKey}: String!) { 56 | ${crudMapping.readOne}(${uniqKey}: $${uniqKey}) ${fieldsQuery} 57 | } 58 | ` 59 | const uniqKeyValue = props.match.params[uniqKey] 60 | Component = graphqlWithoutCache( 61 | ReadOneQuery, 62 | { options: { variables: { [uniqKey]: uniqKeyValue } } } 63 | )(Component) 64 | 65 | return 66 | } 67 | 68 | export default ReadOneWithData 69 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | CMS 30 | 31 | 32 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "byob-cms", 3 | "version": "1.26.0", 4 | "description": "Bring your own backend! A client side only cms that speaks graphQL.", 5 | "main": "index.js", 6 | "dependencies": { 7 | "babel-polyfill": "^6.26.0", 8 | "draft-js": "^0.10.4", 9 | "draft-js-import-html": "^1.2.1", 10 | "draftjs-to-html": "^0.8.2", 11 | "draftjs-utils": "^0.9.1", 12 | "jwt-decode": "^2.2.0", 13 | "lodash": "^4.17.4", 14 | "prop-types": "^15.6.0", 15 | "qs": "^6.5.1", 16 | "react": "^15.6.1", 17 | "react-apollo": "^1.4.15", 18 | "react-dom": "^15.6.1", 19 | "react-dom-factories": "^1.0.1", 20 | "react-draft-wysiwyg": "^1.12.3", 21 | "react-jsonschema-form": "^0.49.0", 22 | "react-number-format": "^3.1.3", 23 | "react-paginate": "^4.4.4", 24 | "react-redux": "^5.0.6", 25 | "react-router": "^4.2.0", 26 | "react-router-dom": "^4.2.2", 27 | "react-scripts": "1.0.11", 28 | "react-selectize": "^3.0.1", 29 | "react-toastify": "2.1.0", 30 | "react-transition-group": "^1.1.2" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "rm -rf dist/; babel src -d dist", 35 | "test": "react-scripts test --env=jsdom", 36 | "eject": "react-scripts eject", 37 | "lint": "eslint src --ext .js", 38 | "prepublishOnly": "npm run build" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/poetic/byob-cms.git" 43 | }, 44 | "keywords": [ 45 | "cms", 46 | "graphql", 47 | "front-end" 48 | ], 49 | "author": "Chun Yang", 50 | "license": "ISC", 51 | "bugs": { 52 | "url": "https://github.com/poetic/byob-cms/issues" 53 | }, 54 | "homepage": "https://github.com/poetic/byob-cms#readme", 55 | "devDependencies": { 56 | "babel-cli": "^6.26.0", 57 | "babel-eslint": "^8.2.1", 58 | "babel-preset-es2015": "^6.24.1", 59 | "babel-preset-react": "^6.24.1", 60 | "babel-preset-stage-2": "^6.24.1", 61 | "eslint": "^4.12.0", 62 | "eslint-config-airbnb": "^16.1.0", 63 | "eslint-plugin-import": "^2.8.0", 64 | "eslint-plugin-jsx-a11y": "^6.0.2", 65 | "eslint-plugin-react": "^7.5.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/AppLib/formLib/withOptionItems.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { gql } from 'react-apollo' 3 | import { get, flow, upperFirst, last, endsWith } from 'lodash' 4 | import { withApollo } from 'react-apollo' 5 | 6 | function withGql (Component) { 7 | class WithGqlComponent extends React.Component { 8 | constructor(props) { 9 | super(props) 10 | this.state = { 11 | loading: true, 12 | optionItems: [], 13 | } 14 | } 15 | 16 | componentDidMount() { 17 | const { gqlOptionsName, client } = this.props 18 | 19 | const query = gql` 20 | query ${upperFirst(gqlOptionsName)}Query { 21 | ${gqlOptionsName} { 22 | value 23 | label 24 | } 25 | }` 26 | 27 | client 28 | .query({ query, fetchPolicy: 'network-only' }) 29 | .then(({ data }) => { 30 | this.setState({ 31 | loading: false, 32 | optionItems: data[gqlOptionsName], 33 | }) 34 | }) 35 | .catch((e) => { 36 | throw e; 37 | }) 38 | } 39 | 40 | render() { 41 | if (this.state.loading) { 42 | return null 43 | } 44 | return 45 | } 46 | } 47 | 48 | return WithGqlComponent 49 | } 50 | 51 | function idToGqlOptionsName (id) { 52 | let name = last(id.split('_')) 53 | if (endsWith(name, 'Id')) { 54 | name = name.replace(/Id$/, '') 55 | } else if (endsWith(name, 'Ids')) { 56 | name = name.replace(/Ids$/, '') 57 | } 58 | return name + 'Options' 59 | } 60 | 61 | function withGqlOptionNameForWidget (Component) { 62 | function WithGqlOptionNameComponent (props) { 63 | const gqlOptionsName = props.options.gqlOptionsName 64 | || idToGqlOptionsName(props.id) 65 | return 66 | } 67 | return WithGqlOptionNameComponent 68 | } 69 | 70 | function withGqlOptionNameForField (Component) { 71 | function WithGqlOptionNameComponent (props) { 72 | const gqlOptionsName = get(props, 'uiSchema.ui:options.gqlOptionsName') 73 | || idToGqlOptionsName(props.name) 74 | return 75 | } 76 | return WithGqlOptionNameComponent 77 | } 78 | 79 | const withGqlOptionNameMap = { 80 | widget: withGqlOptionNameForWidget, 81 | field: withGqlOptionNameForField, 82 | } 83 | 84 | function withOptionItems (Component, type) { 85 | return flow(withGql, withGqlOptionNameMap[type], withApollo)(Component) 86 | } 87 | 88 | export default withOptionItems 89 | -------------------------------------------------------------------------------- /src/AppLib/formLib/HasManyField.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MultiSelect } from 'react-selectize' 3 | import { sortBy } from 'lodash' 4 | import Label from './Label' 5 | import renderValue from './renderValue' 6 | import withOptionItems from './withOptionItems' 7 | 8 | class MultiSelectWidget extends React.Component { 9 | render() { 10 | const { 11 | optionItems, 12 | name, 13 | schema, 14 | uiSchema, 15 | idSchema, 16 | required, 17 | onChange, 18 | formData, 19 | disabled, 20 | readonly, 21 | } = this.props 22 | const id = idSchema.$id 23 | const lbl = uiSchema['ui:title'] || schema.title || name 24 | const valueItems = (formData || []).map(item => optionItems.find(opt => opt.value === item)) 25 | const sortable = uiSchema['ui:options'] && uiSchema['ui:options'].sortable 26 | // NOTE: the reason we do this is to fix a bug in react selectize 27 | // that package caches the renderValue function and it will use the 28 | // up to date closure data for the function, we use a instance method 29 | // to bypass this problem 30 | this.removeByValue = (value) => { 31 | const nextFormData = formData.filter(v => v !== value) 32 | onChange(nextFormData) 33 | } 34 | 35 | this.resortItems = (values) => { 36 | const valItem = values.map(v => formData.find(f => f === v)) 37 | onChange(valItem) 38 | } 39 | 40 | this.reorder = (value, direction) => { 41 | let values = valueItems.map(v => v.value) 42 | const indx = values.indexOf(value) 43 | if (direction === '<') { 44 | values = [ 45 | ...values.slice(0, indx - 1), 46 | value, 47 | values[indx - 1], 48 | ...values.slice(indx + 1), 49 | ] 50 | } else { 51 | values = [ 52 | ...values.slice(0, indx), 53 | values[indx + 1], 54 | value, 55 | ...values.slice(indx + 2), 56 | ] 57 | } 58 | this.resortItems(values) 59 | } 60 | 61 | return ( 62 |
63 |
74 | ) 75 | } 76 | } 77 | 78 | const HasManyField = withOptionItems(MultiSelectWidget, 'field') 79 | 80 | export default HasManyField 81 | -------------------------------------------------------------------------------- /src/AppLib/formLib/TagIdsField.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { gql } from 'react-apollo' 3 | import { groupBy, difference, union, find } from 'lodash' 4 | import { MultiSelect } from 'react-selectize'; 5 | import Label from './Label' 6 | import graphqlWithoutCache from '../../graphqlWithoutCache' 7 | import renderValue from './renderValue' 8 | 9 | class TagIdsField extends React.Component { 10 | // NOTE: this is used for MultiSelect 11 | removeByValue(value) { 12 | const { formData, onChange } = this.props 13 | const nextFormData = formData.filter(v => v !== value) 14 | onChange(nextFormData) 15 | } 16 | 17 | render() { 18 | const props = this.props; 19 | const { 20 | name, 21 | uiSchema, 22 | idSchema, 23 | required, 24 | onChange, 25 | formData, 26 | disabled, 27 | readonly, 28 | data: { loading, tags, tagQuestions }, 29 | } = props 30 | 31 | if (loading) { 32 | return null 33 | } 34 | 35 | const id = idSchema.$id; 36 | const label = uiSchema["ui:title"] || name; 37 | 38 | const tagsGroundByQuestions = groupBy(tags, 'tagQuestionId') 39 | const tagsGroundByQuestionsElements = tagQuestions.map((tagQuestion) => { 40 | const optionItems = tagsGroundByQuestions[tagQuestion._id].map((t) => ({ 41 | value: t._id, 42 | label: t.title, 43 | })) 44 | const valuesForQuestion = formData.filter((tagId) => { 45 | return find(tags, { _id: tagId }).tagQuestionId === tagQuestion._id 46 | }) 47 | const valueObjects = optionItems.filter((optionItem) => { 48 | return valuesForQuestion.includes(optionItem.value) 49 | }) 50 | return
51 |
64 | }) 65 | 66 | return
67 |
70 | } 71 | } 72 | 73 | const Query = gql` 74 | query TagsAndTagQuestions { 75 | tags { 76 | _id 77 | title 78 | tagQuestionId 79 | } 80 | tagQuestions { 81 | _id 82 | title 83 | } 84 | } 85 | ` 86 | 87 | const TagIdsFieldWithData = graphqlWithoutCache(Query)(TagIdsField) 88 | export default TagIdsFieldWithData 89 | -------------------------------------------------------------------------------- /src/StateHOF.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 3 | import { ApolloClient, ApolloProvider, createNetworkInterface } from 'react-apollo'; 4 | import { get, once } from 'lodash' 5 | 6 | const existingAccessToken = process.browser 7 | ? window.localStorage.getItem('accessToken') 8 | : null 9 | 10 | function accessToken (state=existingAccessToken, action) { 11 | switch (action.type) { 12 | case 'SET_ACCESS_TOKEN': 13 | return action.accessToken 14 | default: 15 | return state 16 | } 17 | } 18 | 19 | function setAccessToken (accessToken) { 20 | if (accessToken) { 21 | window.localStorage.setItem('accessToken', accessToken) 22 | } else { 23 | window.localStorage.removeItem('accessToken') 24 | } 25 | return { 26 | type: 'SET_ACCESS_TOKEN', 27 | accessToken, 28 | } 29 | } 30 | 31 | function createProvider ({ config: { graphqlUrl } }) { 32 | const networkInterface = createNetworkInterface({ 33 | uri: graphqlUrl, 34 | }) 35 | 36 | networkInterface.use([{ 37 | applyMiddleware(req, next) { 38 | if (!req.options.headers) { 39 | req.options.headers = {}; // Create the header object if needed. 40 | } 41 | req.options.headers['authorization'] = localStorage.getItem('accessToken') || null; 42 | next(); 43 | }, 44 | }]) 45 | 46 | networkInterface.useAfter([{ 47 | async applyAfterware({ response }, next) { 48 | const text = await response.clone().text() 49 | const firstErrorMessage = get(JSON.parse(text), 'errors.0.message') 50 | if (firstErrorMessage === 'not-authorized') { 51 | store.dispatch(setAccessToken(null)) 52 | } 53 | next(); 54 | }, 55 | }]) 56 | 57 | const client = new ApolloClient({ 58 | networkInterface, 59 | }); 60 | 61 | const initialState = {}; 62 | 63 | // If you are using the devToolsExtension, you can add it here also 64 | const reduxDevtoolMiddleware = (typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== 'undefined') 65 | ? window.__REDUX_DEVTOOLS_EXTENSION__() 66 | : f => f; 67 | 68 | const store = createStore( 69 | combineReducers({ 70 | apollo: client.reducer(), 71 | accessToken, 72 | }), 73 | initialState, 74 | compose( 75 | applyMiddleware(client.middleware()), 76 | reduxDevtoolMiddleware, 77 | ) 78 | ); 79 | 80 | return (props) => { 81 | return 82 | {props.children} 83 | 84 | } 85 | } 86 | 87 | const createProviderOnce = once(createProvider) 88 | 89 | export default function StateHOF (Component) { 90 | return (props) => { 91 | const Provider = createProviderOnce(props) 92 | return 93 | 94 | 95 | } 96 | } 97 | 98 | export { accessToken, setAccessToken } 99 | -------------------------------------------------------------------------------- /src/AppLib/Update.js: -------------------------------------------------------------------------------- 1 | import { startCase, upperFirst } from 'lodash'; 2 | import React from 'react' 3 | import { gql, graphql } from 'react-apollo' 4 | import { toast } from 'react-toastify' 5 | import graphqlWithoutCache from '../graphqlWithoutCache' 6 | import Form from './Form'; 7 | import jsonSchemaToGqlQuery from '../GqlCmsConfigLib/jsonSchemaToGqlQuery' 8 | import getCRUDSchemaFromResource from '../GqlCmsConfigLib/getCRUDSchemaFromResource' 9 | import removeTypename from '../removeTypename' 10 | import nullToUndefined from '../formLib/nullToUndefined' 11 | import undefinedToNull from '../formLib/undefinedToNull' 12 | import alertFirstGqlMsg from '../alertFirstGqlMsg' 13 | 14 | function Update (props) { 15 | const { config, resource, mutate, data, history } = props; 16 | const dirtyFormData = data[resource.crudMapping.readOne] 17 | const purifiedFormData = nullToUndefined( 18 | removeTypename( 19 | dirtyFormData 20 | ) 21 | ) 22 | if (data.loading) { 23 | return null; 24 | } 25 | 26 | const resourceName = resource.displayName || resource.name 27 | 28 | const onSubmit = async ({ formData }) => { 29 | const input = undefinedToNull(formData) 30 | try { 31 | await mutate({ variables: { input } }) 32 | history.push(`/${resourceName}`) 33 | toast.success('Update Success') 34 | } catch (e) { 35 | alertFirstGqlMsg(e) 36 | } 37 | } 38 | const onCancel = () => { 39 | history.push(`/${resourceName}`) 40 | } 41 | const updateSchema = getCRUDSchemaFromResource({ 42 | config, 43 | resource, 44 | crudType: 'update' 45 | }) 46 | return
47 |

Update {startCase(resourceName)}

48 | 57 |
58 | } 59 | 60 | function UpdateWithData (props) { 61 | const { config, resource } = props; 62 | const { crudMapping, uniqKey } = resource; 63 | const updateSchema = getCRUDSchemaFromResource({ 64 | config, 65 | resource, 66 | crudType: 'update' 67 | }) 68 | 69 | let Component = Update 70 | const fieldsQuery = jsonSchemaToGqlQuery(updateSchema.jsonSchema) 71 | const ReadOneQuery = gql` 72 | query ${crudMapping.readOne}($${uniqKey}: String!) { 73 | ${crudMapping.readOne}(${uniqKey}: $${uniqKey}) ${fieldsQuery} 74 | } 75 | ` 76 | const uniqKeyValue = props.match.params[uniqKey] 77 | Component = graphqlWithoutCache( 78 | ReadOneQuery, 79 | { options: { variables: { [uniqKey]: uniqKeyValue } } } 80 | )(Component) 81 | 82 | const UpdateQuery = gql` 83 | mutation ${crudMapping.update}($input: ${upperFirst(updateSchema.inputName || resource.name + 'Input')}!) { 84 | ${crudMapping.update}(${resource.name} : $input, ${uniqKey}: "${uniqKeyValue}") 85 | } 86 | `; 87 | Component = graphql(UpdateQuery)(Component) 88 | 89 | return 90 | } 91 | 92 | export default UpdateWithData 93 | -------------------------------------------------------------------------------- /src/AppLib/LoginLib/EmailPasswordLogin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { gql, graphql } from 'react-apollo' 3 | import alertFirstGqlMsg from '../../alertFirstGqlMsg' 4 | import { setAccessToken } from '../../StateHOF' 5 | import { connect } from 'react-redux' 6 | 7 | const LoginMutation = gql` 8 | mutation LoginMutation($loginInput: LoginInput!) { 9 | login(loginInput: $loginInput) 10 | } 11 | ` 12 | 13 | class Login extends React.Component { 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | email: '', 18 | password: '', 19 | loggingIn: false, 20 | } 21 | } 22 | 23 | componentWillUnmount() { 24 | this.setState({ loggingIn: false }) 25 | } 26 | 27 | render() { 28 | const onSubmit = async (e) => { 29 | e.preventDefault() 30 | 31 | this.setState({ loggingIn: true }) 32 | 33 | try { 34 | const response = await this.props.mutate({ 35 | variables: { 36 | loginInput: { 37 | email: this.state.email, 38 | password: this.state.password, 39 | } 40 | } 41 | }) 42 | const token = response.data.login 43 | this.props.setAccessToken(token) 44 | } catch (e) { 45 | this.setState({ loggingIn: false }, () => { 46 | alertFirstGqlMsg(e) 47 | }) 48 | } 49 | } 50 | 51 | const containerStyle = { 52 | display: 'flex', 53 | flexDirection: 'column', 54 | justifyContent: 'center', 55 | alignItems: 'center', 56 | } 57 | const formStyle = { 58 | width: '300px', 59 | maxWidth: '100%', 60 | } 61 | const h1Style = { 62 | marginBottom: '20px', 63 | } 64 | const buttonStyle = { 65 | width: '100%', 66 | } 67 | 68 | const { email, password, loggingIn } = this.state 69 | 70 | return
71 |

Login

72 | 73 | this.setState({ email: e.target.value })} 79 | /> 80 |
81 | this.setState({ password: e.target.value })} 87 | /> 88 |
89 | 97 | 98 |
99 | } 100 | } 101 | 102 | const LoginFormWithData = graphql(LoginMutation)(Login) 103 | 104 | const LoginFormWithState = connect( 105 | (state) => ({ accessToken: state.accessToken }), 106 | { setAccessToken } 107 | )(LoginFormWithData) 108 | 109 | export default LoginFormWithState 110 | -------------------------------------------------------------------------------- /src/AppLib/ReadManyLib/ThField.js: -------------------------------------------------------------------------------- 1 | import { get, startCase } from 'lodash' 2 | import React from 'react' 3 | 4 | function UpAndDown (props) { 5 | return 6 | 7 | 12 | 16 | 17 | 18 | } 19 | 20 | function Up (props) { 21 | return 22 | 23 | 28 | 29 | 30 | } 31 | function Down (props) { 32 | return 33 | 34 | 38 | 39 | 40 | } 41 | 42 | function SortButton ({ sortOrder, changeSort }) { 43 | switch (sortOrder) { 44 | case 'ASC': 45 | return changeSort('DESC')}/> 46 | case 'DESC': 47 | return changeSort('ASC')}/> 48 | default: 49 | return changeSort('ASC')}/> 50 | } 51 | } 52 | 53 | function ThFieldSort ({ columnName, readManySchema, sort, onSortChange }) { 54 | if (!readManySchema.sortStrategy) { 55 | return null 56 | } 57 | 58 | const { type } = readManySchema.sortStrategy 59 | 60 | function changeSort (order) { 61 | if (type === 'SINGLE') { 62 | onSortChange([{ 63 | field: columnName, 64 | order, 65 | }]) 66 | } else { 67 | throw new Error(`${type} is not supported.`) 68 | } 69 | } 70 | 71 | const sortOrder = get(sort.find(({ field }) => field === columnName), 'order') 72 | const style = { 73 | float: 'right', 74 | cursor: 'pointer', 75 | } 76 | return 77 | 78 | 79 | } 80 | 81 | function ThField ({ columnName, readManySchema, sort, onSortChange }) { 82 | return 83 | {startCase(columnName)} 84 | 90 | 91 | } 92 | 93 | export default ThField 94 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill/dist/polyfill'; 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import StateHOF, { setAccessToken } from './StateHOF'; 5 | import { BrowserRouter, Route } from 'react-router-dom'; 6 | import CreateOrReadOne from './AppLib/CreateOrReadOne'; 7 | import ReadMany from './AppLib/ReadMany'; 8 | import Update from './AppLib/Update'; 9 | import IndexRoute from './AppLib/IndexRoute'; 10 | import NavBar from './AppLib/NavBar'; 11 | import CodeLogin from './AppLib/LoginLib/CodeLogin'; 12 | import EmailPasswordLogin from './AppLib/LoginLib/EmailPasswordLogin'; 13 | import withOptionItems from './AppLib/formLib/withOptionItems'; 14 | import Label from './AppLib/formLib/Label' 15 | import { ToastContainer, toast } from 'react-toastify'; 16 | 17 | class App extends Component { 18 | componentDidMount() { 19 | document.title = this.props.config.title || 'CMS' 20 | } 21 | render() { 22 | const { config } = this.props 23 | const resourceRouteElements = config 24 | .resources 25 | .reduce((acc, resource) => { 26 | const { uniqKey } = resource 27 | const { 28 | create, 29 | readMany, 30 | readOne, 31 | update, 32 | } = resource.crudMapping 33 | 34 | const resourceName = resource.displayName || resource.name 35 | 36 | if (readMany) { 37 | const path = `/${resourceName}` 38 | acc.push( } 43 | />); 44 | } 45 | if (create || readOne) { 46 | const path = `/${resourceName}/:${uniqKey}` 47 | acc.push( } 52 | />); 53 | } 54 | if (update) { 55 | const path = `/${resourceName}/:${uniqKey}/edit` 56 | acc.push( } 61 | />); 62 | } 63 | return acc; 64 | }, []) 65 | 66 | return ( 67 |
68 | 69 |
70 | 71 |
72 | } /> 73 | {resourceRouteElements} 74 |
75 |
76 |
77 |
78 | ); 79 | } 80 | } 81 | 82 | function DefaultLogin () { 83 | return
84 |

You need to provide a React Component to the "Login" option

85 |
86 | } 87 | 88 | function AppWithGuard (props) { 89 | const { config } = props 90 | const Login = config.Login || DefaultLogin 91 | 92 | const ComponentWithGuard = (props) => { 93 | if (props.accessToken) { 94 | return 95 | } else { 96 | return Login ? : null 97 | } 98 | } 99 | 100 | const ComponentWithState = connect( 101 | (state) => ({ accessToken: state.accessToken }), 102 | { setAccessToken } 103 | )(ComponentWithGuard) 104 | 105 | return 106 | } 107 | 108 | function AppWithToast (props) { 109 | return
110 | 119 | 120 |
121 | } 122 | 123 | export default StateHOF(AppWithToast); 124 | export { 125 | CodeLogin, 126 | EmailPasswordLogin, 127 | withOptionItems, 128 | Label, 129 | setAccessToken, 130 | StateHOF, 131 | ToastContainer, 132 | toast, 133 | } 134 | -------------------------------------------------------------------------------- /src/AppLib/formLib/WysiwygWidget.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { isEmpty } from 'lodash'; 3 | import { Editor } from 'react-draft-wysiwyg'; 4 | import { 5 | EditorState, 6 | convertToRaw, 7 | ContentState, 8 | ContentBlock, 9 | Modifier, 10 | CharacterMetadata, 11 | genKey, 12 | } from 'draft-js'; 13 | import { List, Repeat } from 'immutable'; 14 | import draftToHtml from 'draftjs-to-html'; 15 | import { stateFromHTML } from 'draft-js-import-html'; 16 | import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css'; 17 | import { getSelectedBlocksType } from 'draftjs-utils'; 18 | 19 | class WysiwygWidget extends Component { 20 | constructor(props) { 21 | super(props); 22 | 23 | const { value } = this.props 24 | this.state = this.handleState( value, null, false ) 25 | } 26 | 27 | // this method could switch between sync and async based on the updateState argument 28 | handleState (newHtml, newEditorState, updateState = true) { 29 | // we will not have a state at the instance construction, so lets make sure 30 | // we emulate an empty object for both state and props 31 | const { html, editorState } = this.state || {} 32 | const { blockType } = this.props || {} 33 | let state = {} 34 | 35 | // we dont have the html nor the editorState 36 | if ( isEmpty(newHtml) && ! newEditorState ) { 37 | state = { 38 | html: '', 39 | editorState: isEmpty( blockType ) ? ( 40 | EditorState.createEmpty() 41 | ):( 42 | EditorState.createWithContent( 43 | ContentState.createFromBlockArray([ 44 | new ContentBlock({ 45 | type: blockType 46 | }) 47 | ]) 48 | ) 49 | ) 50 | } 51 | 52 | return updateState ? this.setState(state) : state 53 | } 54 | 55 | // we have a new html which is different from the state's html (editor state doesnt matter here) 56 | if ( ! isEmpty( newHtml ) && newHtml != html ) { 57 | state = { 58 | html: newHtml, 59 | editorState: EditorState.createWithContent( 60 | stateFromHTML(newHtml) 61 | ) 62 | } 63 | 64 | return updateState ? this.setState(state) : state 65 | } 66 | 67 | // we have an updated editorState, lets create the html version from it 68 | if ( newEditorState ) { 69 | state = { 70 | html: draftToHtml(convertToRaw(newEditorState.getCurrentContent())), 71 | editorState: newEditorState, 72 | } 73 | 74 | return updateState ? this.setState(state) : state 75 | } 76 | 77 | // do nothing 78 | return {} 79 | } 80 | 81 | // lets take advantage of the lifecycle events to manage the state directly (advanced) 82 | componentWillReceiveProps = ({ value }) => { 83 | this.state = { ...this.state, ...this.handleState( value, null, false) } 84 | } 85 | 86 | handlePastedText = (text, html) => { 87 | const linesFromText = text.split('\n'); 88 | 89 | const { editorState } = this.state; 90 | 91 | const currentBlockType = getSelectedBlocksType(editorState); 92 | 93 | const contentBlocksArray = linesFromText.map(line => { 94 | return new ContentBlock({ 95 | key: genKey(), 96 | type: currentBlockType, 97 | characterList: new List(Repeat(CharacterMetadata.create(), line.length)), 98 | text: line, 99 | }); 100 | }); 101 | 102 | const blockMap = ContentState.createFromBlockArray(contentBlocksArray).blockMap; 103 | 104 | const newState = Modifier.replaceWithFragment(editorState.getCurrentContent(), editorState.getSelection(), blockMap); 105 | 106 | return this.handleState( 107 | null, 108 | EditorState.push(editorState, newState, 'insert-fragment') 109 | ); 110 | } 111 | 112 | onEditorStateChange = async (editorState) => { 113 | this.handleState(null, editorState) 114 | const { html } = this.state 115 | 116 | const { onChange } = this.props 117 | if ( typeof onChange == 'function' ) { 118 | await onChange(html) 119 | } 120 | }; 121 | 122 | render() { 123 | const { editorState } = this.state; 124 | const { options: { wysiwygConfig }} = this.props; 125 | 126 | return ( 127 | 133 | ) 134 | } 135 | } 136 | 137 | export default WysiwygWidget; 138 | -------------------------------------------------------------------------------- /src/AppLib/ReadMany.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import qs from 'qs' 3 | import { gql, withApollo } from 'react-apollo'; 4 | import { Link } from 'react-router-dom'; 5 | import { get, startCase } from 'lodash' 6 | import pluralize from 'pluralize' 7 | import jsonSchemaToGqlQuery from '../GqlCmsConfigLib/jsonSchemaToGqlQuery'; 8 | import getCRUDSchemaFromResource from '../GqlCmsConfigLib/getCRUDSchemaFromResource' 9 | import ensureUniqKey from '../GqlCmsConfigLib/ensureUniqKey' 10 | import getReadManyInputQueryString from './ReadManyLib/getReadManyInputQueryString' 11 | import defaultCellFormatter from './ReadManyLib/defaultCellFormatter' 12 | import Tr from './ReadManyLib/Tr' 13 | import Paginate from './ReadManyLib/Paginate' 14 | import Search from './ReadManyLib/Search' 15 | import ThField from './ReadManyLib/ThField' 16 | import alertFirstGqlMsg from '../alertFirstGqlMsg' 17 | 18 | const DEFAULT_ITEMS_PER_PAGE = 15 19 | 20 | class ReadMany extends React.Component { 21 | constructor(props) { 22 | super(props) 23 | // NOTE: loading is not used in render, we may need it later on 24 | this.state = { 25 | loading: true, 26 | items: [], 27 | total: 0, 28 | } 29 | } 30 | componentDidMount() { 31 | this.fetchReadMany(this.props) 32 | } 33 | componentWillReceiveProps(nextProps) { 34 | this.fetchReadMany(nextProps) 35 | } 36 | changeUrl(paramsOverride) { 37 | const { history, location, queryParams } = this.props 38 | const { skip, limit, search, sort } = { ...queryParams, skip: 0, ...paramsOverride } 39 | const queryParmasString = qs.stringify({ 40 | skip, 41 | limit, 42 | search, 43 | sort, 44 | }) 45 | history.push([location.pathname, queryParmasString].join('?')) 46 | } 47 | fetchReadMany(props) { 48 | const { readManySchema, resource, client } = props 49 | const { uniqKey, crudMapping } = resource; 50 | const fieldsQuery = jsonSchemaToGqlQuery( 51 | ensureUniqKey(readManySchema.jsonSchema, uniqKey) 52 | ) 53 | 54 | const { limit, sort, search, skip } = props.queryParams 55 | 56 | const ReadManyInputQueryString = getReadManyInputQueryString( 57 | readManySchema, 58 | { skip, limit, sort, search } 59 | ) 60 | const ReadManyQuery = gql` 61 | query ${crudMapping.readMany} { 62 | ${crudMapping.readMany} ${ReadManyInputQueryString} { 63 | items ${fieldsQuery} 64 | total 65 | } 66 | } 67 | `; 68 | this.setState({ loading: true }) 69 | client 70 | .query({ query: ReadManyQuery, fetchPolicy: 'network-only' }) 71 | .then(({ data }) => { 72 | const { items, total } = data[resource.crudMapping.readMany] 73 | this.setState({ 74 | loading: false, 75 | items, 76 | total, 77 | }) 78 | }) 79 | .catch((e) => { 80 | alertFirstGqlMsg(e); 81 | 82 | throw e; 83 | }) 84 | } 85 | render() { 86 | const { 87 | items, 88 | total, 89 | } = this.state 90 | 91 | const { 92 | queryParams: { 93 | skip, 94 | limit, 95 | sort, 96 | search, 97 | }, 98 | client: { mutate }, 99 | resource, 100 | readManySchema, 101 | } = this.props 102 | 103 | const { properties } = readManySchema.jsonSchema 104 | const columnNames = Object.keys(properties) 105 | 106 | const thActionsElement = 107 | Actions 108 | 109 | const thFieldElements = columnNames.map((columnName) => { 110 | const { title } = properties[columnName] 111 | 112 | return { 118 | this.changeUrl({ sort: nextSort }) 119 | }} 120 | /> 121 | }) 122 | const thElements = thFieldElements.concat(thActionsElement) 123 | 124 | const cellFormatter = readManySchema.cellFormatter || defaultCellFormatter 125 | const trElements = items.map((row, index) => this.changeUrl(paramsOverride)} 132 | columnNames={columnNames} 133 | />) 134 | 135 | const resourceName = resource.displayName || resource.name 136 | 137 | return
138 |

139 | List of { startCase(pluralize(resourceName)) } 140 | { readManySchema.showTotal ? ` (total: ${total})` : null } 141 | { 142 | resource.crudMapping.create 143 | ? 148 | Add New 149 | 150 | : null 151 | } 152 |

153 | { 154 | readManySchema.searchStrategy 155 | ? this.changeUrl({search: nextSearch})} 158 | /> 159 | : null 160 | } 161 |
162 | 163 | 164 | 165 | {thElements} 166 | 167 | 168 | 169 | {trElements} 170 | 171 |
172 | { 173 | readManySchema.paginationStrategy 174 | ? this.changeUrl({ skip: nextSkip })} 179 | /> 180 | : null 181 | } 182 |
183 |
184 | } 185 | } 186 | 187 | const ReadManyWithApollo = withApollo(ReadMany); 188 | 189 | function ReadManyWithData(props) { 190 | const { 191 | config, 192 | resource, 193 | location, 194 | } = props; 195 | 196 | const readManySchema = getCRUDSchemaFromResource({ 197 | config, 198 | resource, 199 | crudType: 'readMany', 200 | }); 201 | 202 | const { search } = location; 203 | const limit = get( 204 | readManySchema, 205 | 'paginationStrategy.itemsPerPage', 206 | DEFAULT_ITEMS_PER_PAGE, 207 | ); 208 | const defaultQueryParams = { limit, skip: 0, sort: [], search: '' }; 209 | const overrideQueryParams = search ? qs.parse(search.substring(1)) : {}; 210 | const queryParams = { ...defaultQueryParams, ...overrideQueryParams }; 211 | 212 | return ( 213 | 218 | ); 219 | } 220 | 221 | export default ReadManyWithData 222 | -------------------------------------------------------------------------------- /src/stylesheets/react-toastify.css: -------------------------------------------------------------------------------- 1 | @keyframes toastify-bounceInRight { 2 | from, 60%, 75%, 90%, to { 3 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 4 | from { 5 | opacity: 0; 6 | transform: translate3d(3000px, 0, 0); } 7 | 60% { 8 | opacity: 1; 9 | transform: translate3d(-25px, 0, 0); } 10 | 75% { 11 | transform: translate3d(10px, 0, 0); } 12 | 90% { 13 | transform: translate3d(-5px, 0, 0); } 14 | to { 15 | transform: none; } } 16 | .toastify-bounceInRight, .toast-enter--top-right, .toast-enter--bottom-right { 17 | animation-name: toastify-bounceInRight; } 18 | 19 | @keyframes toastify-bounceOutRight { 20 | 20% { 21 | opacity: 1; 22 | transform: translate3d(-20px, 0, 0); } 23 | to { 24 | opacity: 0; 25 | transform: translate3d(2000px, 0, 0); } } 26 | .toastify-bounceOutRight, .toast-exit--top-right, .toast-exit--bottom-right { 27 | animation-name: toastify-bounceOutRight; } 28 | 29 | @keyframes toastify-bounceInLeft { 30 | from, 60%, 75%, 90%, to { 31 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 32 | 0% { 33 | opacity: 0; 34 | transform: translate3d(-3000px, 0, 0); } 35 | 60% { 36 | opacity: 1; 37 | transform: translate3d(25px, 0, 0); } 38 | 75% { 39 | transform: translate3d(-10px, 0, 0); } 40 | 90% { 41 | transform: translate3d(5px, 0, 0); } 42 | to { 43 | transform: none; } } 44 | .toastify-bounceInLeft, .toast-enter--top-left, .toast-enter--bottom-left { 45 | animation-name: toastify-bounceInLeft; } 46 | 47 | @keyframes toastify-bounceOutLeft { 48 | 20% { 49 | opacity: 1; 50 | transform: translate3d(20px, 0, 0); } 51 | to { 52 | opacity: 0; 53 | transform: translate3d(-2000px, 0, 0); } } 54 | .toastify-bounceOutLeft, .toast-exit--top-left, .toast-exit--bottom-left { 55 | animation-name: toastify-bounceOutLeft; } 56 | 57 | @keyframes toastify-bounceInUp { 58 | from, 60%, 75%, 90%, to { 59 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 60 | from { 61 | opacity: 0; 62 | transform: translate3d(0, 3000px, 0); } 63 | 60% { 64 | opacity: 1; 65 | transform: translate3d(0, -20px, 0); } 66 | 75% { 67 | transform: translate3d(0, 10px, 0); } 68 | 90% { 69 | transform: translate3d(0, -5px, 0); } 70 | to { 71 | transform: translate3d(0, 0, 0); } } 72 | .toastify-bounceInUp, .toast-enter--bottom-center { 73 | animation-name: toastify-bounceInUp; } 74 | 75 | @keyframes toastify-bounceOutUp { 76 | 20% { 77 | transform: translate3d(0, -10px, 0); } 78 | 40%, 45% { 79 | opacity: 1; 80 | transform: translate3d(0, 20px, 0); } 81 | to { 82 | opacity: 0; 83 | transform: translate3d(0, -2000px, 0); } } 84 | .toastify-bounceOutUp, .toast-exit--top-center { 85 | animation-name: toastify-bounceOutUp; } 86 | 87 | @keyframes toastify-bounceInDown { 88 | from, 60%, 75%, 90%, to { 89 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 90 | 0% { 91 | opacity: 0; 92 | transform: translate3d(0, -3000px, 0); } 93 | 60% { 94 | opacity: 1; 95 | transform: translate3d(0, 25px, 0); } 96 | 75% { 97 | transform: translate3d(0, -10px, 0); } 98 | 90% { 99 | transform: translate3d(0, 5px, 0); } 100 | to { 101 | transform: none; } } 102 | .toastify-bounceInDown, .toast-enter--top-center { 103 | animation-name: toastify-bounceInDown; } 104 | 105 | @keyframes toastify-bounceOutDown { 106 | 20% { 107 | transform: translate3d(0, 10px, 0); } 108 | 40%, 45% { 109 | opacity: 1; 110 | transform: translate3d(0, -20px, 0); } 111 | to { 112 | opacity: 0; 113 | transform: translate3d(0, 2000px, 0); } } 114 | .toastify-bounceOutDown, .toast-exit--bottom-center { 115 | animation-name: toastify-bounceOutDown; } 116 | 117 | .toastify-animated { 118 | animation-duration: 0.75s; 119 | animation-fill-mode: both; } 120 | 121 | .toastify { 122 | z-index: 999; 123 | position: fixed; 124 | padding: 4px; 125 | width: 320px; 126 | box-sizing: border-box; 127 | color: #fff; } 128 | .toastify--top-left { 129 | top: 1em; 130 | left: 1em; } 131 | .toastify--top-center { 132 | top: 1em; 133 | left: 50%; 134 | margin-left: -160px; } 135 | .toastify--top-right { 136 | top: 1em; 137 | right: 1em; } 138 | .toastify--bottom-left { 139 | bottom: 1em; 140 | left: 1em; } 141 | .toastify--bottom-center { 142 | bottom: 1em; 143 | left: 50%; 144 | margin-left: -160px; } 145 | .toastify--bottom-right { 146 | bottom: 1em; 147 | right: 1em; } 148 | 149 | @media only screen and (max-width: 480px) { 150 | .toastify { 151 | width: 100vw; 152 | padding: 0; } 153 | 154 | .toastify--top-left, .toastify--top-center, .toastify--top-right { 155 | left: 0; 156 | top: 0; 157 | margin: 0; } 158 | 159 | .toastify--bottom-left, .toastify--bottom-center, .toastify--bottom-right { 160 | left: 0; 161 | bottom: 0; 162 | margin: 0; } } 163 | .toastify__close { 164 | padding: 0; 165 | color: #fff; 166 | font-weight: bold; 167 | font-size: 14px; 168 | background: transparent; 169 | outline: none; 170 | border: none; 171 | cursor: pointer; 172 | opacity: .7; 173 | transition: .3s ease; 174 | align-self: flex-start; } 175 | .toastify__close:hover, .toastify__close:focus { 176 | opacity: 1; } 177 | .toastify-content--default .toastify__close { 178 | color: #000; 179 | opacity: 0.3; } 180 | .toastify-content--default .toastify__close:hover { 181 | opacity: 1; } 182 | 183 | .toastify-content { 184 | position: relative; 185 | min-height: 48px; 186 | margin-bottom: 1rem; 187 | padding: 8px; 188 | border-radius: 1px; 189 | box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 0.1), 0 2px 15px 0 rgba(0, 0, 0, 0.05); 190 | display: flex; 191 | justify-content: space-between; 192 | max-height: 800px; 193 | overflow: hidden; 194 | font-family: sans-serif; 195 | cursor: pointer; } 196 | .toastify-content--default { 197 | background: #fff; 198 | color: #aaa; } 199 | .toastify-content--info { 200 | background: #3498db; } 201 | .toastify-content--success { 202 | background: #07bc0c; } 203 | .toastify-content--warning { 204 | background: #f1c40f; } 205 | .toastify-content--error { 206 | background: #e74c3c; } 207 | 208 | .toastify__body { 209 | margin: auto 0; 210 | flex: 1; } 211 | 212 | @media only screen and (max-width: 480px) { 213 | .toastify-content { 214 | margin-bottom: 0; } } 215 | @keyframes track-progress { 216 | 0% { 217 | width: 100%; } 218 | 100% { 219 | width: 0; } } 220 | .toastify__progress { 221 | position: absolute; 222 | bottom: 0; 223 | left: 0; 224 | width: 0; 225 | height: 5px; 226 | z-index: 999; 227 | opacity: 0.7; 228 | animation: track-progress linear 1; 229 | background-color: rgba(255, 255, 255, 0.7); } 230 | .toastify__progress--default { 231 | background: linear-gradient(to right, #4cd964, #5ac8fa, #007aff, #34aadc, #5856d6, #ff2d55); } 232 | 233 | /*# sourceMappingURL=ReactToastify.css.map */ 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BYOB CMS 2 | byob-cms is a react component for client only CMS 3 | There are three basic concepts in byob-cms: 4 | - config 5 | "config" is an javascript object that contains all the info needed 6 | for byob-cms to build the cms. 7 | You should pass config into byob-cms component as a prop. 8 | 9 | - resource 10 | "resource" is a RESTful concept. 11 | You can do CRUD on the records in resources. 12 | You can think of a resource as a class and a record as an instance, 13 | or a resource as a model and a record as a document. 14 | 15 | - schema 16 | We use https://github.com/mozilla-services/react-jsonschema-form package 17 | to render forms in the cms. There for you can provide 18 | jsonSchema and uiSchema to define the structure of the form. 19 | We let you configure schemas for CRUD pages. "defaultSchema" is used 20 | when you do not provide a schema for that CRUD type. 21 | 22 | ## Usage 23 | You can import this package as a component and give it a config prop. 24 | ``` 25 | import React from 'react'; 26 | import ReactDOM from 'react-dom'; 27 | import CMS from 'byob-cms' 28 | 29 | ReactDOM.render( 30 | , 31 | document.getElementById('root') 32 | ); 33 | ``` 34 | 35 | ## Config example 36 | ``` 37 | const config = { 38 | graphqlUrl: 'http://localhost:4000', 39 | resources: [ 40 | { 41 | name: 'drop', 42 | uniqKey: '_id', 43 | crudMapping: { 44 | create: 'createDrop', 45 | readMany: 'drops', 46 | readOne: 'drop', 47 | update: 'updateDrop', 48 | // delete: 'deleteDrop', 49 | }, 50 | readManySchema: { 51 | jsonSchema: { 52 | type: 'object', 53 | properties: { 54 | _id: 'string', 55 | title: 'string', 56 | } 57 | } 58 | }, 59 | defaultSchema: { 60 | jsonSchema: { 61 | title: 'Drop', 62 | type: 'object', 63 | required: [ 64 | 'title', 65 | 'content', 66 | 'tagIds', 67 | ], 68 | properties: { 69 | title: { 70 | type: 'string', 71 | }, 72 | content: { 73 | type: 'string', 74 | }, 75 | lifeEventIds: { 76 | type: 'array', 77 | default: [], 78 | items: { 79 | type: 'string' 80 | } 81 | }, 82 | tagIds: { 83 | type: 'array', 84 | default: [], 85 | items: { 86 | type: 'string' 87 | } 88 | } 89 | }, 90 | }, 91 | uiSchema: { 92 | lifeEventIds: { 93 | 'ui:field': 'hasManyField' 94 | }, 95 | tagIds: { 96 | 'ui:field': 'hasManyField' 97 | }, 98 | }, 99 | } 100 | }, 101 | { 102 | name: 'tag', 103 | uniqKey: '_id', 104 | crudMapping: { 105 | readMany: 'tags', 106 | readOne: 'tag', 107 | create: 'createTag', 108 | update: 'updateTag', 109 | delete: 'deleteTag', 110 | }, 111 | readManySchema: { 112 | jsonSchema: { 113 | type: 'object', 114 | properties: { 115 | _id: 'string', 116 | title: 'string', 117 | } 118 | } 119 | }, 120 | defaultSchema: { 121 | jsonSchema: { 122 | title: 'Tag', 123 | type: 'object', 124 | required: [ 125 | 'title', 126 | 'tagQuestionId', 127 | ], 128 | properties: { 129 | title: { 130 | type: 'string' 131 | }, 132 | tagQuestionId: { 133 | type: 'string', 134 | }, 135 | } 136 | }, 137 | uiSchema: { 138 | tagQuestionId: { 139 | 'ui:widget': 'hasOneWidget', 140 | } 141 | }, 142 | }, 143 | }, 144 | { 145 | name: 'tagQuestion', 146 | uniqKey: '_id', 147 | crudMapping: { 148 | readMany: 'tagQuestions', 149 | readOne: 'tagQuestion', 150 | create: 'createTagQuestion', 151 | update: 'updateTagQuestion', 152 | delete: 'deleteTagQuestion', 153 | }, 154 | readManySchema: { 155 | jsonSchema: { 156 | type: 'object', 157 | properties: { 158 | _id: 'string', 159 | title: 'string', 160 | } 161 | } 162 | }, 163 | defaultSchema: { 164 | jsonSchema: { 165 | title: 'Tag Question', 166 | type: 'object', 167 | required: [ 168 | 'title', 169 | ], 170 | properties: { 171 | title: { 172 | type: 'string' 173 | }, 174 | } 175 | }, 176 | uiSchema: { }, 177 | }, 178 | }, 179 | ] 180 | } 181 | 182 | ``` 183 | 184 | ## config API reference 185 | - brand 186 | The text on the left of the navbar 187 | - graphqlUrl 188 | The backend graphql url used by byob-cms 189 | - resources 190 | - resources.[].name 191 | The name of the resource (needs to be singular). Used for GraphQL arguments and input types 192 | - resources.[].displayName 193 | The display name of the resource (should be singular). Used for titles and routes. If missing, defaults to resources.[].name 194 | - resources.[].uniqKey 195 | Unique key used in your records. Most of the time its 'id' or '\_id'. 196 | - resources.[].crudMapping 197 | GraphQL mutation and query names for crud options 198 | - resources.[].crudMapping.create 199 | ``` 200 | mutation ${crudMapping.create}($input: ${upperFirst(resource.name + 'Input')}!) { 201 | ${crudMapping.create}(${resource.name} : $input) 202 | } 203 | ``` 204 | - resources.[].crudMapping.update 205 | ``` 206 | mutation ${crudMapping.update}($input: ${upperFirst(resource.name + 'Input')}!) { 207 | ${crudMapping.update}(${resource.name} : $input, ${uniqKey}: "${uniqKeyValue}") 208 | } 209 | ``` 210 | - resources.[].crudMapping.delete 211 | ``` 212 | mutation ${crudMapping.delete}($${uniqKey}: String!) { 213 | ${crudMapping.delete}(${uniqKey}: $${uniqKey}) 214 | } 215 | ``` 216 | - resources.[].crudMapping.readOne 217 | ``` 218 | query ${crudMapping.readOne}($${uniqKey}: String!) { 219 | ${crudMapping.readOne}(${uniqKey}: $${uniqKey}) ${jsonSchemaToGqlQuery(readOneSchema.jsonSchema)} 220 | } 221 | ``` 222 | - resources.[].crudMapping.readMany 223 | ``` 224 | query ${crudMapping.readMany} { 225 | ${crudMapping.readMany} ${jsonSchemaToGqlQuery(readManySchema.jsonSchema)} 226 | } 227 | ``` 228 | - createSchema 229 | - createSchema.jsonSchema 230 | - createSchema.uiSchema 231 | - updateSchema 232 | - updateSchema.jsonSchema 233 | - updateSchema.uiSchema 234 | - readOneSchema 235 | - readOneSchema.jsonSchema 236 | - readOneSchema.uiSchema 237 | - readManySchema 238 | - readManySchema.jsonSchema 239 | - readManySchema.uiSchema 240 | - readManySchema.cellFormatter 241 | cellFormatter is a function used to format each td in the table 242 | function signature: (value, object, fieldName) -> ReactElement 243 | default value: 244 | ``` 245 | function defaultCellFormatter (value) { 246 | return
247 |       {JSON.stringify(value, null, 2)}
248 |     
249 | } 250 | ``` 251 | - defaultSchema 252 | - defaultSchema.jsonSchema 253 | - defaultSchema.uiSchema 254 | - jsonSchemaFormExtensions 255 | You can extend json schema form by providing this object 256 | - jsonSchemaFormExtensions.widgets 257 | https://github.com/mozilla-services/react-jsonschema-form#custom-component-registration 258 | - jsonSchemaFormExtensions.fields 259 | https://github.com/mozilla-services/react-jsonschema-form#custom-field-components 260 | - Login 261 | You can define your own custom Login component, setAccessToken function is 262 | passed in as a prop. After you call it, the following graphql requests will 263 | contain this value in the header as "authorization". 264 | You can also use built in components like this: 265 | ``` 266 | import { CodeLogin } from 'byob-cms'; 267 | ``` 268 | 269 | ## pre-defined jsonSchema widgets and fields 270 | - numberWidget, currencyWidget (both use [react-number-format](https://github.com/s-yadav/react-number-format)) 271 | - Formats/filters text input and evaluates field as number value 272 | - Passing/overriding props: 273 | ``` 274 | 'ui:options': { 275 | props: { 276 | decimalScale: 0, // Blocks floats 277 | allowNegative: false, 278 | }, 279 | }, 280 | ``` 281 | - hasOneWidget 282 | ``` 283 | // by default gqlOptionsName is `${fieldName}Options` 284 | query ${upperFirst(gqlOptionsName)}Query { 285 | ${gqlOptionsName} { 286 | value 287 | label 288 | } 289 | } 290 | ``` 291 | props: 292 | - ui:options 293 | - ui:options.gqlOptionsName 294 | - wysiwygWidget 295 | Wysiwyg Widget use [React Draft Wysiwyg](https://github.com/jpuri/react-draft-wysiwyg). 296 | props: 297 | - ui:options 298 | - wysiwygConfig: An array of props passing directly to Editor Component. You can find the full list of props here https://github.com/jpuri/react-draft-wysiwyg/blob/master/src/config/defaultToolbar.js 299 | - blockType: This is a special prop that does not use directly in the Editor component. Use this if you want to start your wysiwyg with a specific container. For example, specify `blockType: 'unordered-list-item'` will begin your wysiwyg with an unordered list element. 300 | - hasManyField 301 | Same as hasOneWidget, but for array of values 302 | -------------------------------------------------------------------------------- /src/stylesheets/react-selectize.css: -------------------------------------------------------------------------------- 1 | .react-selectize { 2 | color: #000; 3 | } 4 | .react-selectize.root-node { 5 | position: relative; 6 | width: 300px; 7 | } 8 | .react-selectize.root-node.disabled { 9 | pointer-events: none; 10 | } 11 | .react-selectize.root-node .react-selectize-control { 12 | cursor: pointer; 13 | display: flex; 14 | align-items: flex-start; 15 | position: relative; 16 | padding: 2px; 17 | } 18 | .react-selectize.root-node .react-selectize-control .react-selectize-placeholder { 19 | display: block; 20 | line-height: 30px; 21 | overflow: hidden; 22 | text-overflow: ellipsis; 23 | vertical-align: middle; 24 | white-space: nowrap; 25 | position: absolute; 26 | max-width: calc(100% - 56px); 27 | } 28 | .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values { 29 | display: flex; 30 | min-height: 30px; 31 | flex-grow: 1; 32 | flex-wrap: wrap; 33 | } 34 | .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input { 35 | background: none; 36 | border: none; 37 | outline: none; 38 | font-size: 1em; 39 | margin: 2px; 40 | padding: 4px 0px; 41 | vertical-align: middle; 42 | width: 0px; 43 | } 44 | .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper { 45 | display: flex; 46 | align-items: center; 47 | } 48 | .react-selectize.root-node .react-selectize-control .react-selectize-reset-button-container, 49 | .react-selectize.root-node .react-selectize-control .react-selectize-toggle-button-container { 50 | flex-grow: 0; 51 | flex-shrink: 0; 52 | cursor: pointer; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | height: 30px; 57 | } 58 | .react-selectize.root-node .react-selectize-control .react-selectize-reset-button-container { 59 | width: 16px; 60 | } 61 | .react-selectize.root-node .react-selectize-control .react-selectize-toggle-button-container { 62 | width: 32px; 63 | } 64 | .react-selectize.root-node .react-selectize-control .react-selectize-reset-button-container:hover .react-selectize-reset-button path { 65 | stroke: #c0392b; 66 | } 67 | .react-selectize.root-node .react-selectize-control .react-selectize-reset-button path { 68 | transition: stroke 0.5s 0s ease; 69 | stroke: #999; 70 | stroke-linecap: square; 71 | stroke-linejoin: mitter; 72 | } 73 | .react-selectize.root-node .react-selectize-control .react-selectize-toggle-button path { 74 | fill: #999; 75 | } 76 | .react-selectize.dropdown-menu-wrapper { 77 | position: absolute; 78 | } 79 | .react-selectize.dropdown-menu-wrapper.tethered { 80 | min-width: 300px; 81 | } 82 | .react-selectize.dropdown-menu-wrapper:not(.tethered) { 83 | width: 100%; 84 | } 85 | .react-selectize.dropdown-menu { 86 | box-sizing: border-box; 87 | overflow: auto; 88 | position: absolute; 89 | max-height: 200px; 90 | z-index: 10; 91 | } 92 | .react-selectize.dropdown-menu.tethered { 93 | min-width: 300px; 94 | } 95 | .react-selectize.dropdown-menu:not(.tethered) { 96 | width: 100%; 97 | } 98 | .react-selectize.dropdown-menu .groups.as-columns { 99 | display: flex; 100 | } 101 | .react-selectize.dropdown-menu .groups.as-columns > div { 102 | flex: 1; 103 | } 104 | .react-selectize.dropdown-menu .option-wrapper { 105 | cursor: pointer; 106 | outline: none; 107 | } 108 | .multi-select.react-selectize.root-node .simple-value { 109 | display: inline-block; 110 | margin: 2px; 111 | vertical-align: middle; 112 | } 113 | .multi-select.react-selectize.root-node .simple-value span { 114 | display: inline-block; 115 | padding: 2px 5px 4px; 116 | vertical-align: center; 117 | } 118 | .simple-select.react-selectize.root-node .simple-value { 119 | margin: 2px; 120 | } 121 | .simple-select.react-selectize.root-node .simple-value span { 122 | display: inline-block; 123 | vertical-align: center; 124 | } 125 | .react-selectize.default { 126 | font-family: Helvetica Neue, Helvetica, Arial, sans-serif; 127 | } 128 | .react-selectize.default.root-node .react-selectize-control { 129 | background-color: #fff; 130 | border: 1px solid; 131 | border-color: #d9d9d9 #ccc #b3b3b3; 132 | border-radius: 4px; 133 | font-size: 1em; 134 | } 135 | .react-selectize.default.root-node .react-selectize-control .react-selectize-placeholder { 136 | color: #aaa; 137 | text-indent: 8px; 138 | } 139 | .react-selectize.default.root-node .react-selectize-control .react-selectize-search-field-and-selected-values { 140 | padding-left: 5px; 141 | } 142 | .react-selectize.default.root-node.open.flipped .react-selectize-control { 143 | border-bottom-left-radius: 4px; 144 | border-bottom-right-radius: 4px; 145 | border-top-left-radius: 0px; 146 | border-top-right-radius: 0px; 147 | } 148 | .react-selectize.default.root-node.open:not(.flipped) .react-selectize-control { 149 | border-bottom-left-radius: 0px; 150 | border-bottom-right-radius: 0px; 151 | border-top-left-radius: 4px; 152 | border-top-right-radius: 4px; 153 | } 154 | .react-selectize.dropdown-menu-wrapper.default { 155 | overflow: hidden; 156 | } 157 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.custom-enter-active, 158 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.custom-leave-active { 159 | transition: transform 0.2s 0s ease; 160 | } 161 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.flipped.custom-enter { 162 | transform: translateY(100%); 163 | } 164 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.flipped.custom-enter-active { 165 | transform: translateY(0%); 166 | } 167 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.flipped.custom-leave { 168 | transform: translateY(0%); 169 | } 170 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu.flipped.custom-leave-active { 171 | transform: translateY(100%); 172 | } 173 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu:not(.flipped).custom-enter { 174 | transform: translateY(-100%); 175 | } 176 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu:not(.flipped).custom-enter-active { 177 | transform: translateY(0%); 178 | } 179 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu:not(.flipped).custom-leave { 180 | transform: translateY(0%); 181 | } 182 | .react-selectize.dropdown-menu-wrapper.default .dropdown-menu:not(.flipped).custom-leave-active { 183 | transform: translateY(-100%); 184 | } 185 | .react-selectize.dropdown-menu.default { 186 | background: #fff; 187 | border: 1px solid #ccc; 188 | margin-top: -1px; 189 | } 190 | .react-selectize.dropdown-menu.default.flipped { 191 | border-top-left-radius: 4px; 192 | border-top-right-radius: 4px; 193 | } 194 | .react-selectize.dropdown-menu.default:not(.flipped) { 195 | border-color: #b3b3b3 #ccc #d9d9d9; 196 | border-bottom-left-radius: 4px; 197 | border-bottom-right-radius: 4px; 198 | } 199 | .react-selectize.dropdown-menu.default .no-results-found { 200 | color: #aaa !important; 201 | font-style: oblique; 202 | padding: 8px 10px; 203 | } 204 | .react-selectize.dropdown-menu.default .simple-group-title { 205 | background-color: #fafafa; 206 | padding: 8px 8px; 207 | } 208 | .react-selectize.dropdown-menu.default .option-wrapper.highlight { 209 | background: #f2f9fc; 210 | color: #333; 211 | } 212 | .react-selectize.dropdown-menu.default .option-wrapper .simple-option { 213 | color: #666; 214 | cursor: pointer; 215 | padding: 8px 10px; 216 | } 217 | .react-selectize.dropdown-menu.default .option-wrapper .simple-option.not-selectable { 218 | background-color: #f8f8f8; 219 | color: #999; 220 | cursor: default; 221 | font-style: oblique; 222 | text-shadow: 0px 1px 0px #fff; 223 | } 224 | .multi-select.react-selectize.default.root-node .simple-value { 225 | background: #f2f9fc; 226 | border: 1px solid #c9e6f2; 227 | border-radius: 2px; 228 | color: #08c; 229 | } 230 | .simple-select.react-selectize.default.root-node.open .react-selectize-control { 231 | background-color: #fff; 232 | } 233 | .simple-select.react-selectize.default.root-node:not(.open) .react-selectize-control { 234 | background-color: #f9f9f9; 235 | background-image: linear-gradient(to bottom, #fefefe, #f2f2f2); 236 | } 237 | .react-selectize.bootstrap3 { 238 | font-family: Helvetica Neue, Helvetica, Arial, sans-serif; 239 | } 240 | .react-selectize.bootstrap3.root-node.open .react-selectize-control { 241 | background-color: #fff; 242 | border: 1px solid #66afe9; 243 | box-shadow: inset 0 1px 1px rgba(0,0,0,0.075), 0 0 8px rgba(102,175,233,0.6); 244 | } 245 | .react-selectize.bootstrap3.root-node .react-selectize-control { 246 | border: 1px solid; 247 | border-color: #d9d9d9 #ccc #b3b3b3; 248 | border-radius: 4px; 249 | font-size: 1em; 250 | } 251 | .react-selectize.bootstrap3.root-node .react-selectize-control .react-selectize-placeholder { 252 | color: #aaa; 253 | text-indent: 8px; 254 | } 255 | .react-selectize.bootstrap3.root-node .react-selectize-control .react-selectize-search-field-and-selected-values { 256 | padding-left: 5px; 257 | } 258 | .react-selectize.bootstrap3.dropdown-menu-wrapper.flipped { 259 | margin-bottom: 5px; 260 | } 261 | .react-selectize.bootstrap3.dropdown-menu-wrapper:not(.flipped) { 262 | margin-top: 5px; 263 | } 264 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-enter-active, 265 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-leave-active { 266 | transition: opacity 0.2s 0s ease; 267 | } 268 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-enter { 269 | opacity: 0; 270 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; 271 | filter: alpha(opacity=0); 272 | } 273 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-enter-active { 274 | opacity: 1; 275 | -ms-filter: none; 276 | filter: none; 277 | } 278 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-leave { 279 | opacity: 1; 280 | -ms-filter: none; 281 | filter: none; 282 | } 283 | .react-selectize.bootstrap3.dropdown-menu-wrapper .dropdown-menu.custom-leave-active { 284 | opacity: 0; 285 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; 286 | filter: alpha(opacity=0); 287 | } 288 | .react-selectize.bootstrap3.dropdown-menu { 289 | background: #fff; 290 | border: 1px solid #ccc; 291 | border-radius: 4px; 292 | box-shadow: 0 6px 12px rgba(0,0,0,0.175); 293 | } 294 | .react-selectize.bootstrap3.dropdown-menu.flipped { 295 | margin-bottom: 5px; 296 | } 297 | .react-selectize.bootstrap3.dropdown-menu:not(.flipped) { 298 | margin-top: 5px; 299 | } 300 | .react-selectize.bootstrap3.dropdown-menu .no-results-found { 301 | color: #aaa !important; 302 | font-style: oblique; 303 | padding: 8px 10px; 304 | } 305 | .react-selectize.bootstrap3.dropdown-menu .groups:not(.as-columns) > div:not(:first-child) { 306 | border-top: 1px solid #e5e5e5; 307 | margin: 12px 0px 0px 0px; 308 | } 309 | .react-selectize.bootstrap3.dropdown-menu .simple-group-title { 310 | background-color: #fff; 311 | color: #999; 312 | padding: 8px 8px; 313 | text-shadow: 0px 1px 0px rgba(0,0,0,0.05); 314 | } 315 | .react-selectize.bootstrap3.dropdown-menu .option-wrapper.highlight { 316 | background: #428bca; 317 | } 318 | .react-selectize.bootstrap3.dropdown-menu .option-wrapper.highlight .simple-option { 319 | color: #fff; 320 | } 321 | .react-selectize.bootstrap3.dropdown-menu .option-wrapper .simple-option { 322 | color: #333; 323 | cursor: pointer; 324 | padding: 8px 10px; 325 | } 326 | .react-selectize.bootstrap3.dropdown-menu .option-wrapper .simple-option.not-selectable { 327 | background-color: #f8f8f8; 328 | color: #999; 329 | cursor: default; 330 | font-style: oblique; 331 | text-shadow: 0px 1px 0px #fff; 332 | } 333 | .multi-select.react-selectize.bootstrap3.root-node .simple-value { 334 | background: #efefef; 335 | border-radius: 4px; 336 | color: #333; 337 | } 338 | .react-selectize.material { 339 | font-family: Roboto, sans-serif; 340 | } 341 | .react-selectize.material.root-node.open .react-selectize-control:after { 342 | transform: scaleX(1); 343 | } 344 | .react-selectize.material.root-node .react-selectize-control { 345 | border-bottom: 1px solid rgba(0,0,0,0.3); 346 | } 347 | .react-selectize.material.root-node .react-selectize-control:after { 348 | background-color: #00bcd4; 349 | content: ""; 350 | transform: scaleX(0); 351 | transition: transform 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; 352 | position: absolute; 353 | left: 0px; 354 | bottom: -1px; 355 | width: 100%; 356 | height: 2px; 357 | } 358 | .react-selectize.material.root-node .react-selectize-control .react-selectize-placeholder { 359 | color: rgba(0,0,0,0.3); 360 | text-indent: 4px; 361 | } 362 | .react-selectize.material.dropdown-menu-wrapper.flipped { 363 | margin-bottom: 8px; 364 | } 365 | .react-selectize.material.dropdown-menu-wrapper.flipped .dropdown-menu { 366 | transform-origin: 100% 100%; 367 | } 368 | .react-selectize.material.dropdown-menu-wrapper:not(.flipped) { 369 | margin-top: 8px; 370 | } 371 | .react-selectize.material.dropdown-menu-wrapper:not(.flipped) .dropdown-menu { 372 | transform-origin: 0% 0%; 373 | } 374 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-enter-active, 375 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-leave-active { 376 | transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1) 0ms, opacity 250ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; 377 | } 378 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-enter { 379 | opacity: 0; 380 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; 381 | filter: alpha(opacity=0); 382 | transform: scale(0, 0); 383 | } 384 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-enter-active { 385 | opacity: 1; 386 | -ms-filter: none; 387 | filter: none; 388 | transform: scale(1, 1); 389 | } 390 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-leave { 391 | opacity: 1; 392 | -ms-filter: none; 393 | filter: none; 394 | transform: scale(1, 1); 395 | } 396 | .react-selectize.material.dropdown-menu-wrapper .dropdown-menu.custom-leave-active { 397 | opacity: 0; 398 | -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; 399 | filter: alpha(opacity=0); 400 | } 401 | .react-selectize.material.dropdown-menu { 402 | background-color: #fff; 403 | border-radius: 2px; 404 | box-shadow: rgba(0,0,0,0.118) 0px 1px 6px, rgba(0,0,0,0.118) 0px 1px 4px; 405 | max-height: 250px; 406 | padding: 8px 0px; 407 | } 408 | .react-selectize.material.dropdown-menu.flipped { 409 | margin-bottom: 8px; 410 | } 411 | .react-selectize.material.dropdown-menu:not(.flipped) { 412 | margin-top: 8px; 413 | } 414 | .react-selectize.material.dropdown-menu .no-results-found { 415 | font-style: oblique; 416 | font-size: 16px; 417 | height: 32px; 418 | padding: 0px 16px; 419 | display: flex; 420 | align-items: center; 421 | } 422 | .react-selectize.material.dropdown-menu .groups:not(.as-columns) > div:not(:last-child) { 423 | border-bottom: 1px solid #e5e5e5; 424 | } 425 | .react-selectize.material.dropdown-menu .simple-group-title { 426 | color: #8f8f8f; 427 | display: flex; 428 | align-items: center; 429 | font-size: 14px; 430 | height: 48px; 431 | padding: 0px 10px; 432 | } 433 | .react-selectize.material.dropdown-menu .option-wrapper.highlight { 434 | background-color: rgba(0,0,0,0.098); 435 | } 436 | .react-selectize.material.dropdown-menu .option-wrapper .simple-option { 437 | color: rgba(0,0,0,0.875); 438 | cursor: pointer; 439 | display: flex; 440 | flex-direction: column; 441 | align-items: flex-start; 442 | justify-content: center; 443 | font-size: 16px; 444 | height: 48px; 445 | padding: 0px 16px; 446 | } 447 | .react-selectize.material.dropdown-menu .option-wrapper .simple-option.not-selectable { 448 | background-color: #f8f8f8; 449 | color: #999; 450 | cursor: default; 451 | font-style: oblique; 452 | text-shadow: 0px 1px 0px #fff; 453 | } 454 | .multi-select.react-selectize.material.root-node .simple-value span { 455 | padding: 0px; 456 | } 457 | .multi-select.react-selectize.material.root-node .simple-value span:after { 458 | content: ","; 459 | } 460 | .simple-select.react-selectize.material.root-node .simple-value { 461 | margin: 4px 3px 3px 2px; 462 | } 463 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import './stylesheets/react-selectize.css'; 5 | import './stylesheets/react-jsonschemaform.css'; 6 | import './stylesheets/bootstrap-override.css'; 7 | import './stylesheets/react-toastify.css'; 8 | import Cms, { EmailPasswordLogin, StateHOF } from './App'; 9 | import { connect } from 'react-redux'; 10 | import jwtDecode from 'jwt-decode'; 11 | import { ToastContainer } from 'react-toastify' 12 | 13 | const graphqlUrl = 'http://localhost:4000/admin-graphql' 14 | 15 | const AdminGqlCmsConfig = { 16 | brand: 'Encouragement CMS', 17 | title: 'Encouragement CMS', 18 | graphqlUrl, 19 | jsonSchemaFormExtensions: { 20 | // widgets: { 21 | // imageWidget: ImageWidget, 22 | // }, 23 | // fields: { 24 | // sortableField: SortableField, 25 | // }, 26 | }, 27 | readManySchema: { 28 | cellFormatter(value, object, fieldName) { 29 | if (value && typeof value === 'object') { 30 | return
{JSON.stringify(value)}
31 | } 32 | return value 33 | }, 34 | sortStrategy: { 35 | type: 'SINGLE', 36 | defaultSortField: 'title', 37 | }, 38 | searchStrategy: { 39 | type: 'FULLTEXT' 40 | }, 41 | paginationStrategy: { 42 | type: 'STATIC', 43 | }, 44 | }, 45 | resources: [ 46 | { 47 | // NOTE: name is singular 48 | name: 'user', 49 | uniqKey: '_id', 50 | crudMapping: { 51 | readMany: 'users', 52 | readOne: 'user', 53 | }, 54 | readManySchema: { 55 | jsonSchema: { 56 | type: 'object', 57 | properties: { 58 | fullName: { 59 | type: 'string' 60 | }, 61 | email: { 62 | type: 'number' 63 | }, 64 | } 65 | } 66 | }, 67 | defaultSchema: { 68 | }, 69 | }, 70 | { 71 | // NOTE: name is singular 72 | name: 'lifeEventCategory', 73 | uniqKey: '_id', 74 | crudMapping: { 75 | create: 'createLifeEventCategory', 76 | readMany: 'lifeEventCategories', 77 | readOne: 'lifeEventCategory', 78 | update: 'updateLifeEventCategory', 79 | // delete: 'deleteLifeEventCategory', 80 | }, 81 | readManySchema: { 82 | jsonSchema: { 83 | type: 'object', 84 | properties: { 85 | title: { 86 | type: 'string' 87 | }, 88 | sortIndex: { 89 | type: 'number' 90 | }, 91 | } 92 | } 93 | }, 94 | defaultSchema: { 95 | jsonSchema: { 96 | title: 'Life Event Category', 97 | type: 'object', 98 | required: [ 99 | 'title', 100 | ], 101 | properties: { 102 | title: { 103 | type: 'string' 104 | }, 105 | sortIndex: { 106 | type: 'number', 107 | default: 1000, 108 | }, 109 | lifeEventIds: { 110 | title: 'Sort Associated Life Events', 111 | type: 'array', 112 | default: [], 113 | items: { 114 | type: 'string' 115 | } 116 | } 117 | } 118 | }, 119 | uiSchema: { 120 | lifeEventIds: { 121 | 'ui:field': 'sortableField', 122 | } 123 | }, 124 | }, 125 | }, 126 | { 127 | // NOTE: name is singular 128 | name: 'lifeEvent', 129 | uniqKey: '_id', 130 | crudMapping: { 131 | create: 'createLifeEvent', 132 | readMany: 'lifeEvents', 133 | readOne: 'lifeEvent', 134 | update: 'updateLifeEvent', 135 | // delete: 'deleteLifeEvent', 136 | }, 137 | readManySchema: { 138 | // NOTE for now this is a hack since we can only render 139 | // one field for this object 140 | cellFormatter(value, object, fieldName) { 141 | if (fieldName === 'published') { 142 | return value ? 'Yes' : 'No' 143 | } 144 | if (value && typeof value === 'object') { 145 | return value.title 146 | } 147 | return value 148 | }, 149 | jsonSchema: { 150 | type: 'object', 151 | properties: { 152 | title: { 153 | type: 'string' 154 | }, 155 | lifeEventCategory: { 156 | type: 'object', 157 | properties: { 158 | title: { 159 | type: 'string', 160 | } 161 | } 162 | }, 163 | published: { 164 | type: 'boolean', 165 | } 166 | } 167 | } 168 | }, 169 | defaultSchema: { 170 | jsonSchema: { 171 | title: 'Life Event', 172 | type: 'object', 173 | required: [ 174 | 'title', 175 | 'subTitle', 176 | 'imageId', 177 | 'published', 178 | 'lifeEventCategoryId', 179 | 'dropIds' 180 | ], 181 | properties: { 182 | title: { 183 | type: 'string' 184 | }, 185 | slug: { 186 | type: 'string', 187 | title: 'Url Slug', 188 | }, 189 | published: { 190 | type: 'boolean', 191 | default: false 192 | }, 193 | subTitle: { 194 | type: 'string' 195 | }, 196 | imageId: { 197 | type: 'string' 198 | }, 199 | durationDescription: { 200 | type: 'string' 201 | }, 202 | sponsoredBy: { 203 | type: 'string' 204 | }, 205 | lifeEventContentItems: { 206 | type: 'array', 207 | items: { 208 | type: 'object', 209 | properties: { 210 | itemTitle: { 211 | type: 'string' 212 | }, 213 | itemContent: { 214 | type: 'string' 215 | } 216 | } 217 | }, 218 | default: [ 219 | { 220 | 'itemTitle': 'He or She Might Be Thinking About...', 221 | }, { 222 | 'itemTitle': 'Words That Might Be Encouraging', 223 | }, { 224 | 'itemTitle': 'Words That Might Be Discouraging', 225 | }], 226 | }, 227 | lifeEventCategoryId: { 228 | type: 'string', 229 | }, 230 | dropIds: { 231 | type: 'array', 232 | default: [], 233 | items: { 234 | type: 'string' 235 | } 236 | }, 237 | } 238 | }, 239 | uiSchema: { 240 | // imageId: { 241 | // 'ui:widget': 'imageWidget', 242 | // }, 243 | lifeEventContentItems: { 244 | items: { 245 | itemContent: { 246 | 'ui:widget': 'wysiwygWidget', 247 | 'ui:options': { 248 | wysiwygConfig: { 249 | toolbar: { 250 | options: ['link', 'list'], 251 | link: { 252 | showOpenOptionOnHover: false, 253 | }, 254 | list: { 255 | options: ['unordered'], 256 | }, 257 | }, 258 | editorClassName: 'form-control', 259 | }, 260 | blockType: 'unordered-list-item' 261 | } 262 | } 263 | }, 264 | }, 265 | lifeEventCategoryId: { 266 | 'ui:widget': 'hasOneWidget', 267 | }, 268 | dropIds: { 269 | 'ui:field': 'hasManyField' 270 | } 271 | }, 272 | }, 273 | }, 274 | { 275 | name: 'drop', 276 | uniqKey: '_id', 277 | crudMapping: { 278 | create: 'createDrop', 279 | readMany: 'drops', 280 | readOne: 'drop', 281 | update: 'updateDrop', 282 | // delete: 'deleteDrop', 283 | }, 284 | readManySchema: { 285 | jsonSchema: { 286 | type: 'object', 287 | properties: { 288 | title: { 289 | type: 'string' 290 | }, 291 | } 292 | } 293 | }, 294 | defaultSchema: { 295 | jsonSchema: { 296 | title: 'Drop', 297 | type: 'object', 298 | required: [ 299 | 'title', 300 | 'content', 301 | 'type', 302 | 'lifeEventIds', 303 | 'tagIds', 304 | ], 305 | properties: { 306 | title: { 307 | type: 'string', 308 | }, 309 | content: { 310 | type: 'string', 311 | }, 312 | type: { 313 | type: 'string', 314 | enum: [ 315 | 'DROP_TYPE_AUDI', 316 | 'DROP_TYPE_EXPE', 317 | 'DROP_TYPE_GIFT', 318 | 'DROP_TYPE_WORD', 319 | ], 320 | enumNames: [ 321 | 'audio / video', 322 | 'experience', 323 | 'gift', 324 | 'word', 325 | ], 326 | }, 327 | url: { 328 | type: 'string', 329 | }, 330 | lifeEventIds: { 331 | type: 'array', 332 | default: [], 333 | items: { 334 | type: 'string' 335 | } 336 | }, 337 | tagIds: { 338 | type: 'array', 339 | default: [], 340 | items: { 341 | type: 'string' 342 | } 343 | } 344 | }, 345 | }, 346 | uiSchema: { 347 | lifeEventIds: { 348 | 'ui:field': 'hasManyField' 349 | }, 350 | tagIds: { 351 | 'ui:field': 'hasManyField' 352 | }, 353 | }, 354 | } 355 | }, 356 | { 357 | name: 'customDrop', 358 | uniqKey: '_id', 359 | crudMapping: { 360 | // create: 'createDrop', 361 | readMany: 'customDrops', 362 | readOne: 'customDrop', 363 | // update: 'updateDrop', 364 | // delete: 'deleteDrop', 365 | }, 366 | readOneSchema: { 367 | show: true 368 | }, 369 | readManySchema: { 370 | jsonSchema: { 371 | type: 'object', 372 | properties: { 373 | title: { 374 | type: 'string' 375 | }, 376 | content: { 377 | type: 'string', 378 | }, 379 | } 380 | } 381 | }, 382 | defaultSchema: { 383 | jsonSchema: { 384 | title: 'Custom Drop', 385 | type: 'object', 386 | required: [ 387 | 'title', 388 | 'content', 389 | ], 390 | properties: { 391 | title: { 392 | type: 'string', 393 | }, 394 | content: { 395 | type: 'string', 396 | }, 397 | }, 398 | }, 399 | } 400 | }, 401 | { 402 | name: 'tag', 403 | uniqKey: '_id', 404 | crudMapping: { 405 | readMany: 'tags', 406 | readOne: 'tag', 407 | create: 'createTag', 408 | update: 'updateTag', 409 | // delete: 'deleteTag', 410 | }, 411 | readManySchema: { 412 | jsonSchema: { 413 | type: 'object', 414 | properties: { 415 | title: { 416 | type: 'string' 417 | }, 418 | } 419 | } 420 | }, 421 | defaultSchema: { 422 | jsonSchema: { 423 | title: 'Tag', 424 | type: 'object', 425 | required: [ 426 | 'title', 427 | ], 428 | properties: { 429 | title: { 430 | type: 'string' 431 | }, 432 | tagQuestionId: { 433 | type: 'string', 434 | }, 435 | } 436 | }, 437 | uiSchema: { 438 | tagQuestionId: { 439 | 'ui:widget': 'hasOneWidget', 440 | } 441 | }, 442 | }, 443 | }, 444 | { 445 | name: 'tagQuestion', 446 | uniqKey: '_id', 447 | crudMapping: { 448 | readMany: 'tagQuestions', 449 | readOne: 'tagQuestion', 450 | create: 'createTagQuestion', 451 | update: 'updateTagQuestion', 452 | // delete: 'deleteTagQuestion', 453 | }, 454 | readManySchema: { 455 | jsonSchema: { 456 | type: 'object', 457 | properties: { 458 | title: { 459 | type: 'string' 460 | }, 461 | } 462 | } 463 | }, 464 | defaultSchema: { 465 | jsonSchema: { 466 | title: 'Tag Question', 467 | type: 'object', 468 | required: [ 469 | 'title', 470 | 'question', 471 | ], 472 | properties: { 473 | title: { 474 | type: 'string' 475 | }, 476 | question: { 477 | type: 'string' 478 | }, 479 | tagIds: { 480 | type: 'array', 481 | items: { 482 | type: 'string', 483 | } 484 | }, 485 | } 486 | }, 487 | uiSchema: { 488 | tagIds: { 489 | 'ui:field': 'hasManyField', 490 | } 491 | }, 492 | }, 493 | }, 494 | { 495 | name: 'hallmarkHoliday', 496 | uniqKey: '_id', 497 | crudMapping: { 498 | readMany: 'hallmarkHolidays', 499 | readOne: 'hallmarkHoliday', 500 | create: 'createHallmarkHoliday', 501 | update: 'updateHallmarkHoliday', 502 | // delete: 'deleteHallmarkHoliday', 503 | }, 504 | readManySchema: { 505 | jsonSchema: { 506 | type: 'object', 507 | properties: { 508 | title: { 509 | type: 'string' 510 | }, 511 | } 512 | } 513 | }, 514 | defaultSchema: { 515 | jsonSchema: { 516 | title: 'Tag Question', 517 | type: 'object', 518 | required: [ 519 | 'title', 520 | 'type', 521 | ], 522 | properties: { 523 | title: { 524 | type: 'string' 525 | }, 526 | content: { 527 | type: 'string' 528 | }, 529 | // type: { 530 | // type: 'string', 531 | // enum: Object.keys(HALLMARK_HOLIDAY_TYPE_MAP), 532 | // enumNames: Object.values(HALLMARK_HOLIDAY_TYPE_MAP), 533 | // }, 534 | // monthAndDay: { 535 | // type: 'object', 536 | // properties: { 537 | // month: { 538 | // type: 'number', 539 | // enum: Object.keys(MONTHS_MAP).map(Number), 540 | // enumNames: Object.values(MONTHS_MAP) 541 | // }, 542 | // dayOfMonth: { 543 | // type: 'number', 544 | // enum: range(1, 32), 545 | // } 546 | // } 547 | // }, 548 | // monthOccurrenceAndDayOfWeek: { 549 | // type: 'object', 550 | // properties: { 551 | // month: { 552 | // type: 'number', 553 | // enum: Object.keys(MONTHS_MAP).map(Number), 554 | // enumNames: Object.values(MONTHS_MAP) 555 | // }, 556 | // occurrence: { 557 | // title: '', 558 | // type: 'number', 559 | // enum: [ 560 | // 0,1,2,3,4,5 561 | // ], 562 | // enumNames: [ 563 | // '1st', 564 | // '2nd', 565 | // '3rd', 566 | // '4th', 567 | // '5th', 568 | // '6th', 569 | // ] 570 | // }, 571 | // // dayOfWeek: { 572 | // // type: 'number', 573 | // // enum: Object.keys(DAYS_OF_WEEK_MAP).map(Number), 574 | // // enumNames: Object.values(DAYS_OF_WEEK_MAP), 575 | // // }, 576 | // }, 577 | // }, 578 | }, 579 | }, 580 | }, 581 | }, 582 | ] 583 | } 584 | 585 | const ContentEditorGqlCmsConfig = { 586 | brand: 'Encouragement CMS', 587 | title: 'Encouragement CMS', 588 | Login: EmailPasswordLogin, 589 | initialPath: 'lifeEvent', 590 | graphqlUrl, 591 | readManySchema: { 592 | cellFormatter(value, object, fieldName) { 593 | if (value && typeof value === 'object') { 594 | return
{JSON.stringify(value)}
595 | } 596 | return value 597 | }, 598 | sortStrategy: { 599 | type: 'SINGLE', 600 | defaultSortField: 'title', 601 | }, 602 | searchStrategy: { 603 | type: 'FULLTEXT' 604 | }, 605 | paginationStrategy: { 606 | type: 'STATIC', 607 | }, 608 | }, 609 | resources: [ 610 | { 611 | // NOTE: name is singular 612 | name: 'lifeEvent', 613 | uniqKey: '_id', 614 | crudMapping: { 615 | create: 'createLifeEvent', 616 | readMany: 'lifeEvents', 617 | readOne: 'lifeEvent', 618 | update: 'updateLifeEvent', 619 | // delete: 'deleteLifeEvent', 620 | }, 621 | readManySchema: { 622 | jsonSchema: { 623 | type: 'object', 624 | properties: { 625 | title: { 626 | type: 'string' 627 | }, 628 | } 629 | } 630 | }, 631 | defaultSchema: { 632 | jsonSchema: { 633 | title: 'Life Event', 634 | type: 'object', 635 | required: [ 636 | 'title', 637 | 'subTitle', 638 | 'imageUrl', 639 | 'durationDescription', 640 | 'lifeEventCategoryId', 641 | 'dropIds' 642 | ], 643 | properties: { 644 | title: { 645 | type: 'string' 646 | }, 647 | subTitle: { 648 | type: 'string' 649 | }, 650 | imageUrl: { 651 | type: 'string' 652 | }, 653 | durationDescription: { 654 | type: 'string' 655 | }, 656 | lifeEventCategoryId: { 657 | type: 'string', 658 | }, 659 | dropIds: { 660 | type: 'array', 661 | default: [], 662 | items: { 663 | type: 'string' 664 | } 665 | }, 666 | } 667 | }, 668 | uiSchema: { 669 | lifeEventCategoryId: { 670 | 'ui:widget': 'hasOneWidget', 671 | }, 672 | dropIds: { 673 | 'ui:field': 'hasManyField' 674 | } 675 | }, 676 | }, 677 | }, 678 | ] 679 | } 680 | 681 | function RoleRouter (props) { 682 | const { accessToken } = props 683 | if (!accessToken) { 684 | return
685 | 694 | 695 |
696 | } 697 | const { role } = jwtDecode(accessToken) 698 | switch (role) { 699 | case 'ADMIN': 700 | return 701 | case 'CONTENT_EDITOR': 702 | return 703 | default: 704 | return null 705 | } 706 | } 707 | 708 | const RoleRouterWithState = StateHOF(connect( 709 | (state) => ({ accessToken: state.accessToken }), 710 | {} 711 | )(RoleRouter)) 712 | 713 | ReactDOM.render(, document.getElementById('root')); 714 | --------------------------------------------------------------------------------