diff --git a/packages/strapi-admin/admin/src/containers/AdminPage/index.js b/packages/strapi-admin/admin/src/containers/AdminPage/index.js index 44130a8d7d..4de89b1f08 100644 --- a/packages/strapi-admin/admin/src/containers/AdminPage/index.js +++ b/packages/strapi-admin/admin/src/containers/AdminPage/index.js @@ -20,7 +20,7 @@ import { bindActionCreators, compose } from 'redux'; // Actions required for disabling and enabling the OverlayBlocker import { disableGlobalOverlayBlocker, - enableGlobalOverlayBlocker + enableGlobalOverlayBlocker, } from 'actions/overlayBlocker'; import { pluginLoaded, updatePlugin } from 'containers/App/actions'; import { @@ -30,7 +30,7 @@ import { makeSelectIsAppLoading, makeSelectShowGlobalAppBlocker, selectHasUserPlugin, - selectPlugins + selectPlugins, } from 'containers/App/selectors'; // Design import ComingSoonPage from 'containers/ComingSoonPage'; @@ -60,7 +60,7 @@ import styles from './styles.scss'; const PLUGINS_TO_BLOCK_PRODUCTION = [ 'content-type-builder', - 'settings-manager' + 'settings-manager', ]; export class AdminPage extends React.Component { @@ -71,7 +71,7 @@ export class AdminPage extends React.Component { disableGlobalOverlayBlocker: this.props.disableGlobalOverlayBlocker, enableGlobalOverlayBlocker: this.props.enableGlobalOverlayBlocker, plugins: this.props.plugins, - updatePlugin: this.props.updatePlugin + updatePlugin: this.props.updatePlugin, }); componentDidMount() { @@ -84,7 +84,7 @@ export class AdminPage extends React.Component { const { adminPage: { uuid }, location: { pathname }, - plugins + plugins, } = this.props; if (prevProps.location.pathname !== pathname) { @@ -206,7 +206,7 @@ export class AdminPage extends React.Component { showLoading = () => { const { isAppLoading, - adminPage: { isLoading } + adminPage: { isLoading }, } = this.props; return ( @@ -219,7 +219,7 @@ export class AdminPage extends React.Component { retrievePlugins = () => { const { adminPage: { currentEnvironment }, - plugins + plugins, } = this.props; if (currentEnvironment === 'production') { @@ -290,11 +290,11 @@ AdminPage.childContextTypes = { disableGlobalOverlayBlocker: PropTypes.func, enableGlobalOverlayBlocker: PropTypes.func, plugins: PropTypes.object, - updatePlugin: PropTypes.func + updatePlugin: PropTypes.func, }; AdminPage.contextTypes = { - router: PropTypes.object.isRequired + router: PropTypes.object.isRequired, }; AdminPage.defaultProps = { @@ -302,7 +302,7 @@ AdminPage.defaultProps = { appPlugins: [], hasUserPlugin: true, isAppLoading: true, - overlayBlockerData: {} + overlayBlockerData: {}, }; AdminPage.propTypes = { @@ -320,7 +320,7 @@ AdminPage.propTypes = { pluginLoaded: PropTypes.func.isRequired, plugins: PropTypes.object.isRequired, showGlobalAppBlocker: PropTypes.bool.isRequired, - updatePlugin: PropTypes.func.isRequired + updatePlugin: PropTypes.func.isRequired, }; const mapStateToProps = createStructuredSelector({ @@ -331,7 +331,7 @@ const mapStateToProps = createStructuredSelector({ hasUserPlugin: selectHasUserPlugin(), isAppLoading: makeSelectIsAppLoading(), plugins: selectPlugins(), - showGlobalAppBlocker: makeSelectShowGlobalAppBlocker() + showGlobalAppBlocker: makeSelectShowGlobalAppBlocker(), }); function mapDispatchToProps(dispatch) { @@ -341,7 +341,7 @@ function mapDispatchToProps(dispatch) { enableGlobalOverlayBlocker, getAdminData, pluginLoaded, - updatePlugin + updatePlugin, }, dispatch ); diff --git a/packages/strapi-admin/admin/src/containers/ListPluginsPage/index.js b/packages/strapi-admin/admin/src/containers/ListPluginsPage/index.js index 1871cc69cb..948637a2c9 100644 --- a/packages/strapi-admin/admin/src/containers/ListPluginsPage/index.js +++ b/packages/strapi-admin/admin/src/containers/ListPluginsPage/index.js @@ -23,12 +23,12 @@ import { makeSelectCurrentEnv, makeSelectPluginDeleteAction, makeSelectPlugins, - makeSelectIsLoading + makeSelectIsLoading, } from './selectors'; import { getPlugins, onDeletePluginClick, - onDeletePluginConfirm + onDeletePluginConfirm, } from './actions'; import reducer from './reducer'; import saga from './saga'; @@ -37,7 +37,7 @@ import styles from './styles.scss'; export class ListPluginsPage extends React.Component { // eslint-disable-line react/prefer-stateless-function getChildContext = () => ({ - currentEnvironment: this.props.currentEnvironment + currentEnvironment: this.props.currentEnvironment, }); componentDidMount() { @@ -64,10 +64,10 @@ export class ListPluginsPage extends React.Component {
@@ -85,7 +85,7 @@ export class ListPluginsPage extends React.Component { } ListPluginsPage.childContextTypes = { - currentEnvironment: PropTypes.string.isRequired + currentEnvironment: PropTypes.string.isRequired, }; ListPluginsPage.contextTypes = {}; @@ -98,14 +98,14 @@ ListPluginsPage.propTypes = { onDeletePluginClick: PropTypes.func.isRequired, onDeletePluginConfirm: PropTypes.func.isRequired, pluginActionSucceeded: PropTypes.bool.isRequired, - plugins: PropTypes.object.isRequired + plugins: PropTypes.object.isRequired, }; const mapStateToProps = createStructuredSelector({ currentEnvironment: makeSelectCurrentEnv(), isLoading: makeSelectIsLoading(), pluginActionSucceeded: makeSelectPluginDeleteAction(), - plugins: makeSelectPlugins() + plugins: makeSelectPlugins(), }); function mapDispatchToProps(dispatch) { @@ -113,15 +113,15 @@ function mapDispatchToProps(dispatch) { { getPlugins, onDeletePluginClick, - onDeletePluginConfirm + onDeletePluginConfirm, }, - dispatch + dispatch, ); } const withConnect = connect( mapStateToProps, - mapDispatchToProps + mapDispatchToProps, ); /* Remove this line if the container doesn't have a route and @@ -137,5 +137,5 @@ const withSaga = injectSaga({ key: 'listPluginsPage', saga }); export default compose( withReducer, withSaga, - withConnect + withConnect, )(ListPluginsPage); diff --git a/packages/strapi-helper-plugin/lib/src/app.js b/packages/strapi-helper-plugin/lib/src/app.js index 29cdc74dfa..f5d10ed5a6 100644 --- a/packages/strapi-helper-plugin/lib/src/app.js +++ b/packages/strapi-helper-plugin/lib/src/app.js @@ -18,7 +18,7 @@ import { translationMessages } from './i18n'; const LoadableApp = Loadable({ loader: () => import('containers/App'), - loading: LoadingIndicatorPage + loading: LoadingIndicatorPage, }); const tryRequireRoot = source => { @@ -70,7 +70,7 @@ function Comp(props) { if (window.Cypress) { window.__store__ = Object.assign(window.__store__ || {}, { - [pluginId]: store + [pluginId]: store, }); } @@ -105,7 +105,7 @@ strapi.registerPlugin({ name: pluginPkg.strapi.name, pluginRequirements, preventComponentRendering: false, - translationMessages + translationMessages, }); // Export store diff --git a/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout.svg b/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout.svg new file mode 100644 index 0000000000..5c505a9000 --- /dev/null +++ b/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout_hover.svg b/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout_hover.svg new file mode 100644 index 0000000000..ad90546d65 --- /dev/null +++ b/packages/strapi-helper-plugin/lib/src/assets/icons/icon_layout_hover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/strapi-helper-plugin/lib/src/components/NavLink/index.js b/packages/strapi-helper-plugin/lib/src/components/NavLink/index.js new file mode 100644 index 0000000000..0f4d445b35 --- /dev/null +++ b/packages/strapi-helper-plugin/lib/src/components/NavLink/index.js @@ -0,0 +1,62 @@ +/** + * + * NavLink + * + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { isObject } from 'lodash'; +import cn from 'classnames'; + +import styles from './styles.scss'; + +function NavLink(props) { + let content = props.children; + + if (typeof props.message === 'string') { + content = props.message; + } + + if (isObject(props.message) && props.message.id) { + content = ; + } + + let icon = ; + + if (props.icon === 'layout') { + icon = ; + } + + return ( + + {icon} + {content} + + ); +} + +NavLink.defaultProps = { + children: '', + icon: '', + message: '', + url: '', +}; + +NavLink.propTypes = { + children: PropTypes.node, + icon: PropTypes.string, + message: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.string, + PropTypes.shape({ + id: PropTypes.string, + params: PropTypes.object, + }), + ]), + url: PropTypes.string, +}; + +export default NavLink; diff --git a/packages/strapi-helper-plugin/lib/src/components/NavLink/styles.scss b/packages/strapi-helper-plugin/lib/src/components/NavLink/styles.scss new file mode 100644 index 0000000000..9cd8ddf811 --- /dev/null +++ b/packages/strapi-helper-plugin/lib/src/components/NavLink/styles.scss @@ -0,0 +1,28 @@ +a.navLink { + text-decoration: none; + width: 100%; + display: block; + span, i { + color: #333740; + } + span { + font-size: 13px; + } + i { + margin-right: 10px; + width: 13px; + height: 11px; + } + .fa.layout { + background-image: url('../../assets/icons/icon_layout.svg'); + display: inline-block; + } + &:hover { + span, i { + color: #007EFF; + } + .fa.layout { + background-image: url('../../assets/icons/icon_layout_hover.svg'); + } + } +} \ No newline at end of file diff --git a/packages/strapi-plugin-content-manager/admin/src/bootstrap.js b/packages/strapi-plugin-content-manager/admin/src/bootstrap.js index 524be693df..5b143db3ea 100644 --- a/packages/strapi-plugin-content-manager/admin/src/bootstrap.js +++ b/packages/strapi-plugin-content-manager/admin/src/bootstrap.js @@ -2,23 +2,26 @@ import { map, omit } from 'lodash'; import request from 'utils/request'; // This method is executed before the load of the plugin -const bootstrap = (plugin) => new Promise((resolve, reject) => { - request('/content-manager/models', { method: 'GET' }) - .then(models => { - const menu = [{ - name: 'Content Types', - links: map(omit(models.models.models, 'plugins'), (model, key) => ({ - label: model.labelPlural || model.label || key, - destination: key, - })), - }]; - plugin.leftMenuSections = menu; - resolve(plugin); - }) - .catch(e => { - strapi.notification.error('content-manager.error.model.fetch'); - reject(e); - }); -}); +const bootstrap = plugin => + new Promise((resolve, reject) => { + request('/content-manager/models', { method: 'GET' }) + .then(models => { + const menu = [ + { + name: 'Content Types', + links: map(omit(models.models.models, 'plugins'), (model, key) => ({ + label: model.labelPlural || model.label || key, + destination: key, + })), + }, + ]; + plugin.leftMenuSections = menu; + resolve(plugin); + }) + .catch(e => { + strapi.notification.error('content-manager.error.model.fetch'); + reject(e); + }); + }); export default bootstrap; diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/index.js b/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/index.js index 04697d6176..a7f12e496d 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/index.js +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/index.js @@ -7,7 +7,6 @@ import React from 'react'; import { connect } from 'react-redux'; import { bindActionCreators, compose } from 'redux'; -import { Link } from 'react-router-dom'; import { createStructuredSelector } from 'reselect'; import PropTypes from 'prop-types'; import { @@ -19,7 +18,7 @@ import { toNumber, toString, truncate, - replace + replace, } from 'lodash'; import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-dnd'; @@ -32,7 +31,7 @@ import EmptyAttributesBlock from 'components/EmptyAttributesBlock'; import LoadingIndicator from 'components/LoadingIndicator'; import PluginHeader from 'components/PluginHeader'; import PopUpWarning from 'components/PopUpWarning'; -import ListRow from 'components/ListRow'; +import NavLink from 'components/NavLink'; // Plugin's components import CustomDragLayer from 'components/CustomDragLayer'; import Edit from 'components/Edit'; @@ -59,13 +58,15 @@ import { resetProps, setFileRelations, setFormErrors, - submit + submit, } from './actions'; import reducer from './reducer'; import saga from './saga'; import makeSelectEditPage from './selectors'; import styles from './styles.scss'; +const pluginId = 'content-manager'; + export class EditPage extends React.Component { state = { showWarning: false, showWarningDelete: false }; @@ -87,17 +88,17 @@ export class EditPage extends React.Component { includes(this.props.location.search, '?redirectUrl') ) { const redirectUrl = this.props.location.search.split( - '?redirectUrl=' + '?redirectUrl=', )[1]; this.props.history.push({ pathname: redirectUrl.split('?')[0], - search: redirectUrl.split('?')[1] + search: redirectUrl.split('?')[1], }); } else { this.props.history.push({ pathname: replace(this.props.location.pathname, '/create', ''), - search: `?source=${this.getSource()}` + search: `?source=${this.getSource()}`, }); } } @@ -122,7 +123,7 @@ export class EditPage extends React.Component { getLayout = () => bindLayout.call( this, - get(this.props.schema, ['layout', this.getModelName()], {}) + get(this.props.schema, ['layout', this.getModelName()], {}), ); /** @@ -135,9 +136,9 @@ export class EditPage extends React.Component { this.props.editPage.formValidations, [ findIndex(this.props.editPage.formValidations, ['name', name]), - 'validations' + 'validations', ], - {} + {}, ); getDisplayedFields = () => @@ -153,7 +154,7 @@ export class EditPage extends React.Component { 'models', 'plugins', this.getSource(), - this.getModelName() + this.getModelName(), ]); /** @@ -179,12 +180,12 @@ export class EditPage extends React.Component { * @return {Object} */ getSchema = () => - this.getSource() !== 'content-manager' + this.getSource() !== pluginId ? get(this.props.schema, [ 'models', 'plugins', this.getSource(), - this.getModelName() + this.getModelName(), ]) : get(this.props.schema, ['models', this.getModelName()]); @@ -195,18 +196,18 @@ export class EditPage extends React.Component { const primaryKey = this.getModel().primaryKey; const { match: { - params: { id } - } + params: { id }, + }, } = this.props; const title = get( this.getSchema(), 'editDisplay.displayedField', - primaryKey + primaryKey, ); const valueToDisplay = get( this.props.editPage, ['initialRecord', title], - id + id, ); return isEmpty(valueToDisplay) @@ -220,6 +221,25 @@ export class EditPage extends React.Component { */ getSource = () => getQueryParameters(this.props.location.search, 'source'); + /** + * Get url base to create edit layout link + * @type {String} url base + */ + getContentManagerBaseUrl = () => { + let url = `/plugins/${pluginId}/ctm-configurations/edit-settings/`; + if (this.getSource() === 'users-permissions') { + url = `${url}plugins/${this.getSource()}/`; + } + + return url; + }; + + /** + * Access url base from injected component to create edit model link + * @type {String} url base + */ + getContentTypeBuilderBaseUrl = () => '/plugins/content-type-builder/models/'; + /** * Initialize component */ @@ -229,7 +249,7 @@ export class EditPage extends React.Component { this.isCreating(), this.getSource(), this.getModelAttributes(), - this.getDisplayedFields() + this.getDisplayedFields(), ); if (!this.isCreating()) { @@ -240,7 +260,7 @@ export class EditPage extends React.Component { // Get all relations made with the upload plugin const fileRelations = Object.keys( - get(this.getSchema(), 'relations', {}) + get(this.getSchema(), 'relations', {}), ).reduce((acc, current) => { const association = get(this.getSchema(), ['relations', current], {}); @@ -250,7 +270,7 @@ export class EditPage extends React.Component { ) { const relation = { name: current, - multiple: association.nature === 'manyToManyMorph' + multiple: association.nature === 'manyToManyMorph', }; acc.push(relation); @@ -265,7 +285,7 @@ export class EditPage extends React.Component { handleAddRelationItem = ({ key, value }) => { this.props.addRelationItem({ key, - value + value, }); }; @@ -276,19 +296,19 @@ export class EditPage extends React.Component { return this.props.changeData({ target: { name: `record.${target.name}`, - value: defaultValue - } + value: defaultValue, + }, }); } const errorIndex = findIndex(this.props.editPage.formErrors, [ 'name', - target.name + target.name, ]); const errors = inputValidations( target.value, this.getAttributeValidations(target.name), - target.type + target.type, ); const formErrors = cloneDeep(this.props.editPage.formErrors); @@ -308,7 +328,7 @@ export class EditPage extends React.Component { // Check if date if ( ['float', 'integer', 'biginteger', 'decimal'].indexOf( - get(this.getSchema(), ['fields', e.target.name, 'type']) + get(this.getSchema(), ['fields', e.target.name, 'type']), ) !== -1 ) { value = toNumber(e.target.value); @@ -316,7 +336,7 @@ export class EditPage extends React.Component { const target = { name: `record.${e.target.name}`, - value + value, }; this.props.changeData({ target }); @@ -336,14 +356,14 @@ export class EditPage extends React.Component { handleGoBack = () => this.props.history.goBack(); - handleRedirect = ({ model, id, source = 'content-manager' }) => { + handleRedirect = ({ model, id, source = pluginId }) => { /* eslint-disable */ switch (model) { case 'permission': case 'role': case 'file': // Exclude special models which are handled by plugins. - if (source !== 'content-manager') { + if (source !== pluginId) { break; } default: @@ -355,8 +375,8 @@ export class EditPage extends React.Component { pathname, search: `?source=${source}&redirectURI=${generateRedirectURI({ model, - search: `?source=${source}` - })}` + search: `?source=${source}`, + })}`, }); } /* eslint-enable */ @@ -366,7 +386,7 @@ export class EditPage extends React.Component { e.preventDefault(); const formErrors = checkFormValidity( this.generateFormFromRecord(), - this.props.editPage.formValidations + this.props.editPage.formValidations, ); if (isEmpty(formErrors)) { @@ -376,24 +396,20 @@ export class EditPage extends React.Component { this.props.setFormErrors(formErrors); }; - hasDisplayedRelations = () => { - return this.getDisplayedRelations().length > 0; - }; - - hasDisplayedDevSection = () => { - return process.env.NODE_ENV === 'development'; - }; - - hasDisplayedRightSection = () => { - return this.hasDisplayedRelations || this.hasDisplayedDevSection; - }; - hasDisplayedFields = () => { return get(this.getModel(), ['editDisplay', 'fields'], []).length > 0; }; isCreating = () => this.props.match.params.id === 'create'; + /** + * Check environment + * @type {boolean} current env is dev + */ + isDevEnvironment = () => { + return process.env.NODE_ENV === 'development'; + }; + isRelationComponentNull = () => Object.keys(get(this.getSchema(), 'relations', {})).filter( relation => @@ -401,7 +417,7 @@ export class EditPage extends React.Component { (!get(this.getSchema(), ['relations', relation, 'nature'], '') .toLowerCase() .includes('morph') || - !get(this.getSchema(), ['relations', relation, relation])) + !get(this.getSchema(), ['relations', relation, relation])), ).length === 0; // NOTE: technical debt that needs to be redone @@ -412,25 +428,43 @@ export class EditPage extends React.Component { return acc; }, {}); + /** + * Render the edit layout link + * @type {NavLink} + */ + layoutLink = () => { + // Retrieve URL + const url = `${this.getContentManagerBaseUrl()}${this.getModelName()}`; + // Link props to display + const message = { + message: { + id: `${pluginId}.containers.Edit.Link.Layout`, + }, + icon: 'layout', + }; + + return ; + }; + pluginHeaderActions = () => [ { - label: 'content-manager.containers.Edit.reset', + label: `${pluginId}.containers.Edit.reset`, kind: 'secondary', onClick: this.toggle, type: 'button', - disabled: this.showLoaders() + disabled: this.showLoaders(), }, { kind: 'primary', - label: 'content-manager.containers.Edit.submit', + label: `${pluginId}.containers.Edit.submit`, onClick: this.handleSubmit, type: 'submit', loader: this.props.editPage.showLoader, style: this.props.editPage.showLoader ? { marginRight: '18px', flexGrow: 2 } : { flexGrow: 2 }, - disabled: this.showLoaders() - } + disabled: this.showLoaders(), + }, ]; pluginHeaderSubActions = () => { @@ -443,18 +477,67 @@ export class EditPage extends React.Component { kind: 'delete', onClick: this.toggleDelete, type: 'button', - disabled: this.showLoaders() - } + disabled: this.showLoaders(), + }, ]; return subActions; /* eslint-enable indent */ }; + /** + * Retrieve external links from injected components + * @type {Array} List of external links to display + */ + retrieveLinksContainerComponent = () => { + // Should be retrieved from the global props (@soupette) + const { plugins } = this.context; + const appPlugins = plugins.toJS(); + const componentToInject = Object.keys(appPlugins).reduce((acc, current) => { + // Retrieve injected compos from plugin + // if compo can be injected in left.links area push the compo in the array + const currentPlugin = appPlugins[current]; + const injectedComponents = get(currentPlugin, 'injectedComponents', []); + + const compos = injectedComponents + .filter(compo => { + return ( + compo.plugin === `${pluginId}.editPage` && + compo.area === 'right.links' + ); + }) + .map(compo => { + const Component = compo.component; + + return ( +
  • + +
  • + ); + }); + + return [...acc, ...compos]; + }, []); + + return componentToInject; + }; + + shouldDisplayedRelations = () => { + return this.getDisplayedRelations().length > 0; + }; + + /** + * Right section to display if needed + * @type {boolean} + */ + shouldDisplayedRightSection = () => { + return this.shouldDisplayedRelations() || this.isDevEnvironment(); + }; + showLoaders = () => { const { editPage: { isLoading }, - schema: { layout } + schema: { layout }, } = this.props; return ( @@ -468,72 +551,38 @@ export class EditPage extends React.Component { toggleDelete = () => this.setState(prevState => ({ - showWarningDelete: !prevState.showWarningDelete + showWarningDelete: !prevState.showWarningDelete, })); - layoutLink = () => { - const url = this.getContentManagerBaseUrl() + this.getModelName(); + /** + * Render internal and external links + * @type {Array} List of all links to display + */ + renderNavLinks = () => { + // Add ctm link as list item to external links array to return the entire list + let ctmLink =
  • {this.layoutLink()}
  • ; - return Configure the layout; - }; - - getContentManagerBaseUrl = () => { - if (this.getModelName() !== 'user') { - return '/plugins/content-manager/ctm-configurations/edit-settings/'; - } else { - return '/plugins/content-manager/ctm-configurations/edit-settings/plugins/users-permissions/'; - } - }; - - getContentTypeBuilderBaseUrl = () => { - return '/plugins/content-type-builder/models/'; - }; - - retrieveLinksContainerComponent = () => { - // Should be retrieved from the global props (@soupette) - const { plugins } = this.context; - const appPlugins = plugins.toJS(); - const componentToInject = Object.keys(appPlugins).reduce((acc, current) => { - // Retrieve injected compo from plugin - // if compo can be injected in left.links area push the compo in the array - const currentPlugin = appPlugins[current]; - const injectedComponents = get(currentPlugin, 'injectedComponents', []); - - const compos = injectedComponents - .filter(compo => { - return ( - compo.plugin === 'content-manager.editPage' && - compo.area === 'left.links' - ); - }) - .map(compo => { - const Component = compo.component; - - return ; - }); - - return [...acc, ...compos]; - }, []); - - return componentToInject; + return [ctmLink, ...this.retrieveLinksContainerComponent()]; }; renderEdit = () => { const { editPage, - location: { search } + location: { search }, } = this.props; const source = getQueryParameters(search, 'source'); - const basePath = '/plugins/content-manager/ctm-configurations'; + const basePath = `/plugins/${pluginId}/ctm-configurations`; const pathname = - source !== 'content-manager' + source !== pluginId ? `${basePath}/plugins/${source}/${this.getModelName()}` : `${basePath}/${this.getModelName()}`; if (this.showLoaders()) { return (
    @@ -545,11 +594,13 @@ export class EditPage extends React.Component { if (!this.hasDisplayedFields()) { return (
    this.props.history.push(pathname)} />
    @@ -558,7 +609,9 @@ export class EditPage extends React.Component { return (
    {this.renderEdit()} - {this.hasDisplayedRightSection() && ( + {this.shouldDisplayedRightSection() && (
    - {this.hasDisplayedRelations() && ( + {this.shouldDisplayedRelations() && (
    )} - {this.hasDisplayedDevSection() && ( + {this.isDevEnvironment() && (
    -
      -
    • {this.layoutLink()}
    • -
    • {this.retrieveLinksContainerComponent()}
    • -
    +
      {this.renderNavLinks()}
    )}
    @@ -663,11 +711,11 @@ export class EditPage extends React.Component { } EditPage.contextTypes = { - plugins: PropTypes.object + plugins: PropTypes.object, }; EditPage.defaultProps = { - schema: {} + schema: {}, }; EditPage.propTypes = { @@ -688,7 +736,7 @@ EditPage.propTypes = { schema: PropTypes.object, setFileRelations: PropTypes.func.isRequired, setFormErrors: PropTypes.func.isRequired, - submit: PropTypes.func.isRequired + submit: PropTypes.func.isRequired, }; function mapDispatchToProps(dispatch) { @@ -706,20 +754,20 @@ function mapDispatchToProps(dispatch) { resetProps, setFileRelations, setFormErrors, - submit + submit, }, - dispatch + dispatch, ); } const mapStateToProps = createStructuredSelector({ editPage: makeSelectEditPage(), - schema: makeSelectSchema() + schema: makeSelectSchema(), }); const withConnect = connect( mapStateToProps, - mapDispatchToProps + mapDispatchToProps, ); const withReducer = injectReducer({ key: 'editPage', reducer }); @@ -728,5 +776,5 @@ const withSaga = injectSaga({ key: 'editPage', saga }); export default compose( withReducer, withSaga, - withConnect + withConnect, )(DragDropContext(HTML5Backend)(EditPage)); diff --git a/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/styles.scss b/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/styles.scss index d1ef96596e..7d76ea6fd0 100644 --- a/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/styles.scss +++ b/packages/strapi-plugin-content-manager/admin/src/containers/EditPage/styles.scss @@ -19,7 +19,7 @@ .sub_wrapper { padding: 0 20px 1px; - margin-bottom: 40px; + margin-bottom: 28px; } .links_wrapper { @@ -33,13 +33,5 @@ &:first-of-type { border-color: transparent; } - a { - text-decoration: none; - width: 100%; - display: block; - p { - font-size: 13px; - } - } } } diff --git a/packages/strapi-plugin-content-manager/admin/src/translations/en.json b/packages/strapi-plugin-content-manager/admin/src/translations/en.json index 76479a2cf8..283a83d288 100644 --- a/packages/strapi-plugin-content-manager/admin/src/translations/en.json +++ b/packages/strapi-plugin-content-manager/admin/src/translations/en.json @@ -35,6 +35,8 @@ "containers.Edit.returnList": "Return to list", "containers.Edit.seeDetails": "Details", "containers.Edit.submit": "Save", + "containers.Edit.Link.Layout": "Configure the layout", + "containers.Edit.Link.Fields": "Edit the fields", "containers.Home.introduction": "To edit your entries go to the specific link in the left menu. This plugin doesn't have a proper way to edit settings and it's still under active development.", "containers.Home.pluginHeaderDescription": "Manage your entries through a powerful and beautiful interface.", "containers.Home.pluginHeaderTitle": "Content Manager", @@ -119,4 +121,4 @@ "popUpWarning.warning.updateAllSettings": "This will modify all your settings", "success.record.delete": "Deleted", "success.record.save": "Saved" -} \ No newline at end of file +} diff --git a/packages/strapi-plugin-content-manager/admin/src/translations/fr.json b/packages/strapi-plugin-content-manager/admin/src/translations/fr.json index 7406974be9..daf6d3b046 100644 --- a/packages/strapi-plugin-content-manager/admin/src/translations/fr.json +++ b/packages/strapi-plugin-content-manager/admin/src/translations/fr.json @@ -35,6 +35,8 @@ "containers.Edit.returnList": "Retourner à la liste", "containers.Edit.seeDetails": "Détails", "containers.Edit.submit": "Valider", + "containers.Edit.Link.Layout": "Paramétrer la vue", + "containers.Edit.Link.Fields": "Éditer le modèle", "containers.Home.introduction": "Pour éditer du contenu, choisissez un type de données dans le menu de gauche.", "containers.Home.pluginHeaderDescription": "Créer et modifier votre type de contenu", "containers.Home.pluginHeaderTitle": "Type de contenu", @@ -120,4 +122,3 @@ "success.record.delete": "Supprimé", "success.record.save": "Sauvegardé" } - diff --git a/packages/strapi-plugin-content-manager/config/functions/bootstrap.js b/packages/strapi-plugin-content-manager/config/functions/bootstrap.js index e07fa753ca..6b7bdfba7d 100644 --- a/packages/strapi-plugin-content-manager/config/functions/bootstrap.js +++ b/packages/strapi-plugin-content-manager/config/functions/bootstrap.js @@ -5,30 +5,35 @@ const { getApisKeys, getApisUploadRelations, getEditDisplayAvailableFieldsPath, - getEditDisplayFieldsPath + getEditDisplayFieldsPath, } = require('./utils/getters'); const splitted = str => str.split('.'); -const pickData = (model) => _.pick(model, [ - 'info', - 'connection', - 'collectionName', - 'attributes', - 'identity', - 'globalId', - 'globalName', - 'orm', - 'options', - 'loadedModel', - 'primaryKey', - 'associations' -]); +const pickData = model => + _.pick(model, [ + 'info', + 'connection', + 'collectionName', + 'attributes', + 'identity', + 'globalId', + 'globalName', + 'orm', + 'options', + 'loadedModel', + 'primaryKey', + 'associations', + ]); module.exports = async cb => { // Retrieve all layout files from the plugins config folder const pluginsLayout = Object.keys(strapi.plugins).reduce((acc, current) => { const models = _.get(strapi.plugins, [current, 'config', 'layout'], {}); Object.keys(models).forEach(model => { - const layout = _.get(strapi.plugins, [current, 'config', 'layout', model], {}); + const layout = _.get( + strapi.plugins, + [current, 'config', 'layout', model], + {} + ); acc[model] = layout; }); @@ -69,48 +74,59 @@ module.exports = async cb => { models: { plugins: {}, }, - layout: {} + layout: {}, }; // Populate the schema object const buildSchema = (model, name, plugin = false) => { // Model data - const schemaModel = Object.assign({ - label: _.upperFirst(name), - labelPlural: _.upperFirst(pluralize(name)), - orm: model.orm || 'mongoose', - search: true, - filters: true, - bulkActions: true, - pageEntries: 10, - defaultSort: model.primaryKey, - sort: 'ASC', - options: model.options, - editDisplay: { - availableFields: {}, - displayedField: model.primaryKey, - fields: [], - relations: [], + const schemaModel = Object.assign( + { + label: _.upperFirst(name), + labelPlural: _.upperFirst(pluralize(name)), + orm: model.orm || 'mongoose', + search: true, + filters: true, + bulkActions: true, + pageEntries: 10, + defaultSort: model.primaryKey, + sort: 'ASC', + options: model.options, + editDisplay: { + availableFields: {}, + displayedField: model.primaryKey, + fields: [], + relations: [], + }, }, - }, model); + model + ); const fieldsToRemove = []; // Fields (non relation) - const fields = _.mapValues(_.pickBy(model.attributes, attribute => - !attribute.model && !attribute.collection - ), (value, attribute) => { - const fieldClassName = _.get(tempLayout, [name, 'attributes', attribute, 'className'], ''); + const fields = _.mapValues( + _.pickBy( + model.attributes, + attribute => !attribute.model && !attribute.collection + ), + (value, attribute) => { + const fieldClassName = _.get( + tempLayout, + [name, 'attributes', attribute, 'className'], + '' + ); - if (fieldClassName === 'd-none') { - fieldsToRemove.push(attribute); + if (fieldClassName === 'd-none') { + fieldsToRemove.push(attribute); + } + + return { + label: _.upperFirst(attribute), + description: '', + type: value.type || 'string', + disabled: false, + }; } - - return { - label: _.upperFirst(attribute), - description: '', - type: value.type || 'string', - disabled: false, - }; - }); + ); // Don't display fields that are hidden by default like the resetPasswordToken for the model user fieldsToRemove.forEach(field => { @@ -128,7 +144,11 @@ module.exports = async cb => { const attrType = schemaModel.fields[attr].type; const sortable = attrType !== 'json' && attrType !== 'array'; - return Object.assign(schemaModel.fields[attr], { name: attr, sortable, searchable: sortable }); + return Object.assign(schemaModel.fields[attr], { + name: attr, + sortable, + searchable: sortable, + }); }) // Retrieve only the fourth first items .slice(0, 4); @@ -143,13 +163,24 @@ module.exports = async cb => { // This object will be used to customise the label and description and so on of an input. // TODO: maybe add the customBootstrapClass in it; - schemaModel.editDisplay.availableFields = Object.keys(schemaModel.fields).reduce((acc, current) => { + schemaModel.editDisplay.availableFields = Object.keys( + schemaModel.fields + ).reduce((acc, current) => { acc[current] = Object.assign( - _.pick(_.get(schemaModel, ['fields', current], {}), ['label', 'type', 'description', 'name']), + _.pick(_.get(schemaModel, ['fields', current], {}), [ + 'label', + 'type', + 'description', + 'name', + ]), { - editable: ['updatedAt', 'createdAt', 'updated_at', 'created_at'].indexOf(current) === -1, + editable: + ['updatedAt', 'createdAt', 'updated_at', 'created_at'].indexOf( + current + ) === -1, placeholder: '', - }); + } + ); return acc; }, {}); @@ -158,13 +189,37 @@ module.exports = async cb => { // Model relations schemaModel.relations = model.associations.reduce((acc, current) => { const label = _.upperFirst(current.alias); - const displayedAttribute = current.plugin ? // Value to modified to custom what's displayed in the react-select - _.get(pluginsModel, [current.plugin, 'models', current.model || current.collection, 'info', 'mainField']) || - _.findKey(_.get(pluginsModel, [current.plugin, 'models', current.model || current.collection, 'attributes']), { type : 'string'}) || - 'id' : - _.get(models, [current.model || current.collection, 'info', 'mainField']) || - _.findKey(_.get(models, [current.model || current.collection, 'attributes']), { type : 'string'}) || - 'id'; + const displayedAttribute = current.plugin // Value to modified to custom what's displayed in the react-select + ? _.get(pluginsModel, [ + current.plugin, + 'models', + current.model || current.collection, + 'info', + 'mainField', + ]) || + _.findKey( + _.get(pluginsModel, [ + current.plugin, + 'models', + current.model || current.collection, + 'attributes', + ]), + { type: 'string' } + ) || + 'id' + : _.get(models, [ + current.model || current.collection, + 'info', + 'mainField', + ]) || + _.findKey( + _.get(models, [ + current.model || current.collection, + 'attributes', + ]), + { type: 'string' } + ) || + 'id'; acc[current.alias] = { ...current, @@ -175,37 +230,55 @@ module.exports = async cb => { return acc; }, {}); - const relationsArray = Object.keys(schemaModel.relations).filter(relation => { - const isUploadRelation = _.get(schemaModel, ['relations', relation, 'plugin'], '') === 'upload'; - const isMorphSide = _.get(schemaModel, ['relations', relation, 'nature'], '').toLowerCase().includes('morp') && _.get(schemaModel, ['relations', relation, relation]) !== undefined; + const relationsArray = Object.keys(schemaModel.relations).filter( + relation => { + const isUploadRelation = + _.get(schemaModel, ['relations', relation, 'plugin'], '') === + 'upload'; + const isMorphSide = + _.get(schemaModel, ['relations', relation, 'nature'], '') + .toLowerCase() + .includes('morp') && + _.get(schemaModel, ['relations', relation, relation]) !== undefined; - return !isUploadRelation && !isMorphSide; - }); - - const uploadRelations = Object.keys(schemaModel.relations).reduce((acc, current) => { - if (_.get(schemaModel, ['relations', current, 'plugin']) === 'upload') { - const model = _.get(schemaModel, ['relations', current]); - - acc[current] = { - description: '', - editable: true, - label: _.upperFirst(current), - multiple: _.has(model, 'collection'), - name: current, - placeholder: '', - type: 'file', - disabled: false, - }; + return !isUploadRelation && !isMorphSide; } + ); - return acc; - }, {}); + const uploadRelations = Object.keys(schemaModel.relations).reduce( + (acc, current) => { + if ( + _.get(schemaModel, ['relations', current, 'plugin']) === 'upload' + ) { + const model = _.get(schemaModel, ['relations', current]); - schemaModel.editDisplay.availableFields = _.merge(schemaModel.editDisplay.availableFields, uploadRelations); + acc[current] = { + description: '', + editable: true, + label: _.upperFirst(current), + multiple: _.has(model, 'collection'), + name: current, + placeholder: '', + type: 'file', + disabled: false, + }; + } + + return acc; + }, + {} + ); + + schemaModel.editDisplay.availableFields = _.merge( + schemaModel.editDisplay.availableFields, + uploadRelations + ); schemaModel.editDisplay.relations = relationsArray; } - schemaModel.editDisplay.fields = Object.keys(schemaModel.editDisplay.availableFields); + schemaModel.editDisplay.fields = Object.keys( + schemaModel.editDisplay.availableFields + ); if (plugin) { return _.set(schema.models.plugins, `${plugin}.${name}`, schemaModel); @@ -230,7 +303,7 @@ module.exports = async cb => { const pluginStore = strapi.store({ environment: '', type: 'plugin', - name: 'content-manager' + name: 'content-manager', }); try { @@ -263,24 +336,42 @@ module.exports = async cb => { const schemaApis = getApis(schema.models); // Array of apis to add - const apisToAdd = schemaApis.filter(api => prevSchemaApis.indexOf(api) === -1).map(splitted); + const apisToAdd = schemaApis + .filter(api => prevSchemaApis.indexOf(api) === -1) + .map(splitted); // Array of apis to remove - const apisToRemove = prevSchemaApis.filter(api => schemaApis.indexOf(api) === -1).map(splitted); + const apisToRemove = prevSchemaApis + .filter(api => schemaApis.indexOf(api) === -1) + .map(splitted); // Retrieve the same apis by name - const sameApis = schemaApis.filter(api => prevSchemaApis.indexOf(api) !== -1).map(splitted); + const sameApis = schemaApis + .filter(api => prevSchemaApis.indexOf(api) !== -1) + .map(splitted); // Retrieve all the field's path of the current unchanged api name const schemaSameApisKeys = _.flattenDeep(getApisKeys(schema, sameApis)); // Retrieve all the field's path of the previous unchanged api name - const prevSchemaSameApisKeys = _.flattenDeep(getApisKeys(prevSchema, sameApis)); + const prevSchemaSameApisKeys = _.flattenDeep( + getApisKeys(prevSchema, sameApis) + ); // Determine for the same api if we need to add some fields - const sameApisAttrToAdd = schemaSameApisKeys.filter(attr => prevSchemaSameApisKeys.indexOf(attr) === -1).map(splitted); + const sameApisAttrToAdd = schemaSameApisKeys + .filter(attr => prevSchemaSameApisKeys.indexOf(attr) === -1) + .map(splitted); // Special case for the relations - const prevSchemaSameApisUploadRelations = _.flattenDeep(getApisUploadRelations(prevSchema, sameApis)); - const schemaSameApisUploadRelations = _.flattenDeep(getApisUploadRelations(schema, sameApis)); - const sameApisUploadRelationsToAdd = schemaSameApisUploadRelations.filter(attr => prevSchemaSameApisUploadRelations.indexOf(attr) === -1).map(splitted); + const prevSchemaSameApisUploadRelations = _.flattenDeep( + getApisUploadRelations(prevSchema, sameApis) + ); + const schemaSameApisUploadRelations = _.flattenDeep( + getApisUploadRelations(schema, sameApis) + ); + const sameApisUploadRelationsToAdd = schemaSameApisUploadRelations + .filter(attr => prevSchemaSameApisUploadRelations.indexOf(attr) === -1) + .map(splitted); // Determine the fields to remove for the unchanged api name - const sameApisAttrToRemove = prevSchemaSameApisKeys.filter(attr => schemaSameApisKeys.indexOf(attr) === -1).map(splitted); + const sameApisAttrToRemove = prevSchemaSameApisKeys + .filter(attr => schemaSameApisKeys.indexOf(attr) === -1) + .map(splitted); // Remove api apisToRemove.map(apiPath => { @@ -295,7 +386,8 @@ module.exports = async cb => { // Check default sort and change it if needed _.unset(prevSchema.models, attrPath); // Retrieve the api path in the schema Object - const apiPath = attrPath.length > 3 ? _.take(attrPath, 3) : _.take(attrPath, 1); + const apiPath = + attrPath.length > 3 ? _.take(attrPath, 3) : _.take(attrPath, 1); // Retrieve the listDisplay path in the schema Object const listDisplayPath = apiPath.concat('listDisplay'); const prevListDisplay = _.get(prevSchema.models, listDisplayPath); @@ -306,24 +398,36 @@ module.exports = async cb => { // If the user has deleted the default sort attribute in the content type builder // Replace it by new generated one from the current schema if (_.includes(currentAttr, defaultSort)) { - _.set(prevSchema.models, defaultSortPath, _.get(schema.models, defaultSortPath)); + _.set( + prevSchema.models, + defaultSortPath, + _.get(schema.models, defaultSortPath) + ); } // Update the displayed fields - const updatedListDisplay = prevListDisplay.filter(obj => obj.name !== currentAttr.join()); + const updatedListDisplay = prevListDisplay.filter( + obj => obj.name !== currentAttr.join() + ); // Retrieve the model's displayed fields for the `EditPage` const fieldsPath = getEditDisplayFieldsPath(attrPath); // Retrieve the previous settings const prevEditDisplayFields = _.get(prevSchema.models, fieldsPath); // Update the fields - const updatedEditDisplayFields = prevEditDisplayFields.filter(field => field !== currentAttr.join()); + const updatedEditDisplayFields = prevEditDisplayFields.filter( + field => field !== currentAttr.join() + ); // Set the new layout _.set(prevSchema.models, fieldsPath, updatedEditDisplayFields); if (updatedListDisplay.length === 0) { // Update it with the one from the generated schema - _.set(prevSchema.models, listDisplayPath, _.get(schema.models, listDisplayPath, [])); + _.set( + prevSchema.models, + listDisplayPath, + _.get(schema.models, listDisplayPath, []) + ); } else { _.set(prevSchema.models, listDisplayPath, updatedListDisplay); } @@ -333,7 +437,10 @@ module.exports = async cb => { // Here we just need to add the data from the current schema Object apisToAdd.map(apiPath => { const api = _.get(schema.models, apiPath); - const { search, filters, bulkActions, pageEntries, options } = _.get(prevSchema, 'generalSettings'); + const { search, filters, bulkActions, pageEntries, options } = _.get( + prevSchema, + 'generalSettings' + ); _.set(api, 'options', options); _.set(api, 'filters', filters); @@ -364,7 +471,13 @@ module.exports = async cb => { sameApis.forEach(apiPath => { // This doesn't keep the prevSettings for the relations, the user will have to reset it. // We might have to improve this if we want the order of the relations to be kept - ['relations', 'loadedModel', 'associations', 'attributes', ['editDisplay', 'relations']] + [ + 'relations', + 'loadedModel', + 'associations', + 'attributes', + ['editDisplay', 'relations'], + ] .map(key => apiPath.concat(key)) .forEach(keyPath => { const newValue = _.get(schema.models, keyPath); @@ -378,22 +491,29 @@ module.exports = async cb => { const attr = _.get(schema.models, attrPath); _.set(prevSchema.models, attrPath, attr); - const fieldsPath = [..._.take(attrPath, attrPath.length -2), 'fields']; + const fieldsPath = [..._.take(attrPath, attrPath.length - 2), 'fields']; const currentFields = _.get(prevSchema.models, fieldsPath, []); currentFields.push(attr.name); _.set(prevSchema.models, fieldsPath, currentFields); }); - schemaApis.map((model) => { + schemaApis.map(model => { const isPlugin = model.includes('plugins.'); - _.set(prevSchema.models[model], 'info', _.get(!isPlugin ? strapi.models[model] : strapi[model], 'info')); - _.set(prevSchema.models[model], 'options', _.get(!isPlugin ? strapi.models[model] : strapi[model], 'options')); + _.set( + prevSchema.models[model], + 'info', + _.get(!isPlugin ? strapi.models[model] : strapi[model], 'info') + ); + _.set( + prevSchema.models[model], + 'options', + _.get(!isPlugin ? strapi.models[model] : strapi[model], 'options') + ); }); await pluginStore.set({ key: 'schema', value: prevSchema }); - - } catch(err) { - console.log('error', err); + } catch (err) { + console.log('error', err); // eslint-disable-line no-console } cb(); diff --git a/packages/strapi-plugin-content-type-builder/admin/src/InjectedComponents/ContentManager/EditViewLink.js b/packages/strapi-plugin-content-type-builder/admin/src/InjectedComponents/ContentManager/EditViewLink.js new file mode 100644 index 0000000000..300dbde680 --- /dev/null +++ b/packages/strapi-plugin-content-type-builder/admin/src/InjectedComponents/ContentManager/EditViewLink.js @@ -0,0 +1,30 @@ +/** + * + * EditViewLink + * + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import NavLink from 'components/NavLink'; + +// Create link from content-type-builder to content-manager +function EditViewLink(props) { + // Retrieve URL from props + let url = `${props.getContentTypeBuilderBaseUrl()}${props.getModelName()}`; + + // Add users-permissions to URL for permission, role and user content types + if (props.getSource() === 'users-permissions') { + url = `${url}&source=${props.getSource()}`; + } + + return ; +} + +EditViewLink.propTypes = { + getContentTypeBuilderBaseUrl: PropTypes.func.isRequired, + getModelName: PropTypes.func.isRequired, + getSource: PropTypes.func.isRequired, +}; + +export default EditViewLink; diff --git a/packages/strapi-plugin-content-type-builder/admin/src/components/ContentManagerEditViewLink/index.js b/packages/strapi-plugin-content-type-builder/admin/src/components/ContentManagerEditViewLink/index.js deleted file mode 100644 index cdbe9f8ff1..0000000000 --- a/packages/strapi-plugin-content-type-builder/admin/src/components/ContentManagerEditViewLink/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * - * ContentManagerEditViewLink - * - */ - -import React from 'react'; -import { Link } from 'react-router-dom'; - -function ContentManagerEditViewLink(props) { - let url = props.getContentTypeBuilderBaseUrl() + props.getModelName(); - if ( - props.getModelName() === 'user' || - props.getModelName() === 'role' || - props.getModelName() === 'permission' - ) { - url = url + '&source=users-permissions'; - } - - return Edit the fields; -} - -ContentManagerEditViewLink.propTypes = {}; - -export default ContentManagerEditViewLink; diff --git a/packages/strapi-plugin-content-type-builder/admin/src/injectedComponents.js b/packages/strapi-plugin-content-type-builder/admin/src/injectedComponents.js index ca12bc5fbe..38447d36dc 100644 --- a/packages/strapi-plugin-content-type-builder/admin/src/injectedComponents.js +++ b/packages/strapi-plugin-content-type-builder/admin/src/injectedComponents.js @@ -1,10 +1,16 @@ -import Link from 'components/ContentManagerEditViewLink'; +import Link from './InjectedComponents/ContentManager/EditViewLink'; export default [ { plugin: 'content-manager.editPage', - area: 'left.links', + area: 'right.links', component: Link, - key: 'content-type-builder.link' - } + key: 'content-type-builder.link', + props: { + message: { + id: 'content-manager.containers.Edit.Link.Fields', + }, + icon: 'fa-cog', + }, + }, ];