strapi-plugin-navigation 1.0.4 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,11 @@ A plugin for [Strapi Headless CMS](https://github.com/strapi/strapi) that provid
23
23
 
24
24
  <img src="public/assets/preview.png" alt="UI preview" />
25
25
 
26
+ ### Versions
27
+
28
+ - **Stable** - [v1.1.2](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation)
29
+ - **Beta** - v4 support - [v2.0.0-beta.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/feat/strapi-v4-support)
30
+
26
31
  ### ⏳ Installation
27
32
 
28
33
  (Use **yarn** to install this plugin within your Strapi project (recommended). [Install yarn with these docs](https://yarnpkg.com/lang/en/docs/install/).)
@@ -73,40 +78,22 @@ Complete installation requirements are exact same as for Strapi itself and can b
73
78
 
74
79
  ## Content Type model relation to Navigation Item
75
80
 
76
- To enable Content Type to work with Navigation Item, you've to add following field to your model `*.settings.json`:
77
-
78
- ```
79
- "navigation": {
80
- "model": "navigationitem",
81
- "plugin": "navigation",
82
- "via": "related",
83
- "configurable": false,
84
- "hidden": true
85
- }
86
- ```
87
-
88
- inside the `attributes` section like in example below:
89
-
90
- ```
91
- "attributes": {
92
- ...,
93
- "navigation": {
94
- "model": "navigationitem",
95
- "plugin": "navigation",
96
- "via": "related",
97
- "configurable": false,
98
- "hidden": true
99
- },
100
- ...
101
- },
81
+ We can define in `config/plugins.js`
82
+ ```js
83
+ navigation: {
84
+ ...
85
+ relatedContentTypes: [
86
+ 'application::pages.pages'
87
+ ],
88
+ ...
89
+ },
102
90
  ```
103
91
 
104
92
  ## Configuration
105
- To setup the plugin properly we recommend to put following snippet as part of `config/custom.js` or `config/<env>/custom.js` file. If you've got already configurations for other plugins stores by this way, use just the `navigation` part within exising `plugins` item.
93
+ To setup the plugin properly we recommend to put following snippet as part of `config/plugins.js` or `config/<env>/plugins.js` file. If you've got already configurations for other plugins stores by this way, use just the `navigation` part within exising `plugins` item.
106
94
 
107
95
  ```js
108
96
  ...
109
- plugins: {
110
97
  navigation: {
111
98
  additionalFields: ['audience'],
112
99
  allowedLevels: 2,
@@ -116,7 +103,6 @@ To setup the plugin properly we recommend to put following snippet as part of `c
116
103
  },
117
104
  gql: { ... }
118
105
  },
119
- },
120
106
  ...
121
107
  ```
122
108
 
@@ -1,3 +1,4 @@
1
+ const {get} = require('lodash');
1
2
  function setupStrapi() {
2
3
  Object.defineProperty(global, 'strapi', {
3
4
  value: {
@@ -14,6 +15,9 @@ function setupStrapi() {
14
15
  },
15
16
  },
16
17
  },
18
+ get(path, defaultValue) {
19
+ return get(strapi, path, defaultValue);
20
+ },
17
21
  },
18
22
  api: {
19
23
  'home-page': {
@@ -40,13 +44,13 @@ function setupStrapi() {
40
44
  modelName: 'pages',
41
45
  associations: [{ model: 'navigationitem' }],
42
46
  },
43
- 'blog-post': {
47
+ 'application::blog-post.blog-post': {
44
48
  ...require('./blog-post.settings.json'),
45
49
  apiName: 'blog-posts',
46
50
  modelName: 'blog-posts',
47
51
  associations: [{ model: 'navigationitem' }],
48
52
  },
49
- 'my-homepage': {
53
+ 'application::my-homepages.my-homepage': {
50
54
  ...require('./my-homepage.settings.json'),
51
55
  apiName: 'my-homepage',
52
56
  modelName: 'my-homepage',
@@ -58,12 +62,12 @@ function setupStrapi() {
58
62
  modelName: 'home-page',
59
63
  associations: [{ model: 'navigationitem' }],
60
64
  },
61
- 'plugin-page': {
65
+ 'plugins::another-plugin.pages': {
62
66
  ...require('./another-plugin/pages.settings.json'),
63
67
  modelName: 'plugin-pages',
64
68
  associations: [{ model: 'navigationitem' }],
65
69
  },
66
- 'plugin-blog-post': {
70
+ 'plugins::another-plugin.blog-post': {
67
71
  ...require('./another-plugin/blog-post.settings.json'),
68
72
  modelName: 'plugin-blog-posts',
69
73
  associations: [{ model: 'navigationitem' }],
@@ -73,7 +77,15 @@ function setupStrapi() {
73
77
  navigation: {
74
78
  services: {
75
79
  navigation: jest.fn().mockImplementation(),
76
- }
80
+ },
81
+ relatedContentTypes: [
82
+ 'application::pages.pages',
83
+ 'application::blog-post.blog-post',
84
+ 'application::my-homepages.my-homepage',
85
+ 'application::page-homes.home-page',
86
+ 'plugins::another-plugin.pages',
87
+ 'plugins::another-plugin.blog-post'
88
+ ]
77
89
  },
78
90
  anotherPlugin: {
79
91
  models: {
@@ -29,7 +29,7 @@ export const form = {
29
29
  is: val => val === navigationItemType.EXTERNAL,
30
30
  then: yup.string()
31
31
  .required(translatedErrors.required)
32
- .matches(/(#.*)|(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/, {
32
+ .matches(/(#.*)|(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,}|mailto:.+@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)+[^.\s]{2,})/, {
33
33
  excludeEmptyString: true,
34
34
  message: `${pluginId}.popup.item.form.externalPath.validation.type`,
35
35
  }),
@@ -1,5 +1,5 @@
1
1
  import { isUuid, uuid } from 'uuidv4';
2
- import { find, get, isArray, isEmpty, isNil, isNumber, isObject, isString, kebabCase, last, omit, orderBy } from 'lodash';
2
+ import { find, get, isArray, isEmpty, isNil, isNumber, isObject, isString, last, omit, orderBy } from 'lodash';
3
3
  import { navigationItemType } from './enums';
4
4
 
5
5
  export const transformItemToRESTPayload = (
@@ -46,8 +46,7 @@ export const transformItemToRESTPayload = (
46
46
  order,
47
47
  uiRouterKey,
48
48
  menuAttached,
49
- audience: audience.map((audienceItem) =>
50
- isObject(audienceItem) ? audienceItem.value : audienceItem,
49
+ audience: audience.map((audienceItem) => isObject(audienceItem) ? audienceItem.value || audienceItem.id : audienceItem,
51
50
  ),
52
51
  path: isExternal ? undefined : path,
53
52
  externalPath: isExternal ? externalPath : undefined,
@@ -65,7 +64,7 @@ export const transformItemToRESTPayload = (
65
64
  };
66
65
 
67
66
  export const transformToRESTPayload = (payload, config = {}) => {
68
- const { id, name, visible, items } = payload;
67
+ const { id, name, visible, items } = payload;
69
68
  return {
70
69
  id,
71
70
  name,
@@ -120,7 +119,7 @@ const linkRelations = (item, config) => {
120
119
  const shouldBuildRelated = !relatedRef || (relatedRef && (relatedRef.id !== relatedId));
121
120
  if (shouldBuildRelated && !shouldFindRelated) {
122
121
  const relatedContentType = find(contentTypes,
123
- ct => kebabCase(ct.contentTypeName) === kebabCase(relatedItem.__contentType), {});
122
+ ct => ct.uid === relatedItem.__contentType, {});
124
123
  const { uid, labelSingular, isSingle } = relatedContentType;
125
124
  relation = {
126
125
  related: relatedItem.id,
@@ -262,7 +261,7 @@ export const usedContentTypes = (items = []) => items.flatMap(
262
261
  if (item.relatedRef) {
263
262
  return [item.relatedRef, ...used];
264
263
  }
265
- return used;
264
+ return used;
266
265
  },
267
266
  );
268
267
 
@@ -284,11 +283,11 @@ export const isRelationPublished = ({ relatedRef, relatedType = {}, type, isColl
284
283
  return true;
285
284
  };
286
285
 
287
- export const validateNavigationStructure = (items = []) =>
288
- items.map(item =>
289
- (item.removed || isRelationCorrect({
290
- related: item.related,
286
+ export const validateNavigationStructure = (items = []) =>
287
+ items.map(item =>
288
+ (item.removed || isRelationCorrect({
289
+ related: item.related,
291
290
  type: item.type,
292
- })) &&
291
+ })) &&
293
292
  validateNavigationStructure(item.items)
294
- ).filter(item => !item).length === 0;
293
+ ).filter(item => !item).length === 0;
@@ -1,26 +1,112 @@
1
- const { isEmpty } = require("lodash");
1
+ const { isEmpty, get, last } = require('lodash');
2
+
3
+ const saveJSONParse = (value) => {
4
+ try {
5
+ return JSON.parse(value).map((_) => ({ ..._, id: _._id }));
6
+ } catch (e) {
7
+ return null;
8
+ }
9
+ };
10
+
11
+ const getDefaultConnectionName = (strapi) => strapi.config.get('database.defaultConnection');
12
+
13
+ const isMongo = (strapi) => {
14
+ const connectionName = getDefaultConnectionName(strapi);
15
+ return strapi.config.get(`database.connections.${connectionName}.connector`).includes('mongo');
16
+ };
17
+
18
+ const getNavigationMorphData = (strapi) => {
19
+ const connectionName = getDefaultConnectionName(strapi);
20
+ const { [connectionName]: knex } = strapi.connections;
21
+ return knex.schema.hasTable('navigations_items_morph').then((exist)=> exist ? knex('navigations_items_morph').select('*') : []);
22
+ };
23
+
24
+ const getNavigationItemsModel = (strapi) => strapi.query('navigationitem', 'navigation');
25
+
26
+ const getRelatedModel = (strapi) => strapi.query('navigations_items_related', 'navigation');
27
+
28
+ const createRelatedData = (relatedModel, navigationItemsModel, items) => ({
29
+ field,
30
+ order,
31
+ related_id,
32
+ related_type,
33
+ navigations_items_id,
34
+ }) => {
35
+ const item = items.find(item => item.id === navigations_items_id);
36
+ const modelUID = get(strapi.query(related_type), 'model.uid');
37
+ if (item && modelUID) {
38
+ const relatedData = {
39
+ field,
40
+ order,
41
+ related_id,
42
+ related_type: modelUID,
43
+ master: get(item.master, 'id', item.master),
44
+ };
45
+ return relatedModel.create(relatedData)
46
+ .then(
47
+ ({ id }) => navigationItemsModel.update({ id: navigations_items_id }, { related: id }),
48
+ );
49
+ }
50
+ return Promise.resolve();
51
+ };
52
+
53
+ const migrateNavigationItemsSQL = async (strapi) => {
54
+ const morphData = await getNavigationMorphData(strapi);
55
+ if (morphData.length) {
56
+ const relatedModel = getRelatedModel(strapi);
57
+ const navigationItemsModel = getNavigationItemsModel(strapi);
58
+ const items = await navigationItemsModel.find({});
59
+ await Promise.all(morphData.map(createRelatedData(relatedModel, navigationItemsModel, items)));
60
+ }
61
+ };
62
+
63
+ const migrateNavigationItemsMongo = async (strapi) => {
64
+ const navigationItemsModel = getNavigationItemsModel(strapi);
65
+ const connectionName = getDefaultConnectionName(strapi);
66
+ const models = strapi.connections[connectionName].models;
67
+ const items = (await models.NavigationNavigationitem.find({}))
68
+ // workaround to change type from object to int
69
+ .map(_ => ({ ..._.toObject(), related: last(saveJSONParse(get(_.errors, 'related.properties.value', null))) }))
70
+ .filter(_ => _.related);
71
+
72
+ if (items.length) {
73
+ await Promise.all(items.map(item => {
74
+ const data = {
75
+ related_id: item.related.ref,
76
+ related_type: models[item.related.kind].uid,
77
+ field: item.related.field,
78
+ order: 1,
79
+ master: item.master,
80
+ };
81
+ return getRelatedModel(strapi)
82
+ .create(data)
83
+ .then(result => navigationItemsModel.update({ id: item.id }, { related: [result.id] }));
84
+ }));
85
+
86
+ }
87
+ };
2
88
 
3
89
  module.exports = async () => {
4
90
  // Check if the plugin users-permissions is installed because the navigation needs it
5
- if (Object.keys(strapi.plugins).indexOf("users-permissions") === -1) {
91
+ if (Object.keys(strapi.plugins).indexOf('users-permissions') === -1) {
6
92
  throw new Error(
7
- "In order to make the navigation plugin work the users-permissions plugin is required",
93
+ 'In order to make the navigation plugin work the users-permissions plugin is required',
8
94
  );
9
95
  }
10
96
 
11
97
  // Add permissions
12
98
  const actions = [
13
99
  {
14
- section: "plugins",
15
- displayName: "Access the Navigation",
16
- uid: "read",
17
- pluginName: "navigation",
100
+ section: 'plugins',
101
+ displayName: 'Access the Navigation',
102
+ uid: 'read',
103
+ pluginName: 'navigation',
18
104
  },
19
105
  {
20
- section: "plugins",
21
- displayName: "Ability to change the Navigation",
22
- uid: "update",
23
- pluginName: "navigation",
106
+ section: 'plugins',
107
+ displayName: 'Ability to change the Navigation',
108
+ uid: 'update',
109
+ pluginName: 'navigation',
24
110
  },
25
111
  ];
26
112
 
@@ -36,6 +122,16 @@ module.exports = async () => {
36
122
  visible: true,
37
123
  });
38
124
  }
125
+ const relatedModel = getRelatedModel(global.strapi);
126
+ const isMigrated = !!(await relatedModel.count({}));
127
+ if (!isMigrated) {
128
+ const isMongoDB = isMongo(global.strapi);
129
+ if (isMongoDB) {
130
+ await migrateNavigationItemsMongo(global.strapi);
131
+ } else {
132
+ await migrateNavigationItemsSQL(global.strapi);
133
+ }
134
+ }
39
135
 
40
136
  const { actionProvider } = strapi.admin.services.permission;
41
137
  await actionProvider.registerMany(actions);
@@ -1,4 +1,3 @@
1
- const {get} = require('lodash');
2
1
  const NAVIGATION_DATE = `
3
2
  # SQL
4
3
  created_at: String
@@ -25,12 +24,12 @@ const NAVIGATION = `
25
24
  `;
26
25
 
27
26
  const getContentTypesNamesFields = () => {
28
- const contentTypesNameFields = strapi.config.get('custom.plugins.navigation.contentTypesNameFields');
27
+ const contentTypesNameFields = strapi.config.get('plugins.navigation.contentTypesNameFields');
29
28
  return Object.keys(contentTypesNameFields || {}).map(key => `${key}: [String]!`).join('\n');
30
29
  };
31
30
 
32
31
  const getNavigationRelated = () => {
33
- const related = strapi.config.get('custom.plugins.navigation.gql.navigationItemRelated');
32
+ const related = strapi.config.get('plugins.navigation.gql.navigationItemRelated');
34
33
  if (related) {
35
34
  return related;
36
35
  }
@@ -67,7 +66,7 @@ module.exports = {
67
66
  parent: Int
68
67
  master: Int
69
68
  items: [NavigationItem]
70
- related: NavigationRelated
69
+ related: [NavigationRelated]
71
70
  audience: [String]
72
71
  ${NAVIGATION_DATE}
73
72
  ${NAVIGATION_USER}
@@ -149,6 +148,11 @@ module.exports = {
149
148
  navigationUpdate(id: String!, navigation: CreateNavigation!): Navigation!
150
149
  `,
151
150
  resolver: {
151
+ NavigationRelated: {
152
+ __resolveType: (data) => {
153
+ return strapi.contentTypes[data.__contentType]?.globalId
154
+ }
155
+ },
152
156
  Query: {
153
157
  renderNavigation: {
154
158
  resolverOf: 'plugins::navigation.navigation.render',
@@ -23,35 +23,33 @@ const errorHandler = (ctx) => (error) => {
23
23
  }
24
24
  throw error;
25
25
  };
26
+ const getService = () => strapi.plugins.navigation.services.navigation;
26
27
 
27
28
  module.exports = {
28
- getService() {
29
- return strapi.plugins.navigation.services.navigation;
30
- },
31
29
  /**
32
30
  * Default action.
33
31
  *
34
32
  * @return {Object}
35
33
  */
36
34
  async config() {
37
- return this.getService().config();
35
+ return getService().config();
38
36
  },
39
37
 
40
38
  async get() {
41
- return this.getService().get();
39
+ return getService().get();
42
40
  },
43
41
 
44
42
  async getById(ctx) {
45
43
  const { params } = ctx;
46
44
  const { id } = parseParams(params);
47
- return this.getService().getById(id);
45
+ return getService().getById(id);
48
46
  },
49
47
 
50
48
  async render(ctx) {
51
49
  const { params, query = {} } = ctx;
52
50
  const { type, menu: menuOnly } = query;
53
51
  const { idOrSlug } = parseParams(params);
54
- return this.getService().render(
52
+ return getService().render(
55
53
  idOrSlug,
56
54
  type,
57
55
  menuOnly,
@@ -61,7 +59,7 @@ module.exports = {
61
59
  const { params, query = {} } = ctx;
62
60
  const { type, menu: menuOnly } = query;
63
61
  const { idOrSlug, childUIKey } = parseParams(params);
64
- return this.getService().renderChildren(
62
+ return getService().renderChildren(
65
63
  idOrSlug,
66
64
  childUIKey,
67
65
  type,
@@ -72,14 +70,14 @@ module.exports = {
72
70
  post(ctx) {
73
71
  const { auditLog } = ctx;
74
72
  const { body = {} } = ctx.request;
75
- return this.getService().post(body, auditLog);
73
+ return getService().post(body, auditLog);
76
74
  },
77
75
 
78
76
  put(ctx) {
79
77
  const { params, auditLog } = ctx;
80
78
  const { id } = parseParams(params);
81
79
  const { body = {} } = ctx.request;
82
- return this.getService().put(id, body, auditLog)
80
+ return getService().put(id, body, auditLog)
83
81
  .catch(errorHandler(ctx));
84
82
  },
85
83
  };
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "pluginOptions": {
12
12
  "content-manager": {
13
- "visible": false
13
+ "visible": true
14
14
  },
15
15
  "content-type-builder": {
16
16
  "visible": false
@@ -1,4 +1,5 @@
1
- "use strict";
1
+ 'use strict';
2
+ const { camelCase } = require('lodash');
2
3
 
3
4
  /**
4
5
  * Read the documentation (https://strapi.io/documentation/3.0.0-beta.x/concepts/models.html#life-cycle-callbacks)
@@ -7,10 +8,27 @@
7
8
 
8
9
  module.exports = {
9
10
  type: {
10
- INTERNAL: "INTERNAL",
11
- EXTERNAL: "EXTERNAL",
11
+ INTERNAL: 'INTERNAL',
12
+ EXTERNAL: 'EXTERNAL',
12
13
  },
13
14
  additionalFields: {
14
15
  AUDIENCE: 'audience',
15
16
  },
17
+ lifecycles: {
18
+ afterFind(results) {
19
+ results.forEach(_ => {
20
+ _?.related.forEach(entity => {
21
+ for (const [key, value] of Object.entries(entity)) {
22
+ const newKey = camelCase(key);
23
+ if (value) {
24
+ entity[newKey] = value;
25
+ }
26
+ if (newKey !== key) {
27
+ delete entity[key];
28
+ }
29
+ }
30
+ });
31
+ });
32
+ },
33
+ },
16
34
  };
@@ -15,17 +15,28 @@
15
15
  },
16
16
  "content-type-builder": {
17
17
  "visible": false
18
+ },
19
+ "i18n": {
20
+ "localized": false
18
21
  }
19
22
  },
20
23
  "attributes": {
21
24
  "title": {
22
25
  "type": "text",
23
26
  "configurable": false,
24
- "required": true
27
+ "required": true,
28
+ "pluginOptions": {
29
+ "i18n": {
30
+ "localized": false
31
+ }
32
+ }
25
33
  },
26
34
  "type": {
27
35
  "type": "enumeration",
28
- "enum": ["INTERNAL", "EXTERNAL"],
36
+ "enum": [
37
+ "INTERNAL",
38
+ "EXTERNAL"
39
+ ],
29
40
  "default": "INTERNAL",
30
41
  "configurable": false
31
42
  },
@@ -53,8 +64,8 @@
53
64
  "configurable": false
54
65
  },
55
66
  "related": {
56
- "collection": "*",
57
- "filter": "field",
67
+ "collection": "navigations_items_related",
68
+ "plugin": "navigation",
58
69
  "configurable": false
59
70
  },
60
71
  "parent": {
@@ -0,0 +1,19 @@
1
+ const { camelCase } = require('lodash');
2
+ module.exports = {
3
+ lifecycles: {
4
+ afterFind(results) {
5
+ results
6
+ .forEach(entity => {
7
+ for (const [key, value] of Object.entries(entity)) {
8
+ const newKey = camelCase(key);
9
+ if (value) {
10
+ entity[newKey] = value;
11
+ }
12
+ if (newKey !== key) {
13
+ delete entity[key];
14
+ }
15
+ }
16
+ });
17
+ },
18
+ },
19
+ };
@@ -0,0 +1,45 @@
1
+ {
2
+ "collectionName": "navigations_items_related",
3
+ "info": {
4
+ "name": "navigations_items_related",
5
+ "description": ""
6
+ },
7
+ "options": {
8
+ "increments": true,
9
+ "timestamps": false,
10
+ "populateCreatorFields": false
11
+ },
12
+ "pluginOptions": {
13
+ "content-manager": {
14
+ "visible": false
15
+ },
16
+ "content-type-builder": {
17
+ "visible": false
18
+ },
19
+ "i18n": {
20
+ "localized": false
21
+ }
22
+ },
23
+ "attributes": {
24
+ "related_id": {
25
+ "type": "string",
26
+ "required": true
27
+ },
28
+ "related_type": {
29
+ "type": "string",
30
+ "required": true
31
+ },
32
+ "field": {
33
+ "type": "string",
34
+ "required": true
35
+ },
36
+ "order": {
37
+ "type": "integer",
38
+ "required": true
39
+ },
40
+ "master": {
41
+ "type": "string",
42
+ "required": true
43
+ }
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-navigation",
3
- "version": "1.0.4",
3
+ "version": "1.1.3",
4
4
  "description": "Strapi - Navigation plugin",
5
5
  "strapi": {
6
6
  "name": "Navigation",