From f4f7aa471cad005bb438386208cdf3762a25715a Mon Sep 17 00:00:00 2001 From: Mateusz Ziarko Date: Fri, 13 Nov 2020 22:55:10 +0100 Subject: [PATCH] feat: configurable title fields for content types --- README.md | 16 ++++++-- __mocks__/helpers/blog-post.settings.json | 29 ++++++++++++++ __mocks__/helpers/page.settings.json | 26 ++++++++++++ __mocks__/helpers/strapi.js | 40 +++++++++++++++++++ admin/src/components/Item/index.js | 4 ++ admin/src/components/ItemFooter/index.js | 21 +++------- admin/src/components/List/index.js | 3 ++ .../components/NavigationItemForm/index.js | 8 +++- .../components/NavigationItemPopup/index.js | 8 +++- admin/src/containers/View/index.js | 1 + admin/src/containers/View/utils/parsers.js | 18 ++++++--- jest.config.js | 7 ++++ package.json | 5 ++- services/__tests__/navigation.test.js | 34 +++++++++++++++- services/navigation.js | 38 +++++++++++++++--- 15 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 __mocks__/helpers/blog-post.settings.json create mode 100644 __mocks__/helpers/page.settings.json create mode 100644 __mocks__/helpers/strapi.js diff --git a/README.md b/README.md index 97e97593..970bf6fb 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,24 @@ To setup the plugin properly we recommend to put following snippet as part of `c ... plugins: { navigation: { - additionalFields: ['audience'], // Additional fields: 'audience', more in the future - excludedContentTypes: ["plugins::", "strapi"], // excluded content types patterns (by default built-in and plugin specific content types) - allowedLevels: 2, // Maximum level for which your're able to mark item as "Menu attached" + additionalFields: ['audience'], + excludedContentTypes: ["plugins::", "strapi"], + allowedLevels: 2, + contentTypesNameFields: { + 'blog_posts': ['altTitle'], + 'pages': ['title'], + }, }, }, ... ``` +### Properties +- `additionalFields` - Additional fields: 'audience', more in the future +- `excludedContentTypes` - Excluded content types patterns (by default built-in and plugin specific content types) +- `allowedLevels` - Maximum level for which your're able to mark item as "Menu attached" +- `contentTypesNameFields` - Definition of content type title fields like `'content_type_name': ['field_name_1', 'field_name_2']`, if not set titles are pulled from fields like `['title', 'subject', 'name']` + ## Public API Navigation Item model ### Flat diff --git a/__mocks__/helpers/blog-post.settings.json b/__mocks__/helpers/blog-post.settings.json new file mode 100644 index 00000000..6f9fde7d --- /dev/null +++ b/__mocks__/helpers/blog-post.settings.json @@ -0,0 +1,29 @@ +{ + "kind": "collectionType", + "collectionName": "blog_posts", + "info": { + "name": "Blog posts" + }, + "options": { + "increments": true, + "timestamps": true, + "searchable": true, + "previewable": true + }, + "attributes": { + "title": { + "type": "string", + "required": true + }, + "altTitle": { + "type": "string" + }, + "navigation": { + "model": "navigationitem", + "plugin": "navigation", + "via": "related", + "configurable": false, + "hidden": true + } + } +} diff --git a/__mocks__/helpers/page.settings.json b/__mocks__/helpers/page.settings.json new file mode 100644 index 00000000..14829918 --- /dev/null +++ b/__mocks__/helpers/page.settings.json @@ -0,0 +1,26 @@ +{ + "kind": "collectionType", + "collectionName": "pages", + "info": { + "name": "page" + }, + "options": { + "increments": true, + "timestamps": true, + "searchable": true, + "previewable": true + }, + "attributes": { + "title": { + "type": "string", + "required": true + }, + "navigation": { + "model": "navigationitem", + "plugin": "navigation", + "via": "related", + "configurable": false, + "hidden": true + } + } +} diff --git a/__mocks__/helpers/strapi.js b/__mocks__/helpers/strapi.js new file mode 100644 index 00000000..e17e3b66 --- /dev/null +++ b/__mocks__/helpers/strapi.js @@ -0,0 +1,40 @@ +function setupStrapi() { + Object.defineProperty(global, 'strapi', { + value: { + config: { + custom: { + plugins: { + navigation: { + contentTypesNameFields: { + 'blog_posts': ['altTitle'], + }, + }, + }, + }, + }, + contentTypes: { + 'page': { + ...require('./page.settings.json'), + apiName: 'pages', + }, + 'blog-post': { + ...require('./blog-post.settings.json'), + apiName: 'blog-posts', + }, + }, + plugins: { + navigation: { + services: { + navigation: jest.fn().mockImplementation(), + }, + models: { + 'page': require('./page.settings.json'), + 'blog-post': require('./blog-post.settings.json'), + } + } + }, + }, + writable: true, + }) +} +module.exports = { setupStrapi }; \ No newline at end of file diff --git a/admin/src/components/Item/index.js b/admin/src/components/Item/index.js index 6efd0b24..30881d31 100644 --- a/admin/src/components/Item/index.js +++ b/admin/src/components/Item/index.js @@ -24,6 +24,7 @@ const Item = (props) => { level = 0, levelPath = '', allowedLevels, + contentTypesNameFields, relatedRef, isFirst = false, isLast = false, @@ -46,6 +47,7 @@ const Item = (props) => { removed, menuAttached, relatedRef, + contentTypesNameFields, attachButtons: !(isFirst && isLast), }; @@ -111,6 +113,7 @@ const Item = (props) => { level={level + 1} levelPath={absolutePath} allowedLevels={allowedLevels} + contentTypesNameFields={contentTypesNameFields} /> )} @@ -129,6 +132,7 @@ Item.propTypes = { menuAttached: PropTypes.bool, }).isRequired, relatedRef: PropTypes.object, + contentTypesNameFields: PropTypes.object.isRequired, level: PropTypes.number, levelPath: PropTypes.string, isFirst: PropTypes.bool, diff --git a/admin/src/components/ItemFooter/index.js b/admin/src/components/ItemFooter/index.js index 0591f135..e1aab9c2 100644 --- a/admin/src/components/ItemFooter/index.js +++ b/admin/src/components/ItemFooter/index.js @@ -5,26 +5,16 @@ import { faLink, faGlobe, faSitemap } from "@fortawesome/free-solid-svg-icons"; import CardItemRelation from "./CardItemRelation"; import CardItemType from "./CardItemType"; import Wrapper from "./Wrapper"; -import { isNil, upperFirst } from "lodash"; +import { isNil, get, upperFirst } from "lodash"; import { navigationItemType } from "../../containers/View/utils/enums"; +import { extractRelatedItemLabel } from "../../containers/View/utils/parsers"; -const ENTITY_NAME_PARAMS = [ - "title", - "Title", - "subject", - "Subject", - "name", - "Name", -]; -const resolveEntityName = (entity) => - ENTITY_NAME_PARAMS.map((_) => entity[_]).filter((_) => _)[0] || ""; - -const ItemFooter = ({ type, removed, relatedRef, attachButtons }) => { +const ItemFooter = ({ type, removed, relatedRef, attachButtons, contentTypesNameFields }) => { const formatRelationType = () => - !isNil(relatedRef) ? relatedRef.__contentType : ""; + !isNil(relatedRef) ? get(relatedRef, 'labelSingular', get(relatedRef, '__contentType')) : ""; const formatRelationName = () => - !isNil(relatedRef) ? resolveEntityName(relatedRef) : ""; + !isNil(relatedRef) ? extractRelatedItemLabel(relatedRef, contentTypesNameFields) : ""; return ( @@ -46,6 +36,7 @@ const ItemFooter = ({ type, removed, relatedRef, attachButtons }) => { ItemFooter.propTypes = { type: PropTypes.string.isRequired, + contentTypesNameFields: PropTypes.object.isRequired, menuAttached: PropTypes.bool, removed: PropTypes.bool, relatedRef: PropTypes.object, diff --git a/admin/src/components/List/index.js b/admin/src/components/List/index.js index 1c895073..e80ee6eb 100644 --- a/admin/src/components/List/index.js +++ b/admin/src/components/List/index.js @@ -18,6 +18,7 @@ const List = ({ level = 0, levelPath = '', allowedLevels, + contentTypesNameFields, }) => { const Component = as || Container; return ( @@ -34,6 +35,7 @@ const List = ({ isFirst={n === 0} isLast={n === items.length - 1} allowedLevels={allowedLevels} + contentTypesNameFields={contentTypesNameFields} onItemClick={onItemClick} onItemReOrder={onItemReOrder} onItemRestoreClick={onItemRestoreClick} @@ -61,6 +63,7 @@ List.propTypes = { items: PropTypes.array, level: PropTypes.number, allowedLevels: PropTypes.number, + contentTypesNameFields: PropTypes.object.isRequired, onItemClick: PropTypes.func.isRequired, onItemReOrder: PropTypes.func.isRequired, onItemRestoreClick: PropTypes.func.isRequired, diff --git a/admin/src/containers/View/components/NavigationItemForm/index.js b/admin/src/containers/View/components/NavigationItemForm/index.js index 0a0bd058..b2038868 100644 --- a/admin/src/containers/View/components/NavigationItemForm/index.js +++ b/admin/src/containers/View/components/NavigationItemForm/index.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { Button, Enumeration, Flex, Label, Text, Toggle } from "@buffetjs/core"; import { useIntl } from "react-intl"; -import { find, get, isEmpty, isNil, isString } from "lodash"; +import { find, get, isEmpty, isNil, isNumber, isString } from "lodash"; import PropTypes from "prop-types"; import { ButtonModal, ModalBody, ModalForm } from "strapi-helper-plugin"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -25,6 +25,7 @@ const NavigationItemForm = ({ usedContentTypeEntities = [], availableAudience = [], additionalFields = [], + contentTypesNameFields = {}, onSubmit, getContentTypeEntities, }) => { @@ -115,7 +116,10 @@ const NavigationItemForm = ({ )) .map((item) => ({ value: item.id, - label: extractRelatedItemLabel(item), + label: extractRelatedItemLabel({ + ...item, + __collectionName: get(relatedTypeSelectValue, 'value', relatedTypeSelectValue), + }, contentTypesNameFields), })); const isExternal = form.type === navigationItemType.EXTERNAL; diff --git a/admin/src/containers/View/components/NavigationItemPopup/index.js b/admin/src/containers/View/components/NavigationItemPopup/index.js index e18b28dc..a95a1abd 100644 --- a/admin/src/containers/View/components/NavigationItemPopup/index.js +++ b/admin/src/containers/View/components/NavigationItemPopup/index.js @@ -29,13 +29,16 @@ const NavigationItemPopUp = ({ }; const { related, relatedType } = data; - const { availableAudience = [], additionalFields, contentTypes, contentTypeItems } = config; + const { availableAudience = [], additionalFields, contentTypes, contentTypeItems, contentTypesNameFields = {} } = config; const prepareFormData = data => ({ ...data, related: related ? { value: related, - label: extractRelatedItemLabel(find(contentTypeItems, item => item.id === related, {})) + label: extractRelatedItemLabel({ + ...find(contentTypeItems, item => item.id === related, {}), + __collectionName: relatedType, + }, contentTypesNameFields), } : undefined, relatedType: relatedType ? { value: relatedType, @@ -56,6 +59,7 @@ const NavigationItemPopUp = ({ data={prepareFormData(data)} isLoading={isLoading} additionalFields={additionalFields} + contentTypesNameFields={contentTypesNameFields} availableAudience={availableAudience} contentTypes={contentTypes} contentTypeEntities={contentTypeItems} diff --git a/admin/src/containers/View/index.js b/admin/src/containers/View/index.js index f3e9fac3..0886a162 100644 --- a/admin/src/containers/View/index.js +++ b/admin/src/containers/View/index.js @@ -197,6 +197,7 @@ const View = () => { onItemLevelAddClick={addNewNavigationItem} root allowedLevels={config.allowedLevels} + contentTypesNameFields={config.contentTypesNameFields} /> )} diff --git a/admin/src/containers/View/utils/parsers.js b/admin/src/containers/View/utils/parsers.js index b0dd1506..82dd674a 100644 --- a/admin/src/containers/View/utils/parsers.js +++ b/admin/src/containers/View/utils/parsers.js @@ -101,22 +101,26 @@ const linkRelations = (item, config) => { const shouldBuildRelated = !relatedRef || (relatedRef && (relatedRef.id !== relatedId)); if (shouldBuildRelated && !shouldFindRelated) { const { __contentType } = relatedItem; - const __collectionName = get(find(contentTypes, ct => ct.name.toLowerCase() === __contentType.toLowerCase()), 'collectionName'); + const relatedContentType = find(contentTypes, ct => ct.contentTypeName.toLowerCase() === __contentType.toLowerCase(), {}); + const {collectionName, labelSingular } = relatedContentType; relation = { related: relatedItem.id, relatedRef: { - __collectionName, + __collectionName: collectionName, + labelSingular, ...relatedItem }, - relatedType: __collectionName + relatedType: collectionName }; } else if (shouldFindRelated) { const relatedRef = find(contentTypeItems, cti => cti.id === relatedId); const relatedContentType = find(contentTypes, ct => ct.collectionName.toLowerCase() === relatedType.toLowerCase()); + const { contentTypeName, labelSingular } = relatedContentType; relation = { relatedRef: { __collectionName: relatedType, - __contentType: upperFirst(get(relatedContentType, 'name')), + __contentType: contentTypeName, + labelSingular, ...relatedRef, }, }; @@ -223,4 +227,8 @@ export const prepareItemToViewPayload = (items = [], viewParentId = null, config }; }); - export const extractRelatedItemLabel = (item = {}) => item.name || item.title || item.label || item.id; + export const extractRelatedItemLabel = (item = {}, fields = {}) => { + const { __collectionName } = item; + const { default: defaultFields = [] } = fields; + return get(fields, `${__collectionName}`, defaultFields).map((_) => item[_]).filter((_) => _)[0] || ""; + }; diff --git a/jest.config.js b/jest.config.js index 49dd2aa1..8cd00166 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,13 @@ module.exports = { name: 'Unit test', testMatch: ['**/__tests__/?(*.)+(spec|test).js'], + testPathIgnorePatterns: [ + "/node_modules/", + ".tmp", + ".cache", + "/__mocks__/helpers/" + ], + testEnvironment: "node", transform: {}, coverageDirectory: "./coverage/", collectCoverage: true, diff --git a/package.json b/package.json index 9160ca33..db38bde9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strapi-plugin-navigation", - "version": "1.0.0-beta.11", + "version": "1.0.0-beta.12", "description": "Strapi - Navigation plugin", "strapi": { "name": "Navigation", @@ -30,7 +30,8 @@ "strapi-helper-plugin": "3.1.3", "strapi-utils": "3.1.3", "uuid": "^8.3.0", - "slugify": "^1.4.5" + "slugify": "^1.4.5", + "pluralize": "^8.0.0" }, "devDependencies": { "koa": "^2.8.0", diff --git a/services/__tests__/navigation.test.js b/services/__tests__/navigation.test.js index ce1d2d0e..bf7d7876 100644 --- a/services/__tests__/navigation.test.js +++ b/services/__tests__/navigation.test.js @@ -1,4 +1,34 @@ -describe('Dummy test', () => { - test('Dummy', () => { }); +const { setupStrapi } = require("../../__mocks__/helpers/strapi"); + +beforeAll(setupStrapi); + +describe('Navigation service', () => { + it('Strapi is defined', () => { + expect(strapi).toBeDefined(); + expect(strapi.contentTypes).toBeDefined(); + expect(Object.keys(strapi.contentTypes).length).toBe(2); + }); + it('Config Content Types', () => { + const { configContentTypes } = require("../navigation"); + const result = [{ + collectionName: "pages", + contentTypeName: "Page", + endpoint: "pages", + label: "Pages", + labelSingular: "Page", + name: "page", + visible: true, + }, { + collectionName: "blog_posts", + contentTypeName: "BlogPost", + endpoint: "blog-posts", + label: "Blog posts", + labelSingular: "Blog post", + name: "blog-post", + visible: true, + }]; + expect(configContentTypes()[0]).toMatchObject(result[0]); + expect(configContentTypes()[1]).toMatchObject(result[1]); + }); }); diff --git a/services/navigation.js b/services/navigation.js index 63f4eb4a..df12e39b 100644 --- a/services/navigation.js +++ b/services/navigation.js @@ -2,6 +2,7 @@ const { validate: uuidValidate } = require("uuid"); const slugify = require("slugify"); +const pluralize = require('pluralize'); const { sanitizeEntity } = require("strapi-utils"); const { isArray, @@ -49,7 +50,21 @@ const extractMeta = (plugins) => { const excludedContentTypes = get( strapi.config, "custom.plugins.navigation.excludedContentTypes", - ["plugins::", "strapi"], + [ + "plugins::", + "strapi", + ], +); + +const contentTypesNameFieldsDefaults = [ + "title", + "subject", + "name", +]; +const contentTypesNameFields = get( + strapi.config, + "custom.plugins.navigation.contentTypesNameFields", + {}, ); const buildNestedStructure = (entities, id = null, field = 'parent') => @@ -97,6 +112,10 @@ module.exports = { let extendedResult = {}; const result = { contentTypes: service.configContentTypes(), + contentTypesNameFields: { + default: contentTypesNameFieldsDefaults, + ...(isObject(contentTypesNameFields) ? contentTypesNameFields : {}), + }, allowedLevels: get(strapi.config, 'custom.plugins.navigation.allowedLevels'), additionalFields, } @@ -128,14 +147,21 @@ module.exports = { .map((key) => { const item = strapi.contentTypes[key]; const { options, info, collectionName, apiName, plugin } = item; - const { name, label, description } = info; + const { name, description } = info; const { isManaged, hidden } = options; + const endpoint = pluralize(apiName); + const relationName = last(apiName) === 's' ? apiName.substr(0, apiName.length - 1) : apiName; + const relationNameParts = relationName.split('-'); + const contentTypeName = relationNameParts.length > 1 ? relationNameParts.reduce((prev, curr) => `${prev}${upperFirst(curr)}`, '') : upperFirst(relationName); + const labelSingular = upperFirst(relationNameParts.length > 1 ? relationNameParts.join(' ') : relationName); return { - name, + name: relationName, description, collectionName, - endpoint: last(apiName) === 's' ? apiName : `${apiName}s`, - label: upperFirst(name || collectionName), + contentTypeName, + label: pluralize(labelSingular || name), + labelSingular, + endpoint, plugin, visible: (isManaged || isNil(isManaged)) && !hidden, }; @@ -262,7 +288,7 @@ module.exports = { uiRouterKey: item.uiRouterKey, slug: !slug && item.uiRouterKey ? slugify(item.uiRouterKey) : slug, external: isExternal, - related: isExternal ? undefined : { + related: isExternal || !firstRelated ? undefined : { ...firstRelated, __templateName: getTemplateName(firstRelated.__contentType, firstRelated.id), },