diff --git a/.circleci/config.yml b/.circleci/config.yml
index 1362cea2..2c2ce599 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2,12 +2,13 @@ version: 2.1
orbs:
codecov: codecov/codecov@1.1.1
jobs:
- build-and-test:
+ test:
environment:
CODECOV_TOKEN: c803c20c-c45d-4a63-9ba9-58c7d5d05bbf
docker:
- - image: cimg/node:18.18.2
+ - image: cimg/node:20.12.2
working_directory: ~/repo
+ resource_class: large
steps:
- checkout
- run:
@@ -20,13 +21,17 @@ jobs:
- restore_cache:
keys:
# when lock file changes, use increasingly general patterns to restore cache
- - strapi-plugin-navigation-v1-{{ checksum "yarn.lock" }}
- - strapi-plugin-navigation-
+ - strapi-plugin-navigation-v4-{{ checksum "yarn.lock" }}
+ - strapi-plugin-navigation-v4
- run:
name: Echo versions
command: |
node --version
yarn --version
+ - run:
+ name: Configure NPM
+ command: |
+ echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
- run:
name: Install
command: |
@@ -34,15 +39,56 @@ jobs:
- run:
name: Test
command: |
- yarn test:unit
+ yarn test:unit:ci
+ environment:
+ JEST_JUNIT_OUTPUT_DIR: ./reports/
+ - store_test_results:
+ path: ./reports/
- codecov/upload:
flags: unittest
file:
- save_cache:
paths:
- ./node_modules
- key: strapi-plugin-navigation-v1-{{ checksum "yarn.lock" }}
+ key: strapi-plugin-navigation-v3-{{ checksum "yarn.lock" }}
+ - persist_to_workspace:
+ root: ~/repo
+ paths: .
+ deploy:
+ environment:
+ CODECOV_TOKEN: c803c20c-c45d-4a63-9ba9-58c7d5d05bbf
+ docker:
+ - image: cimg/node:20.12.2
+ working_directory: ~/repo
+ resource_class: large
+ steps:
+ - attach_workspace:
+ at: ~/repo
+ - run:
+ name: Configure NPM
+ command: |
+ echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
+ - run:
+ name: Build
+ command: |
+ yarn build
+ - run:
+ name: Publish package
+ command: npm publish --tag beta
+
workflows:
- build-and-test:
- jobs:
- - build-and-test
+ version: 2
+ test-deploy:
+ jobs:
+ - test:
+ filters:
+ tags:
+ only: /^v.*/
+ - deploy:
+ requires:
+ - test
+ filters:
+ tags:
+ only: /^v.*/
+ branches:
+ ignore: /.*/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 17a0dba4..e2daaf73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@ $RECYCLE.BIN/
*#
ssl
.idea
+.vscode
nbproject
public/uploads/*
!public/uploads/.gitkeep
@@ -94,8 +95,8 @@ logs
results
node_modules
.node_history
-yarn.lock
package-lock.json
+.yarnrc.yml
############################
@@ -104,6 +105,7 @@ package-lock.json
testApp
coverage
+junit.xml
############################
# Strapi
@@ -112,4 +114,5 @@ coverage
.env
exports
.cache
-build
+dist
+.vscode
diff --git a/.npmignore b/.npmignore
index 784cb629..dd34767f 100644
--- a/.npmignore
+++ b/.npmignore
@@ -9,4 +9,5 @@ codecov.yml
*.spec.*
setup-package.*
**/__tests__/**
-**/__mocks__/**
\ No newline at end of file
+**/__mocks__/**
+migrations/**
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
index 49991d30..3f330989 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v18.14.0
+v20.12.0
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..a6a6978f
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "endOfLine": "lf",
+ "tabWidth": 2,
+ "printWidth": 100,
+ "singleQuote": true,
+ "trailingComma": "es5"
+}
diff --git a/README.md b/README.md
index da3af042..82efbfef 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
Strapi v4 - Navigation plugin
+
Strapi - Navigation plugin
Create consumable navigation with a simple and straightforward visual builder
@@ -30,6 +30,7 @@ Strapi Navigation Plugin provides a website navigation / menu builder feature fo
- Tree (nested)
- RFR (ready for handling by Redux First Router)
+
### Table of Contents
1. [💎 Versions](#-versions)
2. [✨ Features](#-features)
@@ -54,8 +55,8 @@ Strapi Navigation Plugin provides a website navigation / menu builder feature fo
17. [👨💻 Community support](#-community-support)
## 💎 Versions
-- **Strapi v5** - [v3.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/v5)
-- **Strapi v4** - (current) [v2.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation)
+- **Strapi v5** - (current) [v3.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation)
+- **Strapi v4** - [v2.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/strapi-v4)
- **Strapi v3** - [v1.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/strapi-v3)
## ✨ Features
@@ -70,14 +71,9 @@ Strapi Navigation Plugin provides a website navigation / menu builder feature fo
- **Customizable:** Possibility to customize the options like: available Content Types, Maximum level for "attach to menu", Additional fields (audience)
- **[Audit log](https://github.com/VirtusLab/strapi-molecules/tree/master/packages/strapi-plugin-audit-log):** integration with Strapi Molecules Audit Log plugin that provides changes track record
-## ⚙️ Versions
-
-- **Strapi v4** - (current) - [v2.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation)
-- **Strapi v3** - [v1.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/strapi-v3)
-
## ⏳ Installation
-### Via Strapi Markerplace
+### Via Strapi Marketplace
As a ✅ **verified** plugin by Strapi team we're available on the [**Strapi Marketplace**](https://market.strapi.io/plugins/strapi-plugin-navigation) as well as **In-App Marketplace** where you can follow the installation instructions.
@@ -90,7 +86,7 @@ As a ✅ **verified** plugin by Strapi team we're available on the [**Strapi Mar
It's recommended to use **yarn** to install this plugin within your Strapi project. [You can install yarn with these docs](https://yarnpkg.com/lang/en/docs/install/).
```bash
-yarn add strapi-plugin-navigation@latest
+yarn add strapi-plugin-navigation@beta
```
After successful installation you've to re-build your Strapi instance. To archive that simply use:
@@ -100,12 +96,6 @@ yarn build
yarn develop
```
-or just run Strapi in the development mode with `--watch-admin` option:
-
-```bash
-yarn develop --watch-admin
-```
-
The **UI Navigation** plugin should appear in the **Plugins** section of Strapi sidebar after you run app again.
You can manage your multiple navigation containers by going to the **Navigation** manage view by clicking "Manage" button.
@@ -124,20 +114,18 @@ Complete installation requirements are exact same as for Strapi itself and can b
**Supported Strapi versions**:
-- Strapi v4.25.11 (recently tested)
-- Strapi v4.x
+- Strapi v5.5.1 (recently tested)
+- Strapi v5.x
-> This plugin is designed for **Strapi v4** and is not working with v3.x. To get version for **Strapi v3** install version [v1.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/strapi-v3).
+> This plugin is designed for **Strapi v5** and is not working with v4.x. To get version for **Strapi v4** install version [v4.x](https://github.com/VirtusLab-Open-Source/strapi-plugin-navigation/tree/strapi-v4).
**We recommend always using the latest version of Strapi to start your new projects**.
## 🔧 Configuration
-To start your journey with **Navigation plugin** you must first setup it using the dedicated Settings page (`v2.0.3` and newer) or for any version, put your configuration in `config/plugins.js`. Anyway we're recommending the click-through option where your configuration is going to be properly validated.
-
-### In `v2.0.3` and newer
+To start your journey with **Navigation plugin** you must first setup it using the dedicated Settings page or for any version, put your configuration in `config/plugins.js`. Anyway we're recommending the click-through option where your configuration is going to be properly validated.
-Version `2.0.3` introduces the intuitive **Settings** page which you can easily access via `Strapi Settings -> Section: Navigation Plugin -> Configuration`.
+### Settings page
On the dedicated page, you will be able to set up all crucial properties which drive the plugin and customize each individual collection for which **Navigation plugin** should be enabled.
@@ -148,15 +136,10 @@ On the dedicated page, you will be able to set up all crucial properties which d
> *Note*
> The default configuration for your plugin is fetched from `config/plugins.js` or, if the file is not there, directly from the plugin itself. If you would like to customize the default state to which you might revert, please follow the next section.
-### In `v2.0.2` and older + default configuration state for `v2.0.3` and newer
+### File
Config for this plugin is stored as a part of the `config/plugins.js` or `config//plugins.js` file. You can use the following snippet to make sure that the config structure is correct. If you've got already configurations for other plugins stores by this way, you can use the `navigation` along with them.
-> *Note v2.0.3 and newer only*
-> Changing this file will not automatically change plugin configuration. To synchronize plugin's config with plugins.js file, it is necessary to restore configuration through the settings page
-
-> *Note for newer than v2.2.0*
-> `slugify` as been removed. **THIS A BREAKING CHANGE**
```js
module.exports = ({ env }) => ({
@@ -194,7 +177,7 @@ Config for this plugin is stored as a part of the `config/plugins.js` or `config
### Additional Fields
It is advised to configure additional fields through the plugin's Settings Page. There you can find the table of custom fields and toggle input for the audience field. When enabled, the audience field can be customized through the content manager. Custom fields can be added, edited, toggled, and removed with the use of the table provided on the Settings Page. When removing custom fields be advised that their values in navigation items will be lost. Disabling the custom fields will not affect the data and can be done with no consequence of loosing information.
-Creating configuration for additional fields with the `config.js` file should be done with caution. Config object contains the `additionalFields` property of type `Array`, where CustomField is of type `{ type: 'string' | 'boolean' | { "name": string, "url": string, "mime": string, "width": number, "height": number, "previewUrl": string }, name: string, label: string }`. When creating custom fields be advised that the `name` property has to be unique. When editing a custom field it is advised not to edit its `name` and `type` properties. After config has been restored the custom fields that are not present in `config.js` file will be deleted and their values in navigation items will be lost.
+Creating configuration for additional fields with the `config.(js|ts)` file should be done with caution. Config object contains the `additionalFields` property of type `Array`, where CustomField is of type `{ type: 'string' | 'boolean' | { "name": string, "url": string, "mime": string, "width": number, "height": number, "previewUrl": string }, name: string, label: string }`. When creating custom fields be advised that the `name` property has to be unique. When editing a custom field it is advised not to edit its `name` and `type` properties. After config has been restored the custom fields that are not present in `config.js` file will be deleted and their values in navigation items will be lost.
## 🔧 GQL Configuration
Using navigation with GraphQL requires both plugins to be installed and working. You can find installation guide for GraphQL plugin **[here](https://docs.strapi.io/developer-docs/latest/plugins/graphql.html#graphql)**. To properly configure GQL to work with navigation you should provide `gql` prop. This should contain union types that will be used to define GQL response format for your data while fetching:
@@ -227,15 +210,7 @@ where `Page` and `UploadFile` are your type names for the **Content Types** you'
## 🌍 i18n Internationalization
-### Settings
-
-This feature is **opt-in**.
-
-In order to use this functionality setting **default locale** is required. (See: Settings -> Internationalization)
-
-Once feature is enabled a restart is required. On server startup missing navigations for other locales will be created. From then you can manage navigation's localizations just like before.
-
-If you want go back to _pre-i18n_ way you can disable it in settings. Already created navigations will not be removed unless you make a choice for plugin to do so(this will require a restart).
+On server startup missing navigations for other locales will be created. From then you can manage navigation's localizations just like before.
If your newly created navigation localization is empty you can copy contents of one version's to the empty one. If related item is localized and locale version exists localization will be used as a related item. Otherwise plugin will fallback to an original item.
@@ -340,7 +315,6 @@ Is applied for **Public API** both for REST and GraphQL. You can manage is by tw
{
"id": "News",
"title": "News",
- "templateName": "pages:1",
"related": {
"contentType": "page",
"collectionName": "pages",
@@ -375,10 +349,6 @@ Plugin supports both **REST API** and **GraphQL API** exposed by Strapi.
### REST API
-> **Important!**
-> Version `v2.0.13` introduced breaking change!
-> All responses have changed their structure. Related field will now be of type ContentType instead of Array\
-
`GET /api/navigation/?locale=&orderBy=&orderDirection=`
NOTE: All params are optional
@@ -396,7 +366,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:45:54.399Z",
"updatedAt": "2023-09-29T13:44:08.702Z",
- "localeCode": "pl"
+ "locale": "pl"
},
{
"id": 384,
@@ -405,7 +375,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:45:54.399Z",
"updatedAt": "2023-09-29T13:44:08.725Z",
- "localeCode": "fr"
+ "locale": "fr"
},
{
"id": 382,
@@ -414,7 +384,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:45:54.173Z",
"updatedAt": "2023-09-29T13:44:08.747Z",
- "localeCode": "en"
+ "locale": "en"
},
{
"id": 374,
@@ -423,7 +393,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:22:30.373Z",
"updatedAt": "2023-09-29T13:44:08.631Z",
- "localeCode": "pl"
+ "locale": "pl"
},
{
"id": 375,
@@ -432,7 +402,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:22:30.373Z",
"updatedAt": "2023-09-29T13:44:08.658Z",
- "localeCode": "fr"
+ "locale": "fr"
},
{
"id": 373,
@@ -441,7 +411,7 @@ NOTE: All params are optional
"visible": true,
"createdAt": "2023-09-29T12:22:30.356Z",
"updatedAt": "2023-09-29T13:44:08.680Z",
- "localeCode": "en"
+ "locale": "en"
}
]
```
@@ -527,7 +497,6 @@ Return a rendered navigation structure depends on passed type (`TREE`, `RFR` or
"News": {
"id": "News",
"title": "News",
- "templateName": "pages:1",
"related": {
"contentType": "page",
"collectionName": "pages",
@@ -541,7 +510,6 @@ Return a rendered navigation structure depends on passed type (`TREE`, `RFR` or
"Community": {
"id": "Community",
"title": "Community",
- "templateName": "pages:2",
"related": {
"contentType": "page",
"collectionName": "pages",
@@ -555,7 +523,6 @@ Return a rendered navigation structure depends on passed type (`TREE`, `RFR` or
"Highlights": {
"id": "Highlights",
"title": "Highlights",
- "templateName": "pages:3",
"related": {
"contentType": "page",
"collectionName": "pages",
@@ -700,14 +667,6 @@ query {
}
```
-### Template name
-
-Depending on a content type `templateName` will be resolved differently
-
-For collection types it will be read from content type's attribute name `template` holding a component which definition has option named `templateName`.
-
-For single types a global name of this content type will be used as a template name or it can be set manually with an option named `templateName`.
-
## 🔌 Extensions
### Slug generation
@@ -819,27 +778,34 @@ If you already got it, make sure that `navigation` plugin is inserted before `gr
Feel free to fork and make a Pull Request to this plugin project. All the input is warmly welcome!
-- Clone repository
+1. Clone repository
+
+ ```
+ git clone git@github.com:VirtusLab-Open-Source/strapi-plugin-navigation.git
+ ```
- ```
- git clone git@github.com:VirtusLab-Open-Source/strapi-plugin-navigation.git
- ```
+2. Run `install` & `watch:link` command
-- Create a soft link in your strapi project to plugin build folder
+ ```ts
+ // Install all dependencies
+ yarn install
- ```sh
- ln -s <...>/strapi-plugin-navigation/build <...>/strapi-project/src/plugins/navigation
- ```
+ // Watch for file changes using `plugin-sdk` and follow the instructions provided by this official Strapi developer tool
+ yarn watch:link
+ ```
-- Run build command
+3. Within the Strapi project, modify `config/plugins.{js,ts}` for `imgix`
- ```ts
- // Watch for file changes
- yarn develop
+```ts
+//...
+'navigation': {
+ enabled: true,
+ //...
+}
+//...
+```
- // or run build without nodemon
- yarn build:dev
- ```
+4. Run your Strapi instance
## 👨💻 Community support
diff --git a/admin/custom.d.ts b/admin/custom.d.ts
new file mode 100644
index 00000000..f5d1b284
--- /dev/null
+++ b/admin/custom.d.ts
@@ -0,0 +1,2 @@
+declare module '@strapi/design-system/*';
+declare module '@strapi/design-system';
diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts
new file mode 100644
index 00000000..f5952910
--- /dev/null
+++ b/admin/src/api/client.ts
@@ -0,0 +1,192 @@
+import { getFetchClient } from '@strapi/strapi/admin';
+import { once } from 'lodash';
+import { strapiContentTypeSchema } from '../schemas';
+import {
+ NavigationPluginConfigSchema,
+ NavigationSchema,
+ configFromServerSchema,
+ i18nCopyItemDetails,
+ localeSchema,
+ navigationSchema,
+ slugifyResult,
+ strapiContentTypeItemSchema,
+} from './validators';
+
+const URL_PREFIX = 'navigation';
+
+export type ApiClient = ReturnType;
+
+export const getApiClient = once((fetch: ReturnType) => ({
+ getIndexPrefix() {
+ return [URL_PREFIX];
+ },
+
+ readAll() {
+ return fetch.get(`/${URL_PREFIX}`).then(({ data }) => navigationSchema.array().parse(data));
+ },
+ readAllIndex() {
+ return [URL_PREFIX, 'navigations'];
+ },
+
+ delete(documentId: string) {
+ return fetch.del(`/${URL_PREFIX}/${documentId}`);
+ },
+
+ create(body: Omit) {
+ return fetch.post(`/${URL_PREFIX}/`, body);
+ },
+
+ update(body: NavigationSchema) {
+ return fetch.put(`/${URL_PREFIX}/${body.documentId}`, body);
+ },
+
+ purge({ documentId, withLangVersions }: { documentId?: string; withLangVersions?: boolean }) {
+ return fetch.del(
+ `/${URL_PREFIX}/cache/purge/${documentId ?? ''}?clearLocalisations=${!!withLangVersions}`
+ );
+ },
+
+ slugify(query: string) {
+ const queryParams = new URLSearchParams();
+
+ queryParams.append('q', query);
+
+ return fetch
+ .get(`/${URL_PREFIX}/slug?${queryParams.toString()}`)
+ .then(({ data }) => slugifyResult.parse(data))
+ .then(({ slug }) => slug);
+ },
+
+ readConfig() {
+ return fetch
+ .get(`/${URL_PREFIX}/config`)
+ .then(({ data }) => configFromServerSchema.parse(data));
+ },
+ readConfigIndex() {
+ return [URL_PREFIX, 'config'];
+ },
+
+ healthCheck() {
+ return fetch
+ .get(`/_health`);
+ },
+
+ healthCheckIndex() {
+ return ['health'];
+ },
+
+ readNavigationItemFromLocale({
+ source,
+ structureId,
+ target,
+ documentId,
+ }: {
+ source: string;
+ target: string;
+ documentId: string;
+ structureId: string;
+ }) {
+ return fetch.get(
+ `/${URL_PREFIX}/i18n/item/read/${documentId}/${source}/${target}?path=${structureId}`
+ );
+ },
+
+ updateConfig(
+ body: Omit
+ ) {
+ return fetch.put(`/${URL_PREFIX}/config`, body).then(() => {});
+ },
+
+ restart() {
+ return fetch.get(`/${URL_PREFIX}/settings/restart`).then(() => {});
+ },
+
+ restoreConfig() {
+ return fetch.del(`/${URL_PREFIX}/config`).then(() => {});
+ },
+
+ readSettingsConfig() {
+ return fetch.get(`/${URL_PREFIX}/settings/config`).then(({ data }) => {
+ const fromServer = configFromServerSchema.parse(data);
+
+ return {
+ ...fromServer,
+ contentTypes: fromServer.contentTypes.map(({ uid }) => uid),
+ };
+ });
+ },
+ readSettingsConfigIndex() {
+ return [URL_PREFIX, 'config'];
+ },
+
+ readContentType() {
+ return fetch
+ .get(`/content-manager/content-types`)
+ .then(({ data }) => strapiContentTypeSchema.array().parse(data.data));
+ },
+ readContentTypeIndex() {
+ return [URL_PREFIX, 'content-manager', 'content-types'];
+ },
+
+ readContentTypeItems({ uid, locale, query }: { uid: string; locale?: string; query?: string }) {
+ const queryParams = new URLSearchParams();
+
+ if (query) {
+ queryParams.append('_q', query);
+ }
+
+ if (locale) {
+ queryParams.append('locale', locale);
+ }
+
+ return fetch
+ .get(`/${URL_PREFIX}/content-type-items/${uid}?${queryParams.toString()}`)
+ .then(({ data }) => strapiContentTypeItemSchema.array().parse(data));
+ },
+ readContentTypeItemsIndex({
+ uid,
+ locale,
+ query,
+ }: {
+ uid: string;
+ locale?: string;
+ query?: string;
+ }) {
+ return [URL_PREFIX, 'content-manager', 'content-type-items', uid, locale, query];
+ },
+
+ readLocale() {
+ return fetch
+ .get(`/${URL_PREFIX}/settings/locale`)
+ .then((data) => localeSchema.parse(data.data));
+ },
+ readLocaleIndex() {
+ return [URL_PREFIX, 'locale'];
+ },
+
+ copyNavigationLocale({
+ documentId,
+ source,
+ target,
+ }: {
+ source: string;
+ target: string;
+ documentId: string;
+ }) {
+ return fetch.put(`/${URL_PREFIX}/i18n/copy/${documentId}/${source}/${target}`);
+ },
+
+ copyNavigationItemLocale({
+ source,
+ structureId = '',
+ target,
+ }: {
+ source: string;
+ target: string;
+ structureId?: string;
+ }) {
+ return fetch
+ .get(`/${URL_PREFIX}/i18n/item/read/${source}/${target}?path=${structureId}`)
+ .then((data) => i18nCopyItemDetails.parse(data.data));
+ },
+}));
diff --git a/admin/src/api/index.ts b/admin/src/api/index.ts
new file mode 100644
index 00000000..4f1cce44
--- /dev/null
+++ b/admin/src/api/index.ts
@@ -0,0 +1 @@
+export * from './client';
diff --git a/admin/src/api/validators.ts b/admin/src/api/validators.ts
new file mode 100644
index 00000000..aef6b385
--- /dev/null
+++ b/admin/src/api/validators.ts
@@ -0,0 +1,325 @@
+import * as z from 'zod';
+
+export type NavigationPluginConfigSchema = z.infer;
+
+export type AudienceDBSchema = z.infer;
+export const audienceDBSchema = z.object({
+ id: z.number(),
+ documentId: z.string(),
+ name: z.string(),
+ key: z.string(),
+});
+
+export type NavigationItemTypeSchema = z.infer;
+export const navigationItemTypeSchema = z.enum(['INTERNAL', 'EXTERNAL', 'WRAPPER']);
+
+const navigationItemBaseSchema = z.object({
+ id: z.number(),
+ documentId: z.string(),
+ title: z.string(),
+ type: navigationItemTypeSchema,
+ path: z.string(),
+ externalPath: z.string().or(z.null()).optional(),
+ uiRouterKey: z.string(),
+ menuAttached: z.boolean(),
+ order: z.number().int(),
+ collapsed: z.boolean(),
+ autoSync: z.boolean().or(z.null()).optional(),
+ related: z
+ .object({ documentId: z.string().optional(), __type: z.string() })
+ .catchall(z.unknown())
+ .nullish(),
+ additionalFields: z.record(z.string(), z.unknown()).or(z.null()).optional(),
+ audience: z.array(audienceDBSchema).or(z.null()).optional(),
+ viewId: z.number().optional(),
+ viewParentId: z.number().optional(),
+ structureId: z.string().optional(),
+ removed: z.boolean().optional(),
+ isSearchActive: z.boolean().optional(),
+ updated: z.boolean().optional(),
+});
+
+export type NavigationItemSchema = z.infer & {
+ items?: NavigationItemSchema[] | null;
+};
+export const navigationItemSchema: z.ZodType =
+ navigationItemBaseSchema.extend({
+ items: z.lazy(() => navigationItemSchema.array().or(z.null())).optional(),
+ });
+
+export type NavigationSchema = z.infer;
+export const navigationSchema = z.object({
+ id: z.number(),
+ documentId: z.string(),
+ name: z.string(),
+ slug: z.string(),
+ locale: z.string(),
+ visible: z.boolean(),
+ items: z.array(navigationItemSchema),
+});
+
+const navigationCustomFieldBase = z.object({
+ // TODO: Proper message translation
+ name: z.string().refine((current) => !current.includes(' '), { message: 'No space allowed' }),
+ label: z.string(),
+ required: z.boolean().optional(),
+ enabled: z.boolean().optional(),
+});
+
+export type NavigationItemCustomFieldSelect = z.infer;
+const navigationItemCustomFieldSelect = navigationCustomFieldBase.extend({
+ type: z.literal('select'),
+ multi: z.boolean(),
+ options: z.array(z.string()),
+});
+
+export type NavigationItemCustomFieldPrimitive = z.infer;
+const navigationItemCustomFieldPrimitive = navigationCustomFieldBase.extend({
+ type: z.enum(['boolean', 'string']),
+ multi: z.literal(false).optional(),
+ options: z.array(z.string()).max(0).optional(),
+});
+
+export type NavigationItemCustomFieldMedia = z.infer;
+const navigationItemCustomFieldMedia = navigationCustomFieldBase.extend({
+ type: z.literal('media'),
+ multi: z.literal(false).optional(),
+ options: z.array(z.string()).max(0).optional(),
+});
+
+export type NavigationItemCustomField = z.infer;
+export const navigationItemCustomField = navigationItemCustomFieldPrimitive
+ .or(navigationItemCustomFieldMedia)
+ .or(navigationItemCustomFieldSelect);
+
+export type NavigationItemAdditionalField = z.infer;
+export const navigationItemAdditionalField = z.union([
+ z.literal('audience'),
+ navigationItemCustomField,
+]);
+
+export const configContentTypeSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ draftAndPublish: z.boolean(),
+ isSingle: z.boolean(),
+ description: z.string(),
+ collectionName: z.string(),
+ contentTypeName: z.string(),
+ label: z.string(),
+ labelSingular: z.string(),
+ endpoint: z.string(),
+ available: z.boolean(),
+ visible: z.boolean(),
+});
+
+export const configSchema = z.object({
+ additionalFields: z.array(navigationItemAdditionalField),
+ allowedLevels: z.number(),
+ availableAudience: z
+ .object({
+ id: z.number(),
+ documentId: z.string(),
+ name: z.string(),
+ key: z.string(),
+ })
+ .array(),
+ contentTypes: z.array(z.string()),
+ contentTypesNameFields: z.record(z.string(), z.array(z.string())),
+ contentTypesPopulate: z.record(z.string(), z.array(z.string())),
+ gql: z.object({
+ navigationItemRelated: z.array(z.string()),
+ }),
+ pathDefaultFields: z.record(z.string(), z.string().array()),
+ cascadeMenuAttached: z.boolean(),
+ preferCustomContentTypes: z.boolean(),
+ allowedContentTypes: z.string().array(),
+ restrictedContentTypes: z.string().array(),
+ isCacheEnabled: z.boolean().optional(),
+ isCachePluginEnabled: z.boolean().optional(),
+});
+
+export type ConfigFromServerSchema = z.infer;
+export const configFromServerSchema = configSchema
+ .omit({
+ contentTypes: true,
+ })
+ .extend({
+ contentTypes: configContentTypeSchema.array(),
+ });
+
+export const localeSchema = z.object({
+ defaultLocale: z.string(),
+ restLocale: z.string().array(),
+});
+
+export type ContentType = z.infer;
+export const contentType = z.enum(['collectionType', 'singleType']);
+
+export type ContentTypeInfo = z.infer;
+export const contentTypeInfo = z.object({
+ singularName: z.string(),
+ pluralName: z.string(),
+ displayName: z.string(),
+ description: z.string().optional(),
+ name: z.string().optional(),
+});
+
+export type ContentTypeAttributeValidator = z.infer;
+export const contentTypeAttributeValidator = z.object({
+ required: z.boolean().optional(),
+ max: z.number().optional(),
+ min: z.number().optional(),
+ minLength: z.number().optional(),
+ maxLength: z.number().optional(),
+ private: z.boolean().optional(),
+ configurable: z.boolean().optional(),
+ default: z.any().optional(),
+});
+
+export type contentTypeFieldTypeSchema = z.infer;
+export const contentTypeFieldTypeSchema = z.enum([
+ 'string',
+ 'text',
+ 'richtext',
+ 'blocks',
+ 'email',
+ 'password',
+ 'date',
+ 'time',
+ 'datetime',
+ 'timestamp',
+ 'boolean',
+ 'integer',
+ 'biginteger',
+ 'float',
+ 'decimal',
+ 'json',
+ 'relation',
+]);
+
+export type SimpleContentTypeAttribute = z.infer;
+export const simpleContentTypeAttribute = contentTypeAttributeValidator.extend({
+ type: contentTypeFieldTypeSchema,
+});
+
+export type ContentTypeEnumerationAttribute = z.infer;
+export const contentTypeEnumerationAttribute = contentTypeAttributeValidator.extend({
+ type: z.literal('enumeration'),
+ enum: z.string().array(),
+});
+
+export type ContentTypeComponentAttribute = z.infer;
+export const contentTypeComponentAttribute = z.object({
+ type: z.literal('component'),
+ component: z.string(),
+ repeatable: z.boolean().optional(),
+});
+
+export type ContentTypeDynamicZoneAttribute = z.infer;
+export const contentTypeDynamicZoneAttribute = z.object({
+ type: z.literal('dynamiczone'),
+ components: z.string().array(),
+});
+
+export type ContentTypeMediaAttribute = z.infer;
+export const contentTypeMediaAttribute = z.object({
+ media: z.literal('media'),
+ allowedTypes: z.enum(['images', 'videos', 'files']).array(),
+ required: z.boolean().optional(),
+});
+
+export type ContentTypeRelationType = z.infer;
+export const contentTypeRelationType = z.enum([
+ 'oneToOne',
+ 'oneToMany',
+ 'manyToOne',
+ 'manyToMany',
+ 'morphToMany',
+ 'manyToMorph',
+]);
+
+export type ContentTypeRelationAttribute = z.infer;
+export const contentTypeRelationAttribute = z.object({
+ type: z.literal('relation'),
+ relation: contentTypeRelationType,
+ target: z.string(),
+ mappedBy: z.string().optional(),
+ inversedBy: z.string().optional(),
+});
+
+export type ContentTypeAttributes = z.infer;
+export const contentTypeAttributes = z.record(
+ z.string(),
+ z.union([
+ simpleContentTypeAttribute,
+ contentTypeEnumerationAttribute,
+ contentTypeComponentAttribute,
+ contentTypeDynamicZoneAttribute,
+ contentTypeRelationAttribute,
+ contentTypeMediaAttribute,
+ ])
+);
+
+export type ContentTypeFullSchema = z.infer;
+export const contentTypeFullSchema = z.object({
+ kind: contentType,
+ collectionName: z.string(),
+ info: contentTypeInfo,
+ options: z
+ .object({
+ draftAndPublish: z.boolean().optional(),
+ hidden: z.boolean().optional(),
+ templateName: z.string().optional(),
+ })
+ .optional(),
+ attributes: contentTypeAttributes,
+ actions: z.record(z.string(), z.any()).optional(),
+ lifecycles: z.record(z.string(), z.any()).optional(),
+ uid: z.string(),
+ apiName: z.string().optional(),
+
+ // TODO?: remove
+ associations: z
+ .object({
+ model: z.string(),
+ alias: z.string(),
+ })
+ .array()
+ .optional(),
+ modelName: z.string().optional(),
+ plugin: z.string().optional(),
+ pluginOptions: z.record(z.string(), z.any()).optional(),
+ isSingle: z.boolean().optional(),
+});
+
+export type ContentTypeSchema = z.infer;
+export const contentTypeSchema = contentTypeFullSchema.pick({
+ info: true,
+ kind: true,
+ attributes: true,
+ options: true,
+});
+
+export type StrapiContentTypeItemSchema = z.infer;
+export const strapiContentTypeItemSchema = z
+ .object({
+ id: z.number(),
+ documentId: z.string(),
+ locale: z.string().or(z.null()).optional(),
+ })
+ .and(z.record(z.string(), z.any()));
+
+export const slugifyResult = z.object({ slug: z.string() });
+
+export const i18nCopyItemDetails = z.object({
+ externalPath: z.string().or(z.null()).optional(),
+ path: z.string().or(z.null()).optional(),
+ related: z
+ .object({ documentId: z.string().optional(), __type: z.string() })
+ .catchall(z.unknown())
+ .nullish(),
+ title: z.string(),
+ type: navigationItemTypeSchema,
+ uiRouterKey: z.string(),
+});
diff --git a/admin/src/components/AdditionalFieldInput/index.tsx b/admin/src/components/AdditionalFieldInput/index.tsx
deleted file mode 100644
index 0c76a1bc..00000000
--- a/admin/src/components/AdditionalFieldInput/index.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import React, { BaseSyntheticEvent, useEffect, useMemo } from "react";
-import { ToBeFixed, assertBoolean, assertString } from "../../../../types";
-//@ts-ignore
-import { ToggleInput } from "@strapi/design-system/ToggleInput";
-//@ts-ignore
-import { TextInput } from "@strapi/design-system/TextInput";
-//@ts-ignore
-import { Select, Option } from "@strapi/design-system/Select";
-//@ts-ignore
-import { useNotification, useLibrary } from "@strapi/helper-plugin";
-import { getTrad } from "../../translations";
-import { AdditionalFieldInputProps, Input } from "./types";
-import { isNil } from "lodash";
-import { useIntl } from "react-intl";
-
-const DEFAULT_STRING_VALUE = "";
-const handlerFactory =
- ({ field, prop, onChange }: Input) =>
- ({ target }: BaseSyntheticEvent) => {
- onChange(field.name, target[prop], field.type);
- };
-
-const mediaAttribute = {
- type: "media",
- multiple: false,
- required: false,
- allowedTypes: ["images"],
- pluginOptions: {
- i18n: {
- localized: false,
- },
- },
-};
-
-const AdditionalFieldInput: React.FC = ({
- field,
- isLoading,
- onChange,
- value: baseValue,
- disabled,
- error,
-}) => {
- const { fields } = useLibrary();
- const value = useMemo(
- () =>
- field.type === "media" && baseValue
- ? JSON.parse(baseValue as string)
- : baseValue,
- [baseValue, field.type]
- );
- const toggleNotification = useNotification();
- const { formatMessage } = useIntl();
- const defaultInputProps = useMemo(
- () => ({
- id: field.name,
- name: field.name,
- label: field.label,
- disabled: isLoading || disabled,
- error: error && formatMessage(error),
- }),
- [field, isLoading, error]
- );
- const handleBoolean = useMemo(
- () => handlerFactory({ field, onChange, prop: "checked" }),
- [onChange, field]
- );
- const handleString = useMemo(
- () => handlerFactory({ field, onChange, prop: "value" }),
- [onChange, field]
- );
- const handleMedia = useMemo(
- () => handlerFactory({ field, onChange, prop: "value" }),
- [onChange, field]
- );
- const MediaInput = (fields?.media ??
- (() => <>>)) as React.ComponentType;
-
- useEffect(() => {
- if (!MediaInput) {
- toggleNotification({
- type: "warning",
- message: getTrad("notification.error.customField.media.missing"),
- });
- }
- }, []);
-
- switch (field.type) {
- case "boolean":
- if (!isNil(value)) assertBoolean(value);
- return (
-
- );
- case "string":
- if (!isNil(value)) assertString(value);
- return (
-
- );
- case "select":
- return (
-
- );
- case "media":
- return (
-
- );
- default:
- toggleNotification({
- type: "warning",
- message: getTrad("notification.error.customField.type"),
- });
- throw new Error(`Type of custom field is unsupported`);
- }
-};
-
-export default AdditionalFieldInput;
diff --git a/admin/src/components/AdditionalFieldInput/types.ts b/admin/src/components/AdditionalFieldInput/types.ts
deleted file mode 100644
index a97319ee..00000000
--- a/admin/src/components/AdditionalFieldInput/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { MessageDescriptor } from "react-intl";
-import { NavigationItemCustomField } from "../../../../types";
-
-export type AdditionalFieldInputProps = {
- field: NavigationItemCustomField;
- isLoading: boolean;
- onChange: (name: string, value: string, fieldType: string) => void;
- value: string | boolean | string[] | null;
- disabled: boolean;
- error: MessageDescriptor | null;
-}
-export type TargetProp = "value" | "checked";
-export type Input = {
- prop: TargetProp;
-} & Pick;
diff --git a/admin/src/components/Alert/styles.js b/admin/src/components/Alert/styles.js
deleted file mode 100644
index a2dc959c..00000000
--- a/admin/src/components/Alert/styles.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import styled from 'styled-components';
-import { Alert } from '@strapi/design-system/Alert';
-
-export const PermanentAlert = styled(Alert)`
- button {
- display: none;
- }
-`;
diff --git a/admin/src/components/CollapseButton/index.js b/admin/src/components/CollapseButton/index.js
deleted file mode 100644
index 84ef0f33..00000000
--- a/admin/src/components/CollapseButton/index.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import styled from 'styled-components'
-import { Flex } from '@strapi/design-system/Flex';
-import { Typography } from '@strapi/design-system/Typography';
-import { Icon } from '@strapi/design-system/Icon';
-import { CarretUp, CarretDown } from '@strapi/icons';
-
-const Wrapper = styled.div`
- border-radius: 50%;
- background: #DCDCE4;
- width: 25px;
- height: 25px;
- display: flex;
- justify-content: center;
- align-items: center;
- margin-right: 8px;
-`;
-
-const CollapseButton = ({ toggle, collapsed, itemsCount }) => (
-
-
- { collapsed ?
- :
-
- }
-
- {itemsCount} nested items
-
-);
-
-export default CollapseButton;
diff --git a/admin/src/components/ConfirmationDialog/index.js b/admin/src/components/ConfirmationDialog/index.js
deleted file mode 100644
index d22f0a9b..00000000
--- a/admin/src/components/ConfirmationDialog/index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- *
- * Entity Details
- *
- */
-
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Button } from '@strapi/design-system/Button';
-import { Dialog, DialogBody, DialogFooter } from '@strapi/design-system/Dialog';
-import { Flex } from '@strapi/design-system/Flex';
-import { Stack } from '@strapi/design-system/Stack';
-import { Typography } from '@strapi/design-system/Typography';
-import { ExclamationMarkCircle, Check } from '@strapi/icons';
-import { getMessage } from '../../utils';
-
-const DEFAULT_ICON =
-
-const ConfirmationDialog = ({
- isVisible = false,
- isActionAsync = false,
- children,
- onConfirm,
- onCancel,
- header,
- labelCancel,
- labelConfirm,
- iconConfirm,
- mainIcon = DEFAULT_ICON
-}) => (
-
-);
-
-ConfirmationDialog.propTypes = {
- isVisible: PropTypes.bool,
- isActionAsync: PropTypes.bool,
- children: PropTypes.any,
- header: PropTypes.string,
- labelCancel: PropTypes.string,
- labelConfirm: PropTypes.string,
- iconConfirm: PropTypes.object,
- onConfirm: PropTypes.func.isRequired,
- onCancel: PropTypes.func.isRequired,
-};
-
-export default ConfirmationDialog;
\ No newline at end of file
diff --git a/admin/src/components/ConfirmationDialog/index.tsx b/admin/src/components/ConfirmationDialog/index.tsx
new file mode 100644
index 00000000..903f57e0
--- /dev/null
+++ b/admin/src/components/ConfirmationDialog/index.tsx
@@ -0,0 +1,84 @@
+/**
+ *
+ * Entity Details
+ *
+ */
+
+import { Button, Dialog, Flex, Typography } from '@strapi/design-system';
+import { Check, WarningCircle } from '@strapi/icons';
+import { FC, PropsWithChildren, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import { getTrad } from '../../translations';
+import { Effect } from '../../types';
+
+const DEFAULT_ICON = ;
+
+interface Props {
+ isVisible?: boolean;
+ isActionAsync?: boolean;
+ onConfirm: Effect;
+ onCancel: Effect;
+ header?: ReactNode;
+ labelCancel?: ReactNode;
+ labelConfirm?: ReactNode;
+ iconConfirm?: ReactNode;
+ mainIcon?: ReactNode;
+}
+
+export const ConfirmationDialog: FC> = ({
+ isVisible = false,
+ isActionAsync = false,
+ children,
+ onConfirm,
+ onCancel,
+ header,
+ labelCancel,
+ labelConfirm,
+ iconConfirm,
+ mainIcon = DEFAULT_ICON,
+}) => {
+ const { formatMessage } = useIntl();
+
+ return isVisible ? (
+ {
+ if (!isOpen && isVisible) {
+ onCancel?.(undefined);
+ }
+ }}
+ title={
+ header || formatMessage(getTrad('components.confirmation.dialog.header', 'Confirmation'))
+ }
+ >
+
+
+
+
+ {children || formatMessage(getTrad('components.confirmation.dialog.description'))}
+
+
+
+
+
+
+
+
+ }
+ disabled={isActionAsync}
+ >
+ {labelConfirm ||
+ formatMessage(getTrad('components.confirmation.dialog.button.confirm', 'Confirm'))}
+
+
+
+
+ ) : null;
+};
diff --git a/admin/src/components/DragButton/index.tsx b/admin/src/components/DragButton/index.tsx
deleted file mode 100644
index 777f3c23..00000000
--- a/admin/src/components/DragButton/index.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react';
-// @ts-ignore
-import styled from 'styled-components';
-// @ts-ignore
-import { Drag } from '@strapi/icons'
-import { ToBeFixed } from '../../../../types';
-
-const DRAG_BUTTON_SIZE_IN_REM = 2;
-const DragButtonWrapper = styled.span`
- display: flex;
- align-items: center;
- justify-content: center;
-
- height: ${DRAG_BUTTON_SIZE_IN_REM}rem;
- width: ${DRAG_BUTTON_SIZE_IN_REM}rem;
- padding: ${({ theme }: ToBeFixed) => theme.spaces[2]};
-
- background: ${({ theme, isActive }: ToBeFixed) => isActive ? theme.colors.neutral150 : theme.colors.neutral0};
- border: 1px solid ${({ theme }: ToBeFixed) => theme.colors.neutral200};
- border-radius: ${({ theme }: ToBeFixed) => theme.borderRadius};
- cursor: pointer;
- transition: background-color 0.3s ease-in;
-
- svg {
- height: ${({ theme }: ToBeFixed) => theme.spaces[3]};
- width: ${({ theme }: ToBeFixed) => theme.spaces[3]};
-
- > g,
- path {
- fill: ${({ theme }: ToBeFixed) => theme.colors.neutral500};
- }
- }
- &:hover {
- svg {
- > g,
- path {
- fill: ${({ theme }: ToBeFixed) => theme.colors.neutral600};
- }
- }
- }
- &:active {
- svg {
- > g,
- path {
- fill: ${({ theme }: ToBeFixed) => theme.colors.neutral400};
- }
- }
- }
- &[aria-disabled='true'] {
- background-color: ${({ theme }: ToBeFixed) => theme.colors.neutral150};
- svg {
- path {
- fill: ${({ theme }: ToBeFixed) => theme.colors.neutral600};
- }
- }
- }
-`;
-
-const DragButton = React.forwardRef((props, ref) => (
-
-
-
-));
-
-export default DragButton;
\ No newline at end of file
diff --git a/admin/src/components/EmptyView/index.js b/admin/src/components/EmptyView/index.js
deleted file mode 100644
index 7a38e487..00000000
--- a/admin/src/components/EmptyView/index.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import styled from "styled-components";
-import { Box } from '@strapi/design-system/Box';
-import { Button } from "@strapi/design-system/Button";
-
-const EmptyView = styled.div`
- display: flex;
- flex-grow: 1;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding-left: 2rem;
- padding-right: 2rem;
- padding-bottom: 8rem;
-
-
- font-size: 2rem;
- font-weight: 600;
- color: ${({ theme }) => theme.colors.neutral600};
- text-align: center;
-
- > {
- margin: 1rem;
- }
-`;
-
-export default EmptyView;
diff --git a/admin/src/components/Initializer.tsx b/admin/src/components/Initializer.tsx
new file mode 100644
index 00000000..115377a1
--- /dev/null
+++ b/admin/src/components/Initializer.tsx
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from 'react';
+
+import { PLUGIN_ID } from '../pluginId';
+
+type InitializerProps = {
+ setPlugin: (id: string) => void;
+};
+
+const Initializer = ({ setPlugin }: InitializerProps) => {
+ const ref = useRef(setPlugin);
+
+ useEffect(() => {
+ ref.current?.(PLUGIN_ID);
+ }, []);
+
+ return null;
+};
+
+export { Initializer };
diff --git a/admin/src/components/Item/ItemCardBadge/index.js b/admin/src/components/Item/ItemCardBadge/index.js
deleted file mode 100644
index 4ffca44a..00000000
--- a/admin/src/components/Item/ItemCardBadge/index.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import styled from "styled-components";
-import { Badge } from '@strapi/design-system/Badge';
-
-const ItemCardBadge = styled(Badge)`
- border: 1px solid ${({ theme, borderColor }) => theme.colors[borderColor]};
-
- ${ ({small, theme}) => small && `
- padding: ${theme.spaces[1]} ${theme.spaces[2]};
- margin: 0px ${theme.spaces[3]};
- vertical-align: middle;
-
- cursor: default;
-
- span {
- font-size: .65rem;
- line-height: 1;
- vertical-align: middle;
- }
- `}
-`;
-
-export default ItemCardBadge;
\ No newline at end of file
diff --git a/admin/src/components/Item/ItemCardHeader/Wrapper.tsx b/admin/src/components/Item/ItemCardHeader/Wrapper.tsx
deleted file mode 100644
index c15477cd..00000000
--- a/admin/src/components/Item/ItemCardHeader/Wrapper.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-//@ts-ignore
-import styled from "styled-components";
-//@ts-ignore
-import { CardTitle } from '@strapi/design-system/Card';
-import { ToBeFixed } from "../../../../../types";
-
-const CardItemTitle = styled(CardTitle)`
- width: 100%;
-
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
-
- > div > * {
- margin: 0px ${({ theme }: ToBeFixed) => theme.spaces[1]};
- }
-`;
-
-export default CardItemTitle;
diff --git a/admin/src/components/Item/ItemCardHeader/icons.tsx b/admin/src/components/Item/ItemCardHeader/icons.tsx
deleted file mode 100644
index 7df1eb41..00000000
--- a/admin/src/components/Item/ItemCardHeader/icons.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from "react";
-// @ts-ignore
-import { Pencil, Trash, Refresh, Eye } from '@strapi/icons';
-
-export const pencilIcon = ;
-export const refreshIcon = ;
-export const trashIcon = ;
-export const eyeIcon =
diff --git a/admin/src/components/Item/ItemCardHeader/index.tsx b/admin/src/components/Item/ItemCardHeader/index.tsx
deleted file mode 100644
index 284e6474..00000000
--- a/admin/src/components/Item/ItemCardHeader/index.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react';
-//@ts-ignore
-import styled from 'styled-components';
-//@ts-ignore
-import { Flex } from '@strapi/design-system/Flex';
-//@ts-ignore
-import { Typography } from '@strapi/design-system/Typography';
-//@ts-ignore
-import { IconButton as BaseIconButton } from '@strapi/design-system/IconButton';
-//@ts-ignore
-import { Icon } from '@strapi/design-system/Icon';
-//@ts-ignore
-import DragButton from '../../DragButton';
-import Wrapper from './Wrapper';
-import ItemCardBadge from '../ItemCardBadge';
-import { getMessage } from '../../../utils';
-import { ToBeFixed, VoidEffect } from '../../../../../types';
-import { pencilIcon, refreshIcon, trashIcon, eyeIcon } from './icons';
-
-interface IProps {
- title: string,
- path: string,
- icon: ToBeFixed,
- removed: boolean,
- canUpdate: boolean,
- onItemRemove: VoidEffect,
- onItemEdit: VoidEffect,
- onItemRestore: VoidEffect,
- dragRef: React.MutableRefObject,
- isSearchActive?: boolean
-}
-
-const wrapperStyle = { zIndex: 2 };
-const pathWrapperStyle = { maxWidth: "425px" };
-
-const ItemCardHeader: React.FC = ({ title, path, icon, removed, canUpdate, onItemRemove, onItemEdit, onItemRestore, dragRef, isSearchActive }) => (
-
-
- {canUpdate && ()}
-
- {title}
-
-
- {path}
-
-
-
-
-
-
- {removed &&
- (
- {getMessage("components.navigationItem.badge.removed")}
- )
- }
-
-
- {canUpdate && (<>{removed ?
- :
-
- }>)}
-
-
-);
-
-const IconButton = styled(BaseIconButton)`
- transition: background-color 0.3s ease-in;
- ${({isActive, theme}: ToBeFixed) => isActive ? `background-color: ${theme.colors.neutral150} ;` : ''}
-`
-
-export default ItemCardHeader;
\ No newline at end of file
diff --git a/admin/src/components/Item/ItemCardRemovedOverlay/index.js b/admin/src/components/Item/ItemCardRemovedOverlay/index.js
deleted file mode 100644
index 0b02598b..00000000
--- a/admin/src/components/Item/ItemCardRemovedOverlay/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import styled from "styled-components";
-
-export const ItemCardRemovedOverlay = styled.div`
- width: 100%;
- height: 100%;
- position: absolute;
- left: 0;
- right: 0;
- z-index: 1;
-
- background: rgba(255,255,255,.75);
-`;
\ No newline at end of file
diff --git a/admin/src/components/Item/Wrapper.js b/admin/src/components/Item/Wrapper.js
deleted file mode 100644
index 9a432622..00000000
--- a/admin/src/components/Item/Wrapper.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import styled from "styled-components";
-
-const Wrapper = styled.div`
-position: relative;
-margin-top: ${({theme}) => theme.spaces[2]};
-margin-left: ${({ level }) => level && '54px'}};
-
-${({ level, theme, isLast }) => level && `
- &::before {
- ${!isLast && 'content: "";'}
- display: block;
- top: ${theme.spaces[1]};
- left: -24px;
- position: absolute;
- height: calc(100% + ${theme.spaces[2]});
- width: 19px;
- border: 0px solid transparent;
- border-left: 4px solid ${theme.colors.neutral300};
- }
-
- &::after {
- content: "";
- display: block;
- height: 22px;
- width: 19px;
- position: absolute;
- top: ${theme.spaces[1]};
- left: -${theme.spaces[6]};
-
- background: transparent;
- border: 4px solid ${theme.colors.neutral300};
- border-top: transparent;
- border-right: transparent;
- border-radius: 0 0 0 100%;
- }
-`};
-`;
-
-export default Wrapper;
\ No newline at end of file
diff --git a/admin/src/components/Item/index.js b/admin/src/components/Item/index.js
deleted file mode 100644
index dd6f7ee5..00000000
--- a/admin/src/components/Item/index.js
+++ /dev/null
@@ -1,278 +0,0 @@
-import React, { useCallback, useEffect, useRef } from 'react';
-import PropTypes from 'prop-types';
-import { useDrag, useDrop } from 'react-dnd';
-import { isEmpty, isNumber } from 'lodash';
-import { useTheme } from 'styled-components';
-
-import { Card, CardBody } from '@strapi/design-system/Card';
-import { Divider } from '@strapi/design-system/Divider';
-import { Flex } from '@strapi/design-system/Flex';
-import { Link } from '@strapi/design-system/Link';
-import { TextButton } from '@strapi/design-system/TextButton';
-import { Typography } from '@strapi/design-system/Typography';
-import { ArrowRight, Link as LinkIcon, Earth, Plus, Cog } from '@strapi/icons';
-
-import ItemCardHeader from './ItemCardHeader';
-import List from '../NavigationItemList';
-import Wrapper from './Wrapper';
-import { extractRelatedItemLabel } from '../../pages/View/utils/parsers';
-import ItemCardBadge from './ItemCardBadge';
-import { ItemCardRemovedOverlay } from './ItemCardRemovedOverlay';
-import { getMessage, ItemTypes, navigationItemType } from '../../utils';
-import CollapseButton from '../CollapseButton';
-
-const Item = (props) => {
- const {
- item,
- isLast = false,
- level = 0,
- levelPath = '',
- allowedLevels,
- relatedRef,
- isParentAttachedToMenu,
- onItemLevelAdd,
- onItemRemove,
- onItemRestore,
- onItemEdit,
- onItemReOrder,
- onItemToggleCollapse,
- error,
- displayChildren,
- config = {},
- permissions = {},
- } = props;
-
- const {
- viewId,
- title,
- type,
- path,
- removed,
- externalPath,
- menuAttached,
- collapsed,
- structureId,
- items = [],
- isSearchActive,
- } = item;
-
- const { contentTypes = [], contentTypesNameFields } = config;
- const isExternal = type === navigationItemType.EXTERNAL;
- const isWrapper = type === navigationItemType.WRAPPER;
- const isHandledByPublishFlow = contentTypes.find(_ => _.uid === relatedRef?.__collectionUid)?.draftAndPublish;
- const isPublished = isHandledByPublishFlow && relatedRef.publishedAt;
- const isNextMenuAllowedLevel = isNumber(allowedLevels) ? level < (allowedLevels - 1) : true;
- const isMenuAllowedLevel = isNumber(allowedLevels) ? level < allowedLevels : true;
- const hasChildren = !isEmpty(item.items) && !isExternal && !displayChildren;
- const absolutePath = isExternal ? undefined : `${levelPath === '/' ? '' : levelPath}/${path === '/' ? '' : path}`;
-
- const relatedItemLabel = !isExternal ? extractRelatedItemLabel(relatedRef, contentTypesNameFields, { contentTypes }) : '';
- const relatedTypeLabel = relatedRef?.labelSingular;
- const relatedBadgeColor = isPublished ? 'success' : 'secondary';
-
- const { canUpdate } = permissions;
-
- const dragRef = useRef(null);
- const dropRef = useRef(null);
- const previewRef = useRef(null);
-
- const [, drop] = useDrop({
- accept: `${ItemTypes.NAVIGATION_ITEM}_${levelPath}`,
- hover(hoveringItem, monitor) {
- const dragIndex = hoveringItem.order;
- const dropIndex = item.order;
-
- // Don't replace items with themselves
- if (dragIndex === dropIndex) {
- return;
- }
-
- const hoverBoundingRect = dropRef.current.getBoundingClientRect();
- const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
- const clientOffset = monitor.getClientOffset();
- const hoverClientY = clientOffset.y - hoverBoundingRect.top;
-
- // Place the hovering item before or after the drop target
- const isAfter = hoverClientY > hoverMiddleY;
- const newOrder = isAfter ? item.order + 0.5 : item.order - 0.5;
-
- if (dragIndex < dropIndex && hoverClientY < hoverMiddleY) {
- return;
- }
- // Dragging upwards
- if (dragIndex > dropIndex && hoverClientY > hoverMiddleY) {
- return;
- }
-
- onItemReOrder({ ...hoveringItem }, newOrder);
- hoveringItem.order = newOrder;
- },
- collect: monitor => ({
- isOverCurrent: monitor.isOver({ shallow: true }),
- })
- });
-
- const [{ isDragging }, drag, dragPreview] = useDrag({
- type: `${ItemTypes.NAVIGATION_ITEM}_${levelPath}`,
- item: () => {
- return item;
- },
- collect: monitor => ({
- isDragging: monitor.isDragging(),
- }),
- });
-
- const refs = {
- dragRef: drag(dragRef),
- dropRef: drop(dropRef),
- previewRef: dragPreview(previewRef),
- }
-
- const contentTypeUid = relatedRef?.__collectionUid;
- const contentType = contentTypes.find(_ => _.uid === contentTypeUid) || {};
- const generatePreviewUrl = entity => {
- const { isSingle } = contentType;
- const entityLocale = entity?.locale ? `?plugins[i18n][locale]=${entity?.locale}` : '';
- return `/content-manager/${ isSingle ? 'single-types' : 'collection-types'}/${entity?.__collectionUid}${!isSingle ? '/' + entity?.id : ''}${entityLocale}`
- }
- const onNewItemClick = useCallback((event) => canUpdate && onItemLevelAdd(
- event,
- viewId,
- isNextMenuAllowedLevel,
- absolutePath,
- menuAttached,
- `${structureId}.${items.length}`,
- ), [viewId, isNextMenuAllowedLevel, absolutePath, menuAttached, structureId, items, canUpdate]);
-
- useEffect(() => {
- if (isSearchActive) {
- refs.dropRef.current?.scrollIntoView?.({
- behavior: "smooth",
- block: "center",
- inline: "center",
- });
- }
- }, [isSearchActive, refs.dropRef.current])
-
- const theme = useTheme();
-
- return (
-
-
- {removed && ()}
-
-
- onItemRemove(item)}
- onItemEdit={() => onItemEdit({
- ...item,
- isMenuAllowedLevel,
- isParentAttachedToMenu,
- isSearchActive: false,
- }, levelPath, isParentAttachedToMenu)}
- onItemRestore={() => onItemRestore(item)}
- dragRef={refs.dragRef}
- removed={removed}
- canUpdate={canUpdate}
- isSearchActive={isSearchActive}
- />
-
-
- {!isExternal && (
-
-
-
- {!isEmpty(item.items) && onItemToggleCollapse(item)} collapsed={collapsed} itemsCount={item.items.length}/>}
- {canUpdate && (}
- onClick={onNewItemClick}
- >
-
- {getMessage("components.navigationItem.action.newItem")}
-
- )}
-
- {relatedItemLabel && (
-
- {isHandledByPublishFlow && (
- {getMessage({id: `components.navigationItem.badge.${isPublished ? 'published' : 'draft'}`})}
- )}
- {relatedTypeLabel} /
- {relatedItemLabel}
- }>
- )
- }
-
- )}
-
-
- {hasChildren && !removed && !collapsed &&
- }
-
-
- );
-};
-
-Item.propTypes = {
- item: PropTypes.shape({
- title: PropTypes.string,
- type: PropTypes.string,
- uiRouterKey: PropTypes.string,
- path: PropTypes.string,
- externalPath: PropTypes.string,
- related: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- menuAttached: PropTypes.bool,
- collapsed: PropTypes.bool,
- }).isRequired,
- relatedRef: PropTypes.object,
- level: PropTypes.number,
- levelPath: PropTypes.string,
- isParentAttachedToMenu: PropTypes.bool,
- onItemRestore: PropTypes.func.isRequired,
- onItemLevelAdd: PropTypes.func.isRequired,
- onItemRemove: PropTypes.func.isRequired,
- onItemReOrder: PropTypes.func.isRequired,
- onItemToggleCollapse: PropTypes.func.isRequired,
- config: PropTypes.shape({
- contentTypes: PropTypes.array.isRequired,
- contentTypesNameFields: PropTypes.object.isRequired,
- }).isRequired
-};
-
-export default Item;
diff --git a/admin/src/components/NavigationItemList/index.js b/admin/src/components/NavigationItemList/index.js
deleted file mode 100644
index aa46b8a3..00000000
--- a/admin/src/components/NavigationItemList/index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react';
-import PropTypes from "prop-types";
-
-import Item from "../Item";
-import Wrapper from "./Wrapper";
-
-const List = ({
- allowedLevels,
- error,
- isParentAttachedToMenu = false,
- items,
- level = 0,
- levelPath = '',
- onItemEdit,
- onItemLevelAdd,
- onItemRemove,
- onItemRestore,
- onItemReOrder,
- onItemToggleCollapse,
- displayFlat,
- contentTypes,
- contentTypesNameFields,
- permissions,
-}) => (
-
- {items.map((item, n) => {
- const { relatedRef, ...itemProps } = item
- return (
-
- );
- })}
-
-);
-
-List.propTypes = {
- allowedLevels: PropTypes.number,
- isParentAttachedToMenu: PropTypes.bool,
- items: PropTypes.array,
- level: PropTypes.number,
- onItemLevelAdd: PropTypes.func.isRequired,
- onItemRemove: PropTypes.func.isRequired,
- onItemRestore: PropTypes.func.isRequired,
- onItemRestore: PropTypes.func.isRequired,
- onItemReOrder: PropTypes.func.isRequired,
- onItemToggleCollapse: PropTypes.func.isRequired,
- contentTypes: PropTypes.array.isRequired,
- contentTypesNameFields: PropTypes.object.isRequired
-};
-
-export default List;
\ No newline at end of file
diff --git a/admin/src/components/RestartAlert/index.js b/admin/src/components/RestartAlert/index.js
deleted file mode 100644
index b6ec4d66..00000000
--- a/admin/src/components/RestartAlert/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import styled from 'styled-components';
-import { Alert } from '@strapi/design-system/Alert';
-
-export default styled(Alert)`
- [role="status"] {
- flex-direction: column;
- }
-`
diff --git a/admin/src/components/RestartAlert/index.tsx b/admin/src/components/RestartAlert/index.tsx
new file mode 100644
index 00000000..a92090c9
--- /dev/null
+++ b/admin/src/components/RestartAlert/index.tsx
@@ -0,0 +1,8 @@
+import { Alert } from '@strapi/design-system';
+import styled from 'styled-components';
+
+export const RestartAlert = styled(Alert)`
+ [role='status'] {
+ flex-direction: column;
+ }
+`;
diff --git a/admin/src/components/TextArrayInput/index.tsx b/admin/src/components/TextArrayInput/index.tsx
index 466e1e05..23fa3480 100644
--- a/admin/src/components/TextArrayInput/index.tsx
+++ b/admin/src/components/TextArrayInput/index.tsx
@@ -1,9 +1,8 @@
-import React, { useState } from 'react';
-import { Effect } from '../../../../types';
-// @ts-ignore
-import { TextInput } from '@strapi/design-system/TextInput';
-import { GenericInputProps } from "@strapi/helper-plugin"
+import { TextInput } from '@strapi/design-system';
import { isArray } from 'lodash';
+import React, { useState } from 'react';
+
+import { Effect } from '../../types';
interface IProps {
onChange: Effect;
@@ -12,29 +11,22 @@ interface IProps {
name?: string;
label?: string;
disabled?: boolean;
- error?: GenericInputProps["error"];
}
const TextArrayInput: React.FC = ({ onChange, initialValue, ...props }) => {
- const [value, setValue] = useState(isArray(initialValue)
- ? initialValue.reduce((acc, cur) => `${acc}${cur}; `, "")
- : "");
- const handleOnChange = ({target: { value }}: React.BaseSyntheticEvent) => {
+ const [value, setValue] = useState(
+ isArray(initialValue) ? initialValue.reduce((acc, cur) => `${acc}${cur}; `, '') : ''
+ );
+ const handleOnChange = (value: string) => {
const newValue: string = value;
const valuesArray = newValue
.split(';')
- .map(v => v.trim())
- .filter(v => !!v.length);
+ .map((v) => v.trim())
+ .filter((v) => !!v.length);
setValue(value);
onChange(valuesArray);
- }
- return (
-
- )
-}
+ };
+ return ;
+};
-export default TextArrayInput;
\ No newline at end of file
+export default TextArrayInput;
diff --git a/admin/src/components/icons/index.ts b/admin/src/components/icons/index.ts
new file mode 100644
index 00000000..701e5050
--- /dev/null
+++ b/admin/src/components/icons/index.ts
@@ -0,0 +1 @@
+export * from './navigation';
diff --git a/admin/src/components/icons/navigation.js b/admin/src/components/icons/navigation.js
deleted file mode 100644
index 6c3aa3b1..00000000
--- a/admin/src/components/icons/navigation.js
+++ /dev/null
@@ -1,14 +0,0 @@
-
-import React from 'react';
-
-const initSize = 92;
-
-const NavigationIcon = ({ width = 24, height = 24 }) =>
- ;
-
- export default NavigationIcon;
\ No newline at end of file
diff --git a/admin/src/components/icons/navigation.tsx b/admin/src/components/icons/navigation.tsx
new file mode 100644
index 00000000..e2b8ebfb
--- /dev/null
+++ b/admin/src/components/icons/navigation.tsx
@@ -0,0 +1,26 @@
+import styled from "styled-components";
+
+const initSize = 92;
+
+const NavigationIconSvg = styled.svg`
+ path {
+ fill: ${ ({ theme }) => theme.colors.neutral500 };
+ }
+`;
+
+export const NavigationIcon = ({ width = 24, height = 24 }) => (
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/admin/src/components/icons/pluginIcon.tsx b/admin/src/components/icons/pluginIcon.tsx
new file mode 100644
index 00000000..18735d54
--- /dev/null
+++ b/admin/src/components/icons/pluginIcon.tsx
@@ -0,0 +1,11 @@
+/**
+ *
+ * PluginIcon
+ *
+ */
+
+import { NavigationIcon } from './navigation';
+
+const PluginIcon = () => ;
+
+export { PluginIcon };
\ No newline at end of file
diff --git a/admin/src/contexts/DataManagerContext.js b/admin/src/contexts/DataManagerContext.js
deleted file mode 100644
index f36acc32..00000000
--- a/admin/src/contexts/DataManagerContext.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createContext } from "react";
-
-const DataManagerContext = createContext();
-
-export default DataManagerContext;
diff --git a/admin/src/hooks/useAllContentTypes.ts b/admin/src/hooks/useAllContentTypes.ts
deleted file mode 100644
index 50fac971..00000000
--- a/admin/src/hooks/useAllContentTypes.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-//@ts-ignore
-import { useQuery } from 'react-query';
-import { pick } from 'lodash';
-import { fetchAllContentTypes } from '../utils';
-
-const useAllContentTypes = () => pick(
- useQuery('contentTypes', () => fetchAllContentTypes()),
- ["data", "isLoading", "error"]
-);
-
-export default useAllContentTypes;
diff --git a/admin/src/hooks/useDataManager.js b/admin/src/hooks/useDataManager.js
deleted file mode 100644
index 0ab90871..00000000
--- a/admin/src/hooks/useDataManager.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useContext } from "react";
-import DataManagerContext from "../contexts/DataManagerContext";
-
-const useDataManager = () => useContext(DataManagerContext);
-
-export default useDataManager;
diff --git a/admin/src/hooks/useI18nCopyNavigationItemsModal.tsx b/admin/src/hooks/useI18nCopyNavigationItemsModal.tsx
deleted file mode 100644
index 72a2a58d..00000000
--- a/admin/src/hooks/useI18nCopyNavigationItemsModal.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React, { useState, useCallback, useMemo } from "react";
-import {
- ConfirmEffect,
- I18nCopyNavigationItemsModal,
-} from "../pages/View/components/I18nCopyNavigationItems";
-
-export const useI18nCopyNavigationItemsModal = (onConfirm: ConfirmEffect) => {
- const [isOpened, setIsOpened] = useState(false);
- const [sourceLocale, setSourceLocale] = useState(
- undefined
- );
- const onCancel = useCallback(() => {
- setIsOpened(false);
- }, [setIsOpened]);
- const onConfirmWithModalClose = useCallback(() => {
- if (!sourceLocale) {
- return;
- }
-
- onConfirm(sourceLocale);
- setIsOpened(false);
- }, [onConfirm, sourceLocale]);
-
- const modal = useMemo(
- () =>
- isOpened ? (
-
- ) : null,
- [isOpened, onConfirmWithModalClose, onCancel]
- );
-
- return useMemo(
- () => ({
- setI18nCopyModalOpened: setIsOpened,
- setI18nCopySourceLocale: setSourceLocale,
- i18nCopyItemsModal: modal,
- i18nCopySourceLocale: sourceLocale,
- }),
- [setSourceLocale, setIsOpened, modal, sourceLocale]
- );
-};
diff --git a/admin/src/hooks/useNavigationConfig.js b/admin/src/hooks/useNavigationConfig.js
deleted file mode 100644
index 1b913441..00000000
--- a/admin/src/hooks/useNavigationConfig.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useQuery, useQueryClient } from 'react-query';
-import { useNotification } from '@strapi/helper-plugin';
-import { fetchNavigationConfig, restartStrapi, restoreNavigationConfig, updateNavigationConfig } from '../utils';
-import { getTrad } from '../translations';
-
-const useNavigationConfig = () => {
- const queryClient = useQueryClient();
- const toggleNotification = useNotification();
- const { isLoading, data, error } = useQuery('navigationConfig', () =>
- fetchNavigationConfig(toggleNotification)
- );
-
- const handleError = (type) => {
- toggleNotification({
- type: 'warning',
- message: getTrad(`pages.settings.notification.${type}.error`),
- });
- };
-
- const handleSuccess = async (type) => {
- await queryClient.invalidateQueries('navigationConfig');
- toggleNotification({
- type: 'success',
- message: getTrad(`pages.settings.notification.${type}.success`),
- });
- };
-
- const submitMutation = async (...args) => {
- try {
- await updateNavigationConfig(...args);
- await handleSuccess('submit');
- } catch (e) {
- handleError('submit');
- }
- }
-
- const restoreMutation = async (...args) => {
- try {
- await restoreNavigationConfig(...args);
- await handleSuccess('restore');
- } catch (e) {
- handleError('restore');
- }
- }
-
- const restartMutation = async (...args) => {
- try {
- await restartStrapi(...args);
- await handleSuccess('restart');
- } catch (e) {
- handleError('restart');
- }
- }
-
- return { data, isLoading, error, submitMutation, restoreMutation, restartMutation };
-};
-
-export default useNavigationConfig;
diff --git a/admin/src/index.js b/admin/src/index.js
deleted file mode 100644
index e43f65d2..00000000
--- a/admin/src/index.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import { prefixPluginTranslations } from '@strapi/helper-plugin';
-import pluginPkg from '../../package.json';
-import pluginId from './pluginId';
-import pluginPermissions from './permissions';
-import NavigationIcon from './components/icons/navigation';
-import trads, { getTrad } from './translations';
-import { get } from 'lodash';
-const name = pluginPkg.strapi.name;
-
-export default {
- register(app) {
- app.createSettingSection(
- {
- id: pluginId,
- intlLabel: { id: getTrad('pages.settings.section.title'), defaultMessage: 'Navigation Plugin' },
- },
- [
- {
- intlLabel: {
- id: getTrad('pages.settings.section.subtitle'),
- defaultMessage: 'Configuration',
- },
- id: 'navigation',
- to: `/settings/${pluginId}`,
- Component: async () => {
- const component = await import(
- /* webpackChunkName: "navigation-settings" */ './pages/SettingsPage'
- );
-
- return component;
- },
- permissions: pluginPermissions.settings,
- }
- ]);
- app.addMenuLink({
- to: `/plugins/${pluginId}`,
- icon: NavigationIcon,
- intlLabel: {
- id: `${pluginId}.plugin.name`,
- defaultMessage: 'Navigation',
- },
- Component: async () => {
- const component = await import(/* webpackChunkName: "navigation-main-app" */ './pages/App');
-
- return component;
- },
- permissions: pluginPermissions.access,
- });
- app.registerPlugin({
- id: pluginId,
- name,
- });
- },
- bootstrap() {},
- registerTrads({ locales = [] }) {
- return locales
- .filter((locale) => Object.keys(trads).includes(locale))
- .map((locale) => {
- return {
- data: prefixPluginTranslations(get(trads, locale, trads.en), pluginId, {}),
- locale,
- };
- });
- },
-};
diff --git a/admin/src/index.ts b/admin/src/index.ts
new file mode 100644
index 00000000..30024339
--- /dev/null
+++ b/admin/src/index.ts
@@ -0,0 +1,81 @@
+import { Initializer } from './components/Initializer';
+import { PluginIcon } from './components/icons/pluginIcon';
+import App from './pages/App';
+import SettingsPage from './pages/SettingsPage';
+import { PLUGIN_ID } from './pluginId';
+import pluginPermissions from './utils/permissions';
+import { flattenObject, prefixPluginTranslations } from '@sensinum/strapi-utils';
+import trads from "./translations";
+
+const name = "navigation";
+const displayName = "Navigation";
+
+export default {
+ register(app: any) {
+ app.createSettingSection(
+ {
+ id: PLUGIN_ID,
+ intlLabel: {
+ id: `${PLUGIN_ID}.plugin.section.name`,
+ defaultMessage: `${displayName} plugin`,
+ },
+ },
+ [
+ {
+ intlLabel: {
+ id: `${PLUGIN_ID}.plugin.section.item`,
+ defaultMessage: "Configuration",
+ },
+ id: 'navigation',
+ to: PLUGIN_ID,
+ Component() {
+ return SettingsPage;
+ },
+ permissions: pluginPermissions.settings,
+ },
+ ]
+ );
+
+ app.addMenuLink({
+ to: `plugins/${PLUGIN_ID}`,
+ icon: PluginIcon,
+ intlLabel: {
+ id: `${PLUGIN_ID}.plugin.name`,
+ defaultMessage: displayName,
+ },
+ Component() {
+ return App;
+ },
+ permissions: pluginPermissions.access,
+ position: 1,
+ });
+
+ app.registerPlugin({
+ id: PLUGIN_ID,
+ initializer: Initializer,
+ isReady: false,
+ name,
+ });
+ },
+
+
+ registerTrads: async function ({ locales = [] }: { locales: string[] }) {
+ return Promise.all(
+ locales.map(async (locale: string) => {
+ if (locale in trads) {
+ const typedLocale = locale as keyof typeof trads;
+ return trads[typedLocale]().then(({ default: trad }) => {
+ return {
+ data: prefixPluginTranslations(flattenObject(trad), PLUGIN_ID),
+ locale,
+ };
+ });
+ }
+ return {
+ data: prefixPluginTranslations(flattenObject({}), PLUGIN_ID),
+ locale,
+ };
+ }),
+ );
+ },
+};
diff --git a/admin/src/pages/App.tsx b/admin/src/pages/App.tsx
new file mode 100644
index 00000000..79994162
--- /dev/null
+++ b/admin/src/pages/App.tsx
@@ -0,0 +1,19 @@
+import { Page } from '@strapi/strapi/admin';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { Route, Routes } from 'react-router-dom';
+
+import { HomePage } from './HomePage';
+
+const App = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/admin/src/pages/App/index.js b/admin/src/pages/App/index.js
deleted file mode 100644
index 99512d85..00000000
--- a/admin/src/pages/App/index.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- *
- * This component is the skeleton around the actual pages, and should only
- * contain code that should be seen on all pages. (e.g. navigation bar)
- *
- */
-
- import React, { Suspense, lazy } from "react";
- import { Switch, Route } from "react-router-dom";
- import { NotFound, LoadingIndicatorPage } from "@strapi/helper-plugin";
- // Utils
- import DataManagerProvider from "../DataManagerProvider";
- import pluginId from "../../pluginId";
- // Containers
- const View = lazy(() => import("../View"));
-
- const App = () => {
- return (
-
- }>
-
-
-
-
-
-
- );
- };
-
- export default App;
-
\ No newline at end of file
diff --git a/admin/src/pages/DataManagerProvider/actions.js b/admin/src/pages/DataManagerProvider/actions.js
deleted file mode 100644
index 6a7d9aa1..00000000
--- a/admin/src/pages/DataManagerProvider/actions.js
+++ /dev/null
@@ -1,31 +0,0 @@
-export const GET_LIST_DATA = "GET_LIST_DATA";
-export const GET_LIST_DATA_SUCCEEDED = "GET_LIST_DATA_SUCCEEDED";
-export const GET_NAVIGATION_DATA = "GET_NAVIGATION_DATA";
-export const GET_NAVIGATION_DATA_SUCCEEDED = "GET_NAVIGATION_DATA_SUCCEEDED";
-
-export const CHANGE_NAVIGATION_DATA = "CHANGE_NAVIGATION_DATA";
-export const RESET_NAVIGATION_DATA = "RESET_NAVIGATION_DATA";
-
-export const CHANGE_NAVIGATION_POPUP_VISIBILITY =
- "CHANGE_NAVIGATION_POPUP_VISIBILITY";
-export const CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY =
- "CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY";
-
-export const GET_CONTENT_TYPE_ITEMS = "GET_CONTENT_TYPE_ITEMS";
-export const GET_CONTENT_TYPE_ITEMS_SUCCEEDED =
- "GET_CONTENT_TYPE_ITEMS_SUCCEEDED";
-
-export const GET_CONFIG = "GET_CONFIG";
-export const GET_CONFIG_SUCCEEDED = "GET_CONFIG_SUCCEEDED";
-
-export const SUBMIT_NAVIGATION = "SUBMIT_NAVIGATION";
-export const SUBMIT_NAVIGATION_SUCCEEDED = "SUBMIT_NAVIGATION_SUCCEEDED";
-export const SUBMIT_NAVIGATION_ERROR = "SUBMIT_NAVIGATION_ERROR";
-
-export const CACHE_CLEAR = "CACHE_CLEAR";
-export const CACHE_CLEAR_SUCCEEDED = "CACHE_CLEAR_SUCCEEDED";
-
-export const RELOAD_PLUGIN = "RELOAD_PLUGIN";
-
-export const I18N_COPY_NAVIGATION = "I18N_COPY_NAVIGATION";
-export const I18N_COPY_NAVIGATION_SUCCESS = "I18N_COPY_NAVIGATION_SUCCESS";
\ No newline at end of file
diff --git a/admin/src/pages/DataManagerProvider/index.js b/admin/src/pages/DataManagerProvider/index.js
deleted file mode 100644
index d01adbbe..00000000
--- a/admin/src/pages/DataManagerProvider/index.js
+++ /dev/null
@@ -1,484 +0,0 @@
-import React, { memo, useEffect, useMemo, useReducer, useRef } from "react";
-import { useLocation, useRouteMatch } from "react-router-dom";
-import { useIntl } from 'react-intl';
-import PropTypes from "prop-types";
-import { get, find, first, isEmpty } from "lodash";
-import {
- request,
- LoadingIndicatorPage,
- useNotification,
- useAppInfos,
- useRBAC,
-} from "@strapi/helper-plugin";
-import DataManagerContext from "../../contexts/DataManagerContext";
-import pluginId from "../../pluginId";
-import init from "./init";
-import { getTrad } from "../../translations";
-import reducer, { initialState } from "./reducer";
-import {
- GET_NAVIGATION_DATA,
- GET_NAVIGATION_DATA_SUCCEEDED,
- GET_LIST_DATA,
- GET_LIST_DATA_SUCCEEDED,
- CHANGE_NAVIGATION_POPUP_VISIBILITY,
- CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY,
- RESET_NAVIGATION_DATA,
- CHANGE_NAVIGATION_DATA,
- GET_CONFIG,
- GET_CONFIG_SUCCEEDED,
- GET_CONTENT_TYPE_ITEMS_SUCCEEDED,
- GET_CONTENT_TYPE_ITEMS,
- SUBMIT_NAVIGATION,
- SUBMIT_NAVIGATION_SUCCEEDED,
- SUBMIT_NAVIGATION_ERROR,
- I18N_COPY_NAVIGATION,
- I18N_COPY_NAVIGATION_SUCCESS,
- CACHE_CLEAR_SUCCEEDED,
- CACHE_CLEAR,
-} from './actions';
-import { prepareItemToViewPayload } from '../View/utils/parsers';
-import { errorStatusResourceFor, resolvedResourceFor } from "../../utils";
-import NoAcccessPage from "../NoAccessPage";
-import pluginPermissions from "../../permissions";
-
-const i18nAwareItems = ({ items, config }) =>
- config.i18nEnabled ? items.filter(({ localeCode }) => localeCode === config.defaultLocale) : items;
-
-const DataManagerProvider = ({ children }) => {
- const [reducerState, dispatch] = useReducer(reducer, initialState, init);
- const toggleNotification = useNotification();
- const { autoReload } = useAppInfos();
- const { formatMessage } = useIntl();
-
- const {
- items,
- config,
- activeItem,
- initialData,
- changedActiveItem,
- navigationPopupOpened,
- navigationItemPopupOpened,
- isLoading,
- isLoadingForDataToBeSet,
- isLoadingForDetailsDataToBeSet,
- isLoadingForAdditionalDataToBeSet,
- isLoadingForSubmit,
- error,
- availableLocale,
- } = reducerState;
- const { pathname } = useLocation();
- const formatMessageRef = useRef();
- formatMessageRef.current = formatMessage;
-
- const viewPermissions = useMemo(
- () => ({
- access: pluginPermissions.access || pluginPermissions.update,
- update: pluginPermissions.update,
- }),
- [],
- );
-
- const {
- isLoading: isLoadingForPermissions,
- allowedActions: {
- canAccess,
- canUpdate,
- },
- } = useRBAC(viewPermissions);
-
- const getLayoutSettingRef = useRef();
- getLayoutSettingRef.current = (settingName) =>
- get({}, ["settings", settingName], "");
-
- const isInDevelopmentMode = autoReload;
-
- const abortController = new AbortController();
- const { signal } = abortController;
- const getDataRef = useRef();
-
- const menuViewMatch = useRouteMatch(`/plugins/${pluginId}/:id`);
- const activeId = get(menuViewMatch, "params.id", null);
- const passedActiveItems = useMemo(() => {
- return i18nAwareItems({ config, items })
- }, [config, items]);
-
- const getNavigation = async (id, navigationConfig) => {
- try {
- if (activeId || id) {
- dispatch({
- type: GET_NAVIGATION_DATA,
- });
-
- const activeItem = await request(`/${pluginId}/${activeId || id}`, {
- method: "GET",
- signal,
- });
-
- dispatch({
- type: GET_NAVIGATION_DATA_SUCCEEDED,
- activeItem: {
- ...activeItem,
- items: prepareItemToViewPayload({
- config: navigationConfig,
- items: activeItem.items,
- }),
- },
- });
- }
- } catch (err) {
- console.error({ err });
- toggleNotification({
- type: 'warning',
- message: getTrad('notification.error'),
- });
- }
- };
-
- getDataRef.current = async (id) => {
- try {
- dispatch({
- type: GET_CONFIG,
- });
- const config = await request(`/${pluginId}/config`, {
- method: "GET",
- signal,
- });
- dispatch({
- type: GET_CONFIG_SUCCEEDED,
- config,
- });
-
- dispatch({
- type: GET_LIST_DATA,
- });
- const items = await request(`/${pluginId}`, {
- method: "GET",
- signal,
- });
-
- dispatch({
- type: GET_LIST_DATA_SUCCEEDED,
- items,
- });
-
- if (id || !isEmpty(items)) {
- await getNavigation(id || first(i18nAwareItems({ items, config })).id, config);
- }
- } catch (err) {
- console.error({ err });
- toggleNotification({
- type: 'warning',
- message: getTrad('notification.error'),
- });
- }
- };
-
- useEffect(() => {
- getDataRef.current();
- }, []);
-
- useEffect(() => {
- // We need to set the modifiedData after the data has been retrieved
- // and also on pathname change
- if (!isLoading) {
- getNavigation();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isLoading, pathname]);
-
- useEffect(() => {
- if (!autoReload) {
- toggleNotification({
- type: 'info',
- message: { id: 'notification.info.autoreaload-disable' },
- });
- }
- }, [autoReload]);
-
- const getContentTypeItems = async ({ modelUID, query, locale }) => {
- dispatch({
- type: GET_CONTENT_TYPE_ITEMS,
- });
- const url =`/navigation/content-type-items/${modelUID}`;
- const queryParams = new URLSearchParams();
- queryParams.append('_publicationState', 'preview');
- if (query) {
- queryParams.append('_q', query);
- }
- if (locale) {
- queryParams.append('localeCode', locale);
- }
-
- const contentTypeItems = await request(`${url}?${queryParams.toString()}`, {
- method: "GET",
- signal,
- });
-
- const fetchedContentType = find(config.contentTypes, ct => ct.uid === modelUID);
- const isArray = Array.isArray(contentTypeItems);
- dispatch({
- type: GET_CONTENT_TYPE_ITEMS_SUCCEEDED,
- contentTypeItems: (isArray ? contentTypeItems : [contentTypeItems]).map(item => ({
- ...item,
- __collectionUid: get(fetchedContentType, 'collectionUid', modelUID),
- })),
- });
- };
-
- const handleChangeSelection = (id) => {
- getNavigation(id, config);
- };
-
- const handleLocalizationSelection = (id) => {
- getNavigation(id, config);
- };
-
- const handleI18nCopy = async (sourceId, targetId) => {
- dispatch({
- type: I18N_COPY_NAVIGATION
- });
-
- const url = `/navigation/i18n/copy/${sourceId}/${targetId}`;
-
- await request(url, {
- method: "PUT",
- signal,
- });
-
- dispatch({
- type: I18N_COPY_NAVIGATION_SUCCESS,
- });
-
- handleChangeSelection(targetId);
- };
-
- const readNavigationItemFromLocale = async ({ locale, structureId }) => {
- try {
- const source = changedActiveItem.localizations?.find((navigation) => navigation.locale === locale);
-
- if (!source) {
- return errorStatusResourceFor(['popup.item.form.i18n.locale.error.unavailable']);
- }
-
- const url = `/navigation/i18n/item/read/${source.id}/${changedActiveItem.id}?path=${structureId}`;
-
- return resolvedResourceFor(await request(url, {
- method: "GET",
- signal,
- }));
- } catch (error) {
- let messageKey;
-
- if (error instanceof Error) {
- messageKey = get(error, 'response.payload.error.details.messageKey');
- }
-
- return errorStatusResourceFor([messageKey ?? 'popup.item.form.i18n.locale.error.generic']);
- }
- };
-
- const handleChangeNavigationPopupVisibility = (visible) => {
- dispatch({
- type: CHANGE_NAVIGATION_POPUP_VISIBILITY,
- navigationPopupOpened: visible,
- });
- };
-
- const handleChangeNavigationItemPopupVisibility = (visible) => {
- dispatch({
- type: CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY,
- navigationItemPopupOpened: visible,
- });
- };
-
- const handleChangeNavigationData = (payload, forceClosePopups) => {
- dispatch({
- type: CHANGE_NAVIGATION_DATA,
- changedActiveItem: payload,
- forceClosePopups,
- });
- };
-
- const handleResetNavigationData = () => {
- dispatch({
- type: RESET_NAVIGATION_DATA,
- activeItem,
- });
- };
-
- const handleSubmitNavigation = async (formatMessage, payload = {}) => {
- try {
- dispatch({
- type: SUBMIT_NAVIGATION,
- });
-
- const nagivationId = payload.id ? `/${payload.id}` : "";
- const method = payload.id ? "PUT" : "POST";
- const navigation = await request(`/${pluginId}${nagivationId}`, {
- method,
- signal,
- body: payload,
- });
- dispatch({
- type: SUBMIT_NAVIGATION_SUCCEEDED,
- navigation: {
- ...navigation,
- items: prepareItemToViewPayload({
- config,
- items: navigation.items,
- }),
- },
- });
- toggleNotification({
- type: 'success',
- message: getTrad('notification.navigation.submit'),
- });
- } catch (err) {
- dispatch({
- type: SUBMIT_NAVIGATION_ERROR,
- error: err.response.payload.data
- });
-
- if (
- err.response.payload.error &&
- err.response.payload.error.details &&
- err.response.payload.error.details.errorTitles
- ) {
- return toggleNotification({
- type: 'warning',
- message: {
- id: formatMessage(
- getTrad('notification.navigation.error'),
- {
- ...err.response.payload.error.details,
- errorTitles: err.response.payload.error.details.errorTitles
- .map(title => `"${title}"`)
- .join(", ")
- },
- )
- },
- });
- }
-
- toggleNotification({
- type: 'warning',
- message: getTrad('notification.error'),
- });
- }
- };
-
- const handleNavigationsDeletion = (ids) =>
- Promise.all(ids.map(handleNavigationDeletion));
-
- const handleNavigationsPurge = async (ids, withLangVersions = false, skipDispatch = false) => {
- if (!skipDispatch) {
- dispatch({ type: CACHE_CLEAR });
- }
-
- try {
- if (ids.length) {
-
- await Promise.all(ids.map((id) => handleNavigationPurgeReq(id, withLangVersions)));
- } else {
- await handleNavigationsPurgeReq();
- }
- } catch (error) {
- console.error("Unable to clear navigation cache");
- }
-
- if (!skipDispatch) {
- dispatch({ type: CACHE_CLEAR_SUCCEEDED });
- }
- }
-
- const handleNavigationPurgeReq = (id, withLangVersions) =>
- request(`/${pluginId}/cache/purge/${id}?clearLocalisations=${!!withLangVersions}`, {
- method: "DELETE",
- signal,
- });
-
- const handleNavigationsPurgeReq = () =>
- request(`/${pluginId}/cache/purge`, {
- method: "DELETE",
- signal,
- });
-
- const handleNavigationDeletion = (id) =>
- request(`/${pluginId}/${id}`, {
- method: "DELETE",
- signal,
- });
-
- const slugify = (query) =>
- request(
- `/${pluginId}/slug?q=${query}`,
- { method: "GET", signal }
- ).then((res) => {
- if (!res?.slug) {
- toggleNotification({
- type: 'warning',
- message: formatMessage(
- getTrad('notification.error.item.slug'),
- { query, result: res?.slug || "" }
- )
- });
- }
-
- return res;
- });
-
- const hardReset = () => getDataRef.current();
-
-
- if (!canAccess && !isLoadingForPermissions) {
- return ();
- }
-
- return (
-
- {isLoading || isLoadingForPermissions ? : children}
-
- );
-};
-
-DataManagerProvider.propTypes = {
- children: PropTypes.node.isRequired,
-};
-
-export default memo(DataManagerProvider);
diff --git a/admin/src/pages/DataManagerProvider/init.js b/admin/src/pages/DataManagerProvider/init.js
deleted file mode 100644
index 18c17c07..00000000
--- a/admin/src/pages/DataManagerProvider/init.js
+++ /dev/null
@@ -1,5 +0,0 @@
-function init(initialState) {
- return initialState;
-}
-
-export default init;
diff --git a/admin/src/pages/DataManagerProvider/reducer.js b/admin/src/pages/DataManagerProvider/reducer.js
deleted file mode 100644
index 4e669f9d..00000000
--- a/admin/src/pages/DataManagerProvider/reducer.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import produce from 'immer';
-
-import {
- GET_LIST_DATA,
- GET_LIST_DATA_SUCCEEDED,
- GET_NAVIGATION_DATA,
- GET_NAVIGATION_DATA_SUCCEEDED,
- RELOAD_PLUGIN,
- RESET_NAVIGATION_DATA,
- CHANGE_NAVIGATION_POPUP_VISIBILITY,
- CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY,
- CHANGE_NAVIGATION_DATA,
- GET_CONFIG,
- GET_CONFIG_SUCCEEDED,
- GET_CONTENT_TYPE_ITEMS,
- GET_CONTENT_TYPE_ITEMS_SUCCEEDED,
- SUBMIT_NAVIGATION_SUCCEEDED,
- SUBMIT_NAVIGATION,
- SUBMIT_NAVIGATION_ERROR,
- I18N_COPY_NAVIGATION_SUCCESS,
- I18N_COPY_NAVIGATION,
- CACHE_CLEAR,
- CACHE_CLEAR_SUCCEEDED,
-} from './actions';
-
-const initialState = {
- items: [],
- activeItem: undefined,
- changedActiveItem: undefined,
- navigationPopupOpened: false,
- navigationItemPopupOpened: false,
- config: {},
- isLoading: true,
- isLoadingForDataToBeSet: false,
- isLoadingForDetailsDataToBeSet: false,
- isLoadingForAdditionalDataToBeSet: false,
- isLoadingForSubmit: false,
- error: undefined,
- i18nEnabled: false,
- cascadeMenuAttached: true,
- availableLocale: [],
-};
-
-const reducer = (state, action) => produce(state, draftState => {
- switch (action.type) {
- case GET_CONFIG: {
- draftState.isLoadingForDetailsDataToBeSet = true;
- draftState.config = {};
- break;
- }
- case GET_CONFIG_SUCCEEDED: {
- draftState.isLoadingForDetailsDataToBeSet = false;
- draftState.config = action.config;
- break;
- }
- case GET_LIST_DATA: {
- draftState.items = [];
- draftState.isLoadingForDataToBeSet = true;
- draftState.availableLocale = [];
- break;
- }
- case GET_LIST_DATA_SUCCEEDED: {
- draftState.items = action.items;
- draftState.isLoading = false;
- draftState.isLoadingForDataToBeSet = false;
- draftState.availableLocale = [...action.items.reduce((set, item) => set.add(item.localeCode), new Set()).values()];
- break;
- }
- case GET_NAVIGATION_DATA: {
- draftState.activeItem = undefined;
- draftState.changedActiveItem = undefined;
- draftState.isLoadingForDetailsDataToBeSet = true;
- break;
- }
- case GET_NAVIGATION_DATA_SUCCEEDED: {
- const activeItem = action.activeItem || {};
- draftState.activeItem = activeItem;
- draftState.changedActiveItem = activeItem;
- draftState.isLoadingForDetailsDataToBeSet = false;
- break;
- }
- case CHANGE_NAVIGATION_DATA: {
- draftState.changedActiveItem = action.changedActiveItem;
- draftState.navigationPopupOpened = action.forceClosePopups ? false : state.navigationPopupOpened;
- draftState.navigationItemPopupOpened = action.forceClosePopups ? false : state.navigationItemPopupOpened;
- break;
- }
- case RESET_NAVIGATION_DATA : {
- draftState.changedActiveItem = action.activeItem || {};
- break;
- }
- case GET_CONTENT_TYPE_ITEMS: {
- draftState.isLoadingForAdditionalDataToBeSet = true;
- break;
- }
- case GET_CONTENT_TYPE_ITEMS_SUCCEEDED: {
- draftState.isLoadingForAdditionalDataToBeSet = false;
- draftState.config.contentTypeItems = action.contentTypeItems;
- break;
- }
- case CHANGE_NAVIGATION_POPUP_VISIBILITY: {
- draftState.navigationPopupOpened = action.navigationPopupOpened;
- break;
- }
- case CHANGE_NAVIGATION_ITEM_POPUP_VISIBILITY: {
- draftState.navigationItemPopupOpened = action.navigationItemPopupOpened;
- break;
- }
- case SUBMIT_NAVIGATION: {
- draftState.isLoadingForSubmit = true;
- draftState.error = undefined;
- break;
- }
- case SUBMIT_NAVIGATION_SUCCEEDED: {
- draftState.activeItem = action.navigation || {};
- draftState.changedActiveItem = action.navigation || {};
- draftState.isLoadingForSubmit = false;
- break;
- }
- case SUBMIT_NAVIGATION_ERROR: {
- draftState.isLoadingForSubmit = false;
- draftState.error = action.error;
- break;
- }
- case RELOAD_PLUGIN: {
- return initialState;
- }
- case I18N_COPY_NAVIGATION: {
- draftState.isLoading = true;
- break;
- }
- case I18N_COPY_NAVIGATION_SUCCESS: {
- draftState.isLoading = false;
- break;
- }
- case CACHE_CLEAR: {
- draftState.isLoading = true;
- break;
- }
- case CACHE_CLEAR_SUCCEEDED: {
- draftState.isLoading = false;
- break;
- }
- default:
- return draftState;
- }
-});
-
-export default reducer;
-export { initialState };
diff --git a/admin/src/pages/HomePage/components/AdditionalFieldInput/index.tsx b/admin/src/pages/HomePage/components/AdditionalFieldInput/index.tsx
new file mode 100644
index 00000000..b2161c67
--- /dev/null
+++ b/admin/src/pages/HomePage/components/AdditionalFieldInput/index.tsx
@@ -0,0 +1,137 @@
+import {
+ MultiSelect,
+ MultiSelectOption,
+ SingleSelect,
+ SingleSelectOption,
+ TextInput,
+} from '@strapi/design-system';
+import { useNotification, useStrapiApp } from '@strapi/strapi/admin';
+import { isNil } from 'lodash';
+import { useEffect, useMemo } from 'react';
+import { useIntl } from 'react-intl';
+
+import { Toggle } from '@strapi/design-system';
+import { NavigationItemCustomField } from '../../../../schemas';
+import { getTrad } from '../../../../translations';
+
+export type AdditionalFieldInputProps = {
+ name?: string;
+ field: NavigationItemCustomField;
+ isLoading: boolean;
+ onChange: (eventOrPath: React.ChangeEvent | string, value?: any) => void;
+ onChangeEnhancer: (eventOrPath: React.ChangeEvent | string, value?: any, nativeOnChange?: (eventOrPath: React.ChangeEvent | string, value?: any) => void) => void;
+ value: string | boolean | string[] | object | null;
+ disabled: boolean;
+};
+
+const DEFAULT_STRING_VALUE = '';
+
+const mediaAttribute = {
+ type: 'media',
+ multiple: false,
+ required: false,
+ allowedTypes: ['images'],
+ pluginOptions: {
+ i18n: {
+ localized: false,
+ },
+ },
+};
+
+export const AdditionalFieldInput: React.FC = ({
+ name,
+ field,
+ isLoading,
+ onChange,
+ onChangeEnhancer,
+ value,
+ disabled,
+}) => {
+ const { toggleNotification } = useNotification();
+ const { formatMessage } = useIntl();
+
+ const fields = useStrapiApp('AdditionalFieldInput', (state) => state.fields);
+
+ const MediaLibrary = fields.media as React.ComponentType;
+
+ const defaultInputProps = useMemo(
+ () => ({
+ id: field.name,
+ name: name || field.name,
+ disabled: isLoading || disabled,
+ }),
+ [field, isLoading]
+ );
+
+ useEffect(() => {
+ if (field.type === 'media') {
+ onChangeEnhancer(defaultInputProps.name, value, onChange)
+ }
+ }, [value]);
+
+ switch (field.type) {
+ case 'boolean':
+ return (
+ | string) => onChangeEnhancer(eventOrPath, !value, onChange)}
+ onLabel="true"
+ offLabel="false"
+ type="checkbox"
+ />
+ );
+ case 'string':
+ return (
+ | string, value?: any) => onChangeEnhancer(eventOrPath, value, onChange)}
+ value={value || DEFAULT_STRING_VALUE}
+ />
+ );
+ case 'select':
+ return field.multi ? (
+ | string) => onChangeEnhancer(defaultInputProps.name, eventOrPath, onChange)}
+ value={isNil(value) ? (field.multi ? [] : null) : value}
+ multi={field.multi}
+ withTags={field.multi}
+ >
+ {field.options.map((option, index) => (
+
+ {option}
+
+ ))}
+
+ ) : (
+ | string) => onChangeEnhancer(defaultInputProps.name, eventOrPath, onChange)}
+ value={isNil(value) ? (field.multi ? [] : null) : value}
+ multi={field.multi}
+ withTags={field.multi}
+ >
+ {field.options.map((option, index) => (
+
+ {option}
+
+ ))}
+
+ );
+ case 'media':
+ return (
+
+ );
+ default:
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage(getTrad('notification.error.customField.type')),
+ });
+ throw new Error(`Type of custom field is unsupported`);
+ }
+};
diff --git a/admin/src/pages/HomePage/components/CollapseButton/index.tsx b/admin/src/pages/HomePage/components/CollapseButton/index.tsx
new file mode 100644
index 00000000..b2a3f9a3
--- /dev/null
+++ b/admin/src/pages/HomePage/components/CollapseButton/index.tsx
@@ -0,0 +1,37 @@
+import { Flex, Typography } from '@strapi/design-system';
+import { CaretDown, CaretUp } from '@strapi/icons';
+import styled from 'styled-components';
+
+import { Effect } from '../../../../types';
+
+const Wrapper = styled.div`
+ border-radius: 50%;
+ background: #dcdce4;
+ width: 25px;
+ height: 25px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 8px;
+`;
+
+interface Props {
+ toggle: Effect;
+ collapsed?: boolean;
+ itemsCount?: number;
+}
+
+export const CollapseButton = ({ toggle, collapsed, itemsCount }: Props) => (
+
+
+ {collapsed ? : }
+
+ {itemsCount} nested items
+
+);
diff --git a/admin/src/pages/HomePage/components/DragButton/index.tsx b/admin/src/pages/HomePage/components/DragButton/index.tsx
new file mode 100644
index 00000000..e746c8af
--- /dev/null
+++ b/admin/src/pages/HomePage/components/DragButton/index.tsx
@@ -0,0 +1,62 @@
+import { Drag } from '@strapi/icons';
+import React from 'react';
+import styled, { DefaultTheme } from 'styled-components';
+
+const DragButtonWrapper = styled.span<{ ref: unknown, isActive?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ height: 32px;
+ width: 32px;
+ padding: ${({ theme }) => theme.spaces[2]};
+
+ background: ${({ theme, isActive }) =>
+ isActive ? theme.colors.neutral150 : theme.colors.neutral0};
+ border: 1px solid ${({ theme }) => theme.colors.neutral200};
+ border-radius: ${({ theme }) => theme.borderRadius};
+ cursor: pointer;
+ transition: background-color 0.3s ease-in;
+
+ svg {
+ height: ${({ theme }) => theme.spaces[3]};
+ width: ${({ theme }) => theme.spaces[3]};
+
+ > g,
+ path {
+ fill: ${({ theme }) => theme.colors.neutral500};
+ }
+ }
+ &:hover {
+ svg {
+ > g,
+ path {
+ fill: ${({ theme }) => theme.colors.neutral600};
+ }
+ }
+ }
+ &:active {
+ svg {
+ > g,
+ path {
+ fill: ${({ theme }) => theme.colors.neutral400};
+ }
+ }
+ }
+ &[aria-disabled='true'] {
+ background-color: ${({ theme }) => theme.colors.neutral150};
+ svg {
+ path {
+ fill: ${({ theme }) => theme.colors.neutral600};
+ }
+ }
+ }
+`;
+
+const DragButton = React.forwardRef((props, ref) => (
+
+
+
+));
+
+export default DragButton;
diff --git a/admin/src/pages/HomePage/components/I18nCopyNavigationItems/index.tsx b/admin/src/pages/HomePage/components/I18nCopyNavigationItems/index.tsx
new file mode 100644
index 00000000..5d43a625
--- /dev/null
+++ b/admin/src/pages/HomePage/components/I18nCopyNavigationItems/index.tsx
@@ -0,0 +1,38 @@
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+import { ConfirmationDialog } from '../../../../components/ConfirmationDialog';
+import { getTrad } from '../../../../translations';
+
+export interface ConfirmEffect {
+ (source: string): void;
+}
+
+export interface CancelEffect {
+ (): void;
+}
+
+interface Props {
+ onConfirm: ConfirmEffect;
+ onCancel: CancelEffect;
+}
+
+const refreshIcon = <>>;
+
+export const I18nCopyNavigationItemsModal: FC = ({ onConfirm, onCancel }) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+ {formatMessage(getTrad('pages.view.actions.i18nCopyItems.confirmation.content'))}
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationContentHeader/index.tsx b/admin/src/pages/HomePage/components/NavigationContentHeader/index.tsx
new file mode 100644
index 00000000..f5e9dc3d
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationContentHeader/index.tsx
@@ -0,0 +1,16 @@
+import { Flex } from '@strapi/design-system';
+import { ReactNode } from 'react';
+
+interface Props {
+ startActions: ReactNode;
+ endActions: ReactNode;
+}
+
+export const NavigationContentHeader = ({ startActions, endActions }: Props) => {
+ return (
+
+ {startActions}
+ {endActions}
+
+ );
+};
diff --git a/admin/src/hooks/useNavigationManager.tsx b/admin/src/pages/HomePage/components/NavigationHeader/hooks.tsx
similarity index 65%
rename from admin/src/hooks/useNavigationManager.tsx
rename to admin/src/pages/HomePage/components/NavigationHeader/hooks.tsx
index 789d358c..8ed5ee11 100644
--- a/admin/src/hooks/useNavigationManager.tsx
+++ b/admin/src/pages/HomePage/components/NavigationHeader/hooks.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useMemo, useState } from "react";
-import { NavigationManager } from "../pages/View/components/NavigationManager";
+import { useCallback, useMemo, useState } from 'react';
+import { NavigationManager } from '../NavigationManager';
export const useNavigationManager = () => {
const [isOpened, setIsOpened] = useState(false);
@@ -9,11 +9,7 @@ export const useNavigationManager = () => {
const modal = useMemo(
() =>
isOpened ? (
-
+
) : null,
[isOpened, close]
);
diff --git a/admin/src/pages/HomePage/components/NavigationHeader/index.tsx b/admin/src/pages/HomePage/components/NavigationHeader/index.tsx
new file mode 100644
index 00000000..995a170a
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationHeader/index.tsx
@@ -0,0 +1,182 @@
+import {
+ Box,
+ Button,
+ Field,
+ Flex,
+ Grid,
+ SingleSelect,
+ SingleSelectOption,
+ Tag,
+} from '@strapi/design-system';
+import { Check, Information } from '@strapi/icons';
+import { Layouts } from '@strapi/strapi/admin';
+import React from 'react';
+import { useIntl } from 'react-intl';
+import { NavigationSchema } from '../../../../api/validators';
+import { getTrad } from '../../../../translations';
+import { Effect } from '../../../../types';
+import { useConfig } from '../../hooks';
+import { useNavigationManager } from './hooks';
+
+const submitIcon = ;
+
+interface Props {
+ activeNavigation?: NavigationSchema;
+ availableNavigations: NavigationSchema[];
+ structureHasErrors?: boolean;
+ structureHasChanged?: boolean;
+ handleChangeSelection: Effect;
+ handleLocalizationSelection: Effect;
+ handleSave: Effect;
+ handleCachePurge: Effect;
+ permissions: { canUpdate?: boolean };
+ locale: {
+ defaultLocale: string;
+ restLocale: string[];
+ };
+ currentLocale?: string;
+}
+
+export const NavigationHeader: React.FC = ({
+ activeNavigation,
+ availableNavigations,
+ structureHasErrors,
+ structureHasChanged,
+ handleChangeSelection,
+ handleLocalizationSelection,
+ handleSave,
+ handleCachePurge,
+ permissions = {},
+ locale,
+ currentLocale,
+}) => {
+ const { formatMessage } = useIntl();
+ const { openNavigationManagerModal, navigationManagerModal } = useNavigationManager();
+
+ const hasLocalizations = !!locale.restLocale;
+
+ const { canUpdate } = permissions;
+
+ const configQuery = useConfig();
+
+ return (
+ <>
+
+
+
+ {!hasLocalizations ? : null}
+ {canUpdate && (
+
+
+
+ )}
+
+
+ {
+ const nextNavigation = availableNavigations.find(({ documentId }) => nextDocumentId === documentId);
+
+ if (nextNavigation) {
+ handleChangeSelection(nextNavigation);
+ }
+ }}
+ value={activeNavigation?.documentId}
+ size="S"
+ style={null}
+ >
+ {availableNavigations
+ .filter(({ locale }) => locale === currentLocale)
+ .map(({ documentId, name }) => (
+
+ {name}
+
+ ))}
+
+
+
+ {hasLocalizations ? (
+
+
+ {[locale.defaultLocale, ...locale.restLocale].map((code) => (
+
+ {code}
+
+ ))}
+
+
+ ) : null}
+ {canUpdate && (
+
+
+
+ )}
+ {configQuery.data?.isCacheEnabled && (
+
+
+
+ )}
+
+
+ {canUpdate && navigationManagerModal}
+
+ }
+ secondaryAction={
+ }>
+ {activeNavigation
+ ? formatMessage(getTrad('header.meta'), {
+ id: activeNavigation?.documentId,
+ key: activeNavigation?.slug,
+ })
+ : null}
+
+ }
+ />
+ >
+ );
+};
diff --git a/admin/src/pages/View/components/NavigationHeader/styles.js b/admin/src/pages/HomePage/components/NavigationHeader/styles.ts
similarity index 81%
rename from admin/src/pages/View/components/NavigationHeader/styles.js
rename to admin/src/pages/HomePage/components/NavigationHeader/styles.ts
index 3573cae5..f8005061 100644
--- a/admin/src/pages/View/components/NavigationHeader/styles.js
+++ b/admin/src/pages/HomePage/components/NavigationHeader/styles.ts
@@ -1,5 +1,5 @@
import styled from 'styled-components';
-import { IconButton } from '@strapi/design-system/IconButton';
+import { IconButton } from '@strapi/design-system';
export const MoreButton = styled(IconButton)`
margin: ${({ theme }) => `0 ${theme.spaces[2]}`};
diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/index.tsx b/admin/src/pages/HomePage/components/NavigationItemForm/index.tsx
new file mode 100644
index 00000000..12877554
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemForm/index.tsx
@@ -0,0 +1,932 @@
+import { AnyEntity, Field } from '@sensinum/strapi-utils';
+import { Form, useNotification } from '@strapi/strapi/admin';
+import { get, isBoolean, isEmpty, isNil, isObject, isString, set, sortBy } from 'lodash';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useIntl } from 'react-intl';
+
+import {
+ Box,
+ Button,
+ Divider,
+ Grid,
+ Modal,
+ MultiSelect,
+ MultiSelectOption,
+ SingleSelect,
+ SingleSelectOption,
+ TextInput,
+ Toggle,
+} from '@strapi/design-system';
+
+import { NavigationSchema } from '../../../../api/validators';
+import { NavigationItemAdditionalField } from '../../../../schemas';
+import { getTrad } from '../../../../translations';
+import {
+ Effect,
+ FormChangeEvent,
+ FormItemErrorSchema,
+ ToBeFixed,
+ VoidEffect,
+} from '../../../../types';
+import {
+ useConfig,
+ useContentTypeItems,
+ useCopyNavigationItemI18n,
+ useNavigations,
+} from '../../hooks';
+import { extractRelatedItemLabel } from '../../utils';
+import { AdditionalFieldInput } from '../AdditionalFieldInput';
+import { NavigationItemPopupFooter } from '../NavigationItemPopup/NavigationItemPopupFooter';
+import { ContentTypeEntity } from './types';
+import {
+ fallbackDefaultValues,
+ navigationItemFormSchema,
+ NavigationItemFormSchema,
+} from './utils/form';
+import { useSlug } from './utils/hooks';
+import { generatePreviewPath, generateUiRouterKey } from './utils/properties';
+
+export { ContentTypeEntity, GetContentTypeEntitiesPayload } from './types';
+export { NavigationItemFormSchema } from './utils/form';
+
+export type SubmitEffect = Effect;
+
+type NavigationItemFormProps = {
+ appendLabelPublicationStatus: (label: string, entity: ContentTypeEntity, _: boolean) => string;
+ current: Partial;
+ isLoading: boolean;
+ locale: string;
+ onCancel: VoidEffect;
+ onSubmit: SubmitEffect;
+ availableLocale: string[];
+ permissions?: Partial<{ canUpdate: boolean }>;
+ currentNavigation: Pick;
+};
+
+const FALLBACK_ADDITIONAL_FIELDS: Array = [];
+
+export const NavigationItemForm: React.FC = ({
+ availableLocale,
+ isLoading: isPreloading,
+ current = fallbackDefaultValues,
+ onSubmit,
+ onCancel,
+ appendLabelPublicationStatus = appendLabelPublicationStatusFallback,
+ locale,
+ permissions = {},
+ currentNavigation,
+}) => {
+ const { formatMessage } = useIntl();
+
+ const [isLoading, setIsLoading] = useState(isPreloading);
+
+ const { canUpdate } = permissions;
+
+ const [isSingleSelected, setIsSingleSelected] = useState(false);
+
+ const [itemLocaleCopyValue, setItemLocaleCopyValue] = useState();
+
+ const [formValue, setFormValue] = useState(
+ {} as NavigationItemFormSchema
+ );
+
+ const [formError, setFormError] = useState>();
+
+ const configQuery = useConfig();
+
+ const availableAudiences = configQuery.data?.availableAudience ?? [];
+
+ const contentTypes = configQuery.data?.contentTypes ?? [];
+
+ const { toggleNotification } = useNotification();
+
+ const copyItemFromLocaleMutation = useCopyNavigationItemI18n();
+
+ const navigationsQuery = useNavigations();
+
+ const handleChange = (
+ eventOrPath: FormChangeEvent,
+ value?: any,
+ nativeOnChange?: (eventOrPath: FormChangeEvent, value?: any) => void
+ ) => {
+ if (nativeOnChange) {
+ let fieldName = eventOrPath;
+ let fieldValue = value;
+ if (isObject(eventOrPath)) {
+ const { name: targetName, value: targetValue } = eventOrPath.target;
+ fieldName = targetName;
+ fieldValue = isNil(fieldValue) ? targetValue : fieldValue;
+ }
+
+ if (isString(fieldName)) {
+ setFormValueItem(fieldName, fieldValue);
+ }
+
+ return nativeOnChange(eventOrPath, fieldValue);
+ }
+ };
+
+ const setFormValueItem = (path: string, value: any) => {
+ setFormValue(
+ set(
+ {
+ ...formValue,
+ additionalFields: {
+ ...formValue.additionalFields,
+ },
+ updated: true,
+ },
+ path,
+ value
+ )
+ );
+ };
+
+ const encodePayload = (values: NavigationItemFormSchema): NavigationItemFormSchema => {
+ return {
+ ...values,
+ additionalFields:
+ configQuery.data?.additionalFields.reduce((acc, field: NavigationItemAdditionalField) => {
+ const { name, type } = field as ToBeFixed;
+
+ if (name in (values.additionalFields ?? {})) {
+ let val = values.additionalFields[name];
+
+ switch (type) {
+ case 'boolean':
+ val = isBoolean(val) ? `${val}` : val;
+ break;
+ case 'media':
+ val = val ? JSON.stringify(val) : val;
+ break;
+ default:
+ break;
+ }
+ return {
+ ...acc,
+ [name]: val,
+ };
+ }
+ return acc;
+ }, {}) || {},
+ };
+ };
+
+ const decodePayload = (values: NavigationItemFormSchema): NavigationItemFormSchema => {
+ return {
+ ...values,
+ additionalFields:
+ configQuery.data?.additionalFields.reduce((acc, field: NavigationItemAdditionalField) => {
+ const { name, type } = field as ToBeFixed;
+
+ if (name in (values.additionalFields ?? {})) {
+ let val = values.additionalFields[name];
+
+ switch (type) {
+ case 'boolean':
+ val = val === 'true' ? true : false;
+ break;
+ case 'media':
+ val = val ? JSON.parse(val) : val;
+ break;
+ default:
+ break;
+ }
+ return {
+ ...acc,
+ [name]: val,
+ };
+ }
+ return acc;
+ }, {}) || {},
+ };
+ };
+
+ const submit = async (e: React.MouseEvent, values: NavigationItemFormSchema) => {
+ e.preventDefault();
+
+ const sanitizedValues = encodePayload(values);
+ const {
+ success,
+ data: payload,
+ error,
+ } = navigationItemFormSchema({
+ isSingleSelected,
+ additionalFields: configQuery.data?.additionalFields ?? FALLBACK_ADDITIONAL_FIELDS,
+ }).safeParse(sanitizedValues);
+
+ if (success) {
+ const title = !!payload.title.trim()
+ ? payload.title.trim()
+ : payload.type === 'INTERNAL'
+ ? getDefaultTitle(payload?.related?.toString(), payload.relatedType, isSingleSelected)
+ : '';
+
+ setIsLoading(true);
+
+ const uiRouterKey = await generateUiRouterKey(
+ payload.type === 'INTERNAL'
+ ? {
+ slugify: slugifyMutation.mutateAsync,
+ title,
+ related: payload.related,
+ relatedType: payload.relatedType,
+ }
+ : { slugify: slugifyMutation.mutateAsync, title }
+ );
+
+ slugifyMutation.reset();
+
+ setIsLoading(false);
+
+ if (!uiRouterKey) {
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage(getTrad('popup.item.form.uiRouter.unableToRender')),
+ });
+ return;
+ }
+
+ onSubmit(
+ payload.type === 'INTERNAL'
+ ? {
+ ...payload,
+ title,
+ uiRouterKey,
+ }
+ : {
+ ...payload,
+ title,
+ uiRouterKey,
+ }
+ );
+ } else if (error) {
+ setFormError(
+ error.issues.reduce((acc, err) => {
+ return {
+ ...acc,
+ [err.path.join('.')]: err.message,
+ };
+ }, {} as FormItemErrorSchema)
+ );
+ }
+ };
+
+ const renderError = (error: string): string | undefined => {
+ const errorOccurence = get(formError, error);
+ if (errorOccurence) {
+ return formatMessage(getTrad(error));
+ }
+ return undefined;
+ };
+
+ const initialRelatedTypeSelected = current.type === 'INTERNAL' ? current.relatedType : undefined;
+ const {
+ path: currentPath,
+ type: currentType,
+ title: currentTitle,
+ autoSync: autoSyncEnabled,
+ } = formValue;
+
+ const { relatedType: currentRelatedType, related: currentRelated } =
+ formValue.type === 'INTERNAL'
+ ? formValue
+ : {
+ related: undefined,
+ relatedType: undefined,
+ };
+
+ const isExternal = currentType === 'EXTERNAL';
+ const isInternal = currentType === 'INTERNAL';
+
+ const pathSourceName = isExternal ? 'externalPath' : 'path';
+
+ const submitDisabled = (isInternal && !isSingleSelected && isNil(currentRelated)) || isLoading;
+
+ const contentTypeItemsQuery = useContentTypeItems({
+ uid: currentRelatedType ?? '',
+ locale,
+ });
+
+ const slugifyMutation = useSlug();
+
+ const availableLocaleOptions = useMemo(
+ () =>
+ availableLocale.map((locale, index) => ({
+ key: `${locale}-${index}`,
+ value: locale,
+ label: locale,
+ })),
+ [availableLocale]
+ );
+
+ const audienceOptions = useMemo(
+ () =>
+ availableAudiences.map((item) => ({
+ value: item.documentId ?? 0,
+ label: item.name ?? ' ',
+ })),
+ [availableAudiences]
+ );
+
+ const getDefaultTitle = useCallback(
+ (related: string | undefined, relatedType: string | undefined, isSingleSelected: boolean) => {
+ let selectedEntity;
+
+ if (isSingleSelected) {
+ selectedEntity = contentTypeItemsQuery.data?.find(
+ (_) => _.uid === relatedType || _.__collectionUid === relatedType
+ );
+
+ if (!selectedEntity) {
+ return contentTypes.find((_) => _.uid === relatedType)?.contentTypeName;
+ }
+ } else {
+ const entity = contentTypeItemsQuery.data?.find(({ documentId }) => documentId === related);
+
+ selectedEntity = {
+ ...(entity || {
+ documentId: null,
+ }),
+ __collectionUid: relatedType,
+ };
+ }
+ return extractRelatedItemLabel(selectedEntity as AnyEntity, configQuery.data);
+ },
+ [contentTypeItemsQuery.data, configQuery.data, contentTypes]
+ );
+
+ const navigationItemTypeOptions = (['INTERNAL', 'EXTERNAL', 'WRAPPER'] as const).map((key) => {
+ return {
+ key,
+ value: key,
+ label: formatMessage(getTrad(`popup.item.form.type.${key.toLowerCase()}.label`)),
+ };
+ });
+
+ const relatedSelectOptions = sortBy(
+ contentTypeItemsQuery.data?.map((item) => {
+ const label = appendLabelPublicationStatus(
+ extractRelatedItemLabel(
+ {
+ ...item,
+ __collectionUid: currentRelatedType,
+ },
+ configQuery.data
+ ),
+ item,
+ false
+ );
+
+ return {
+ key: item?.documentId?.toString(),
+ value: item?.documentId?.toString(),
+ label: label,
+ };
+ }) ?? [],
+ (item) => item.key
+ );
+
+ const relatedTypeSelectOptions = useMemo(
+ () =>
+ sortBy(
+ configQuery.data?.contentTypes
+ ?.filter((contentType) => {
+ if (contentType.isSingle) {
+ return !!(
+ currentRelatedType &&
+ [currentRelatedType, initialRelatedTypeSelected].includes(contentType.uid)
+ );
+ }
+
+ return true;
+ })
+ .map((item) => {
+ return {
+ key: item.uid,
+ value: item.uid,
+ label: item.contentTypeName,
+ };
+ }),
+ (item) => item.key
+ ),
+ [configQuery.data, currentRelatedType]
+ );
+
+ const thereAreNoMoreContentTypes = isEmpty(relatedSelectOptions);
+
+ const onCopyFromLocale = useCallback(
+ async (event: React.BaseSyntheticEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const source = navigationsQuery.data?.find(({ locale }) => locale === itemLocaleCopyValue);
+
+ if (source) {
+ setIsLoading(true);
+
+ copyItemFromLocaleMutation.mutate(
+ {
+ target: currentNavigation.documentId,
+ structureId: current.structureId,
+ source: source.documentId,
+ },
+ {
+ onSuccess(data) {
+ copyItemFromLocaleMutation.reset();
+
+ const { type, externalPath, path, related, title, uiRouterKey } = data;
+ const { __type, documentId } = related ?? {};
+
+ setFormValueItem('type', type);
+ setFormValueItem('externalPath', externalPath ?? undefined);
+ setFormValueItem('path', path ?? undefined);
+ setFormValueItem('title', title);
+ setFormValueItem('uiRouterKey', uiRouterKey);
+
+ if (__type && documentId) {
+ setFormValueItem('related', documentId);
+ setFormValueItem('relatedType', __type);
+ }
+ },
+ onSettled() {
+ setIsLoading(false);
+ },
+ }
+ );
+ }
+ },
+ [setIsLoading, copyItemFromLocaleMutation, navigationsQuery]
+ );
+
+ useEffect(() => {
+ setFormValue(
+ decodePayload({
+ ...fallbackDefaultValues,
+ ...current,
+ } as NavigationItemFormSchema)
+ );
+ }, [current]);
+
+ useEffect(() => {
+ if (currentRelatedType) {
+ const relatedType = configQuery.data?.contentTypes.find(
+ (contentType) => contentType.uid === currentRelatedType
+ );
+
+ if (relatedType) {
+ setIsSingleSelected(relatedType.isSingle);
+
+ if (relatedType.isSingle && contentTypeItemsQuery.data?.length) {
+ const nextRelated = contentTypeItemsQuery.data[0];
+
+ if (nextRelated) {
+ setFormValueItem('related', nextRelated.documentId);
+ }
+ }
+ }
+ }
+ }, [currentRelatedType, configQuery.data, contentTypeItemsQuery.data]);
+
+ useEffect(() => {
+ if (
+ autoSyncEnabled &&
+ currentType === 'INTERNAL' &&
+ currentRelated &&
+ currentRelatedType &&
+ configQuery.data
+ ) {
+ const relatedItem = contentTypeItemsQuery.data?.find((item) => {
+ return item.documentId === currentRelated;
+ });
+
+ if (relatedItem) {
+ const { contentTypesNameFields, pathDefaultFields } = configQuery.data;
+
+ const nextPath = pathDefaultFields[currentRelatedType]?.reduce(
+ (acc, field) => {
+ return acc ? acc : relatedItem?.[field];
+ },
+ undefined
+ );
+
+ const nextTitle = (contentTypesNameFields[currentRelatedType] ?? [])
+ .concat(contentTypesNameFields.default ?? [])
+ .reduce((acc, field) => {
+ return acc ? acc : relatedItem?.[field];
+ }, undefined);
+
+ const batch: Array<{ name: keyof NavigationItemFormSchema; value: string }> = [];
+
+ if (nextPath && nextPath !== currentPath) {
+ batch.push({ name: 'path', value: nextPath });
+ }
+
+ if (nextTitle && nextTitle !== currentTitle) {
+ batch.push({ name: 'title', value: nextTitle });
+ }
+
+ setTimeout(() => {
+ batch.forEach((next) => {
+ setFormValueItem(next.name as any, next.value);
+ });
+ }, 100);
+ }
+ }
+ }, [
+ currentTitle,
+ currentPath,
+ autoSyncEnabled,
+ currentType,
+ currentRelated,
+ currentRelatedType,
+ configQuery.data,
+ contentTypeItemsQuery.data,
+ ]);
+
+ return (
+ <>
+
+
+
+
+ submit(e, formValue)}
+ handleCancel={onCancel}
+ submitDisabled={submitDisabled}
+ canUpdate={canUpdate}
+ />
+ >
+ );
+};
+
+const appendLabelPublicationStatusFallback = () => '';
diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/types.ts b/admin/src/pages/HomePage/components/NavigationItemForm/types.ts
new file mode 100644
index 00000000..2594e63d
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemForm/types.ts
@@ -0,0 +1,51 @@
+import { NavigationItemTypeSchema } from '../../../../api/validators';
+import { NavigationItemAdditionalField } from '../../../../schemas';
+
+export type GetContentTypeEntitiesPayload = {
+ modelUID: string;
+ query: ContentTypeSearchQuery;
+ locale: string;
+};
+
+export interface PluginPermissions {
+ canUpdate?: boolean;
+ canRead?: boolean;
+}
+
+export type ContentTypeSearchQuery = Record;
+export type RawFormPayload = {
+ type: NavigationItemTypeSchema;
+ autoSync?: boolean;
+ related?: string;
+ relatedType?: string;
+ audience: number[];
+ menuAttached: boolean;
+ title: string;
+ externalPath: string | null;
+ path: string | null;
+ additionalFields: NavigationItemAdditionalField;
+ updated: boolean;
+};
+
+export type SanitizedFormPayload = {
+ title: string;
+ type: NavigationItemTypeSchema;
+ menuAttached: boolean;
+ path?: string | null;
+ externalPath?: string | null;
+ related: string | undefined;
+ relatedType: string | undefined;
+ isSingle: boolean;
+ singleRelatedItem: ContentTypeEntity | undefined;
+ uiRouterKey: string | undefined;
+};
+
+export interface ContentTypeEntity {
+ id: number;
+ documentId: string;
+ uid?: string;
+ __collectionUid?: string;
+ label?: string;
+}
+
+export type PluginConfigNameFields = Record;
diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/utils/form.ts b/admin/src/pages/HomePage/components/NavigationItemForm/utils/form.ts
new file mode 100644
index 00000000..c7049f12
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemForm/utils/form.ts
@@ -0,0 +1,146 @@
+import { z } from 'zod';
+import { NavigationItemAdditionalField } from '../../../../../schemas';
+
+const externalPathRegexps = [
+ /^mailto:[\w-\.]+@([\w-]+\.)+[\w-]{2,}$/,
+ /^tel:(\+\d{1,3})?[\s]?(\(?\d{2,3}\)?)?[\s.-]?(\d{3})?[\s.-]?\d{3,4}$/,
+ /^#.*/,
+ /(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,})/,
+];
+
+interface FormSchemaBuilderInput {
+ isSingleSelected?: boolean;
+ additionalFields: Array;
+}
+
+const navigationItemCommon = ({ additionalFields }: FormSchemaBuilderInput) =>
+ z.object({
+ title: z.string(),
+ autoSync: z.boolean().optional(),
+ removed: z.boolean().optional(),
+ updated: z.boolean().optional(),
+ uiRouterKey: z.string(),
+ levelPath: z.string().optional(),
+ isMenuAllowedLevel: z.boolean().optional(),
+ parentAttachedToMenu: z.boolean().optional(),
+ viewId: z.number().optional(),
+ structureId: z.string().optional(),
+ menuAttached: z.boolean().optional(),
+ collapsed: z.boolean().optional(),
+ isSearchActive: z.boolean().optional(),
+ viewParentId: z.number().optional(),
+ id: z.number().optional(),
+ documentId: z.string().optional(),
+ audience: z.string().array().optional(),
+ order: z.number().optional(),
+ items: z.any().array().optional(),
+ additionalFields: z.object(
+ additionalFields.reduce<
+ Record<
+ string,
+ | z.ZodString
+ | z.ZodBoolean
+ | z.ZodAny
+ | z.ZodArray
+ | z.ZodOptional
+ | z.ZodOptional
+ | z.ZodOptional
+ | z.ZodOptional>
+ >
+ >((acc, field) => {
+ if (typeof field === 'string') {
+ return acc;
+ }
+
+ switch (field.type) {
+ case 'string':
+ acc[field.name] = field.required ? z.string() : z.string().optional();
+ break;
+ case 'boolean':
+ acc[field.name] = field.required ? z.boolean() : z.boolean().optional();
+ case 'media':
+ acc[field.name] = field.required ? z.any() : z.any().optional();
+ case 'select': {
+ if (field.multi) {
+ acc[field.name] = field.required ? z.string().array() : z.string().array().optional();
+ } else {
+ acc[field.name] = field.required ? z.string() : z.string().optional();
+ }
+
+ break;
+ }
+ default:
+ break;
+ }
+
+ return acc;
+ }, {})
+ ),
+ });
+
+const navigationInternalItemFormSchema = ({
+ additionalFields,
+ isSingleSelected,
+}: FormSchemaBuilderInput) =>
+ navigationItemCommon({
+ additionalFields,
+ isSingleSelected,
+ }).extend({
+ type: z.literal('INTERNAL'),
+ path: z.string(),
+ externalPath: z.string().optional(),
+ relatedType: z.string(),
+ related: isSingleSelected ? z.string().optional() : z.string(),
+ });
+
+const navigationExternalItemFormSchema = ({
+ isSingleSelected,
+ additionalFields,
+}: FormSchemaBuilderInput) =>
+ navigationItemCommon({
+ additionalFields,
+ isSingleSelected,
+ }).extend({
+ type: z.literal('EXTERNAL'),
+ path: z.string().or(z.null()).optional(),
+ externalPath: z
+ .string()
+ .min(1)
+ .refine((path) => externalPathRegexps.some((re) => re.test(path))),
+ relatedType: z.string().optional(),
+ related: z.string().optional(),
+ });
+
+const navigationWrapperItemFormSchema = ({
+ isSingleSelected,
+ additionalFields,
+}: FormSchemaBuilderInput) =>
+ navigationItemCommon({
+ additionalFields,
+ isSingleSelected,
+ }).extend({
+ type: z.literal('WRAPPER'),
+ path: z.string().or(z.null()).optional(),
+ });
+
+export type NavigationItemFormSchema = z.infer>;
+export const navigationItemFormSchema = (input: FormSchemaBuilderInput) =>
+ z.discriminatedUnion('type', [
+ navigationExternalItemFormSchema(input),
+ navigationInternalItemFormSchema(input),
+ navigationWrapperItemFormSchema(input),
+ ]);
+
+export const fallbackDefaultValues: NavigationItemFormSchema = {
+ autoSync: true,
+ type: 'INTERNAL',
+ relatedType: '',
+ menuAttached: false,
+ title: '',
+ externalPath: '',
+ path: '',
+ additionalFields: {},
+ audience: [],
+ updated: false,
+ uiRouterKey: '',
+};
diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/utils/hooks.ts b/admin/src/pages/HomePage/components/NavigationItemForm/utils/hooks.ts
new file mode 100644
index 00000000..5fec0c8d
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemForm/utils/hooks.ts
@@ -0,0 +1,15 @@
+import { getFetchClient } from '@strapi/strapi/admin';
+import { useMutation } from '@tanstack/react-query';
+
+import { getApiClient } from '../../../../../api';
+
+export const useSlug = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn(query: string) {
+ return apiClient.slugify(query);
+ },
+ });
+};
diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/utils/properties.ts b/admin/src/pages/HomePage/components/NavigationItemForm/utils/properties.ts
new file mode 100644
index 00000000..834180da
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemForm/utils/properties.ts
@@ -0,0 +1,130 @@
+import { first, isEmpty } from 'lodash';
+
+import {
+ ConfigFromServerSchema,
+ NavigationItemTypeSchema,
+ StrapiContentTypeItemSchema,
+} from '../../../../../api/validators';
+import { extractRelatedItemLabel } from '../../../../HomePage/utils';
+import { NavigationItemFormSchema } from './form';
+
+interface GenerateUiRouterKeyInput {
+ slugify: (s: string) => Promise;
+ title: string;
+ related?: number | string;
+ relatedType?: string;
+ contentTypeItems?: Array;
+ config?: ConfigFromServerSchema;
+}
+
+interface GeneratePreviewPathInput {
+ isExternal?: boolean;
+ currentPath?: string | null;
+ current: Partial;
+ currentType?: NavigationItemTypeSchema;
+ currentRelatedType?: string;
+ currentRelated?: number | string;
+ config?: ConfigFromServerSchema;
+ isSingleSelected?: boolean;
+ contentTypeItems?: Array;
+}
+
+interface GetDefaultPathInput {
+ currentType: NavigationItemTypeSchema;
+ currentRelatedType?: string;
+ currentRelated?: number | string;
+ config?: ConfigFromServerSchema;
+ isSingleSelected?: boolean;
+ contentTypeItems?: Array;
+}
+
+export const generateUiRouterKey = async ({
+ slugify,
+ title,
+ config,
+ related,
+ relatedType,
+ contentTypeItems,
+}: GenerateUiRouterKeyInput): Promise => {
+ if (title) {
+ return title ? await slugify(title) : undefined;
+ } else if (related) {
+ const relationTitle = extractRelatedItemLabel(
+ {
+ ...(contentTypeItems?.find((_) => _.documentId === related.toString()) ?? {
+ documentId: '',
+ id: 0,
+ }),
+ __collectionUid: relatedType,
+ },
+ config
+ );
+
+ return relationTitle ? await slugify(relationTitle) : undefined;
+ }
+
+ return undefined;
+};
+
+export const getDefaultPath = ({
+ currentType,
+ config,
+ contentTypeItems,
+ currentRelated,
+ currentRelatedType,
+ isSingleSelected,
+}: GetDefaultPathInput): string => {
+ if (currentType !== 'INTERNAL') return '';
+
+ if (!currentRelatedType) {
+ return '';
+ }
+
+ const pathDefaultFields = config?.pathDefaultFields[currentRelatedType] ?? [];
+
+ if (isEmpty(currentType) && !isEmpty(pathDefaultFields)) {
+ const selectedEntity = isSingleSelected
+ ? first(contentTypeItems ?? [])
+ : contentTypeItems?.find(({ id }) => id === currentRelated);
+
+ const pathDefaultValues = pathDefaultFields
+ .map((field: string) => selectedEntity?.[field] ?? '')
+ .filter((value: string) => !!value.toString().trim());
+
+ return pathDefaultValues[0] ?? '';
+ }
+
+ return '';
+};
+
+export const generatePreviewPath = ({
+ currentPath,
+ isExternal,
+ current,
+ currentType = 'INTERNAL',
+ config,
+ contentTypeItems,
+ currentRelated,
+ currentRelatedType,
+ isSingleSelected,
+}: GeneratePreviewPathInput): string | undefined => {
+ if (!isExternal) {
+ const itemPath =
+ isEmpty(currentPath) || currentPath === '/'
+ ? getDefaultPath({
+ currentType,
+ config,
+ contentTypeItems,
+ currentRelated,
+ currentRelatedType,
+ isSingleSelected,
+ })
+ : currentPath || '';
+
+ const result = `${current.levelPath !== '/' ? `${current.levelPath}` : ''}/${itemPath}`;
+
+ return result.replace('//', '/');
+ }
+
+ return undefined;
+};
diff --git a/admin/src/components/NavigationItemList/Wrapper.js b/admin/src/pages/HomePage/components/NavigationItemList/Wrapper.ts
similarity index 75%
rename from admin/src/components/NavigationItemList/Wrapper.js
rename to admin/src/pages/HomePage/components/NavigationItemList/Wrapper.ts
index 5a1ebe59..ac8ea385 100644
--- a/admin/src/components/NavigationItemList/Wrapper.js
+++ b/admin/src/pages/HomePage/components/NavigationItemList/Wrapper.ts
@@ -1,8 +1,10 @@
import styled from 'styled-components';
-const Wrapper = styled.div`
+const Wrapper = styled.div<{ level?: number }>`
position: relative;
- ${({ level, theme }) => level && `
+ ${({ level, theme }) =>
+ level &&
+ `
&::before {
content: "";
display: block;
@@ -19,4 +21,4 @@ const Wrapper = styled.div`
`};
`;
-export default Wrapper;
\ No newline at end of file
+export default Wrapper;
diff --git a/admin/src/pages/HomePage/components/NavigationItemList/index.tsx b/admin/src/pages/HomePage/components/NavigationItemList/index.tsx
new file mode 100644
index 00000000..225eeaa5
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemList/index.tsx
@@ -0,0 +1,75 @@
+import { FC } from 'react';
+
+import { NavigationItemSchema } from '../../../../api/validators';
+import {
+ Item,
+ OnItemCollapseEffect,
+ OnItemEditEffect,
+ OnItemLevelAddEffect,
+ OnItemRemoveEffect,
+ OnItemReorderEffect,
+ OnItemRestoreEffect,
+} from '../NavigationItemListItem';
+import Wrapper from './Wrapper';
+
+interface Props {
+ isParentAttachedToMenu?: boolean;
+ items?: Array;
+ level?: number;
+ levelPath?: string;
+ onItemEdit: OnItemEditEffect;
+ onItemLevelAdd: OnItemLevelAddEffect;
+ onItemRemove: OnItemRemoveEffect;
+ onItemRestore: OnItemRestoreEffect;
+ onItemReOrder: OnItemReorderEffect;
+ onItemToggleCollapse: OnItemCollapseEffect;
+ displayFlat?: boolean;
+ permissions: { canUpdate: boolean; canAccess: boolean };
+ structurePrefix: string;
+ viewParentId?: number;
+ locale: string;
+}
+
+export const List: FC = ({
+ isParentAttachedToMenu = false,
+ items,
+ level = 0,
+ levelPath = '',
+ onItemEdit,
+ onItemLevelAdd,
+ onItemRemove,
+ onItemRestore,
+ onItemReOrder,
+ onItemToggleCollapse,
+ displayFlat,
+ permissions,
+ structurePrefix,
+ viewParentId,
+ locale,
+}) => (
+
+ {items?.map((item, index) => {
+ return (
+
+ );
+ })}
+
+);
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardBadge/index.ts b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardBadge/index.ts
new file mode 100644
index 00000000..2f2a77c6
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardBadge/index.ts
@@ -0,0 +1,22 @@
+import { Badge } from '@strapi/design-system';
+import styled from 'styled-components';
+
+export const ItemCardBadge = styled(Badge)`
+ border: 1px solid ${({ theme, borderColor }) => theme.colors[borderColor]};
+
+ ${({ small, theme }) =>
+ small &&
+ `
+ padding: ${theme.spaces[1]} ${theme.spaces[2]};
+ margin: 0px ${theme.spaces[3]};
+ vertical-align: middle;
+
+ cursor: default;
+
+ span {
+ font-size: .65rem;
+ line-height: 1;
+ vertical-align: middle;
+ }
+ `}
+`;
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/Wrapper.tsx b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/Wrapper.tsx
new file mode 100644
index 00000000..5f305970
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/Wrapper.tsx
@@ -0,0 +1,15 @@
+import { CardTitle } from '@strapi/design-system';
+import styled from 'styled-components';
+
+export const CardItemTitle = styled(CardTitle)`
+ width: 100%;
+
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+
+ > div > * {
+ margin: 0px ${({ theme }) => theme.spaces[1]};
+ }
+`;
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/icons.tsx b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/icons.tsx
new file mode 100644
index 00000000..a306e5ec
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/icons.tsx
@@ -0,0 +1,7 @@
+import { ArrowClockwise, Eye, Pencil, Trash, Typhoon } from '@strapi/icons';
+
+export const pencilIcon = ;
+export const refreshIcon = ;
+export const trashIcon = ;
+export const eyeIcon = ;
+export const arrowClockwise =
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/index.tsx b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/index.tsx
new file mode 100644
index 00000000..71cf0a07
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardHeader/index.tsx
@@ -0,0 +1,112 @@
+import { IconButton as BaseIconButton, IconButtonGroup, Flex, Typography } from '@strapi/design-system';
+import { FC, MutableRefObject, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import styled from 'styled-components';
+
+import { getTrad } from '../../../../../translations';
+import { VoidEffect } from '../../../../../types';
+import DragButton from '../../DragButton';
+import { ItemCardBadge } from '../ItemCardBadge';
+import { CardItemTitle } from './Wrapper';
+import { eyeIcon, pencilIcon, arrowClockwise, trashIcon } from './icons';
+
+interface IProps {
+ title: string;
+ path?: string;
+ icon: ReactNode;
+ removed?: boolean;
+ canUpdate: boolean;
+ onItemRemove: VoidEffect;
+ onItemEdit: VoidEffect;
+ onItemRestore: VoidEffect;
+ dragRef: MutableRefObject;
+ isSearchActive?: boolean;
+}
+
+const wrapperStyle = { zIndex: 2 };
+const pathWrapperStyle = { maxWidth: '425px' };
+
+export const ItemCardHeader: FC = ({
+ title,
+ path,
+ icon,
+ removed,
+ canUpdate,
+ onItemRemove,
+ onItemEdit,
+ onItemRestore,
+ dragRef,
+ isSearchActive,
+}) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+ {canUpdate && }
+
+ {title}
+
+
+ {path}
+
+ {icon}
+
+
+ {removed && (
+
+ {formatMessage(getTrad('components.navigationItem.badge.removed'))}
+
+ )}
+
+
+ {canUpdate && (
+ <>
+ {removed ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+ );
+};
+
+const IconButton = styled(BaseIconButton) <{ isActive?: boolean }>`
+ transition: background-color 0.3s ease-in;
+ ${({ isActive, theme }) => (isActive ? `background-color: ${theme.colors.neutral150} ;` : '')}
+`;
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardRemovedOverlay/index.ts b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardRemovedOverlay/index.ts
new file mode 100644
index 00000000..81bd8e92
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/ItemCardRemovedOverlay/index.ts
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+export const ItemCardRemovedOverlay = styled.div`
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ left: 0;
+ right: 0;
+ z-index: 1;
+
+ background: rgba(255, 255, 255, 0.75);
+`;
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/Wrapper.ts b/admin/src/pages/HomePage/components/NavigationItemListItem/Wrapper.ts
new file mode 100644
index 00000000..00b98b55
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/Wrapper.ts
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div<{
+ level?: number;
+ isLast?: boolean;
+}>`
+ position: relative;
+ margin-top: ${({ theme }) => theme.spaces[2]};
+ margin-left: ${({ level }) => level && '54px'};
+
+ ${({ level, theme, isLast }) =>
+ level &&
+ `
+ &::before {
+ ${!isLast && 'content: "";'}
+ display: block;
+ top: ${theme.spaces[1]};
+ left: -24px;
+ position: absolute;
+ height: calc(100% + ${theme.spaces[2]});
+ width: 19px;
+ border: 0px solid transparent;
+ border-left: 4px solid ${theme.colors.neutral300};
+ }
+
+ &::after {
+ content: "";
+ display: block;
+ height: 22px;
+ width: 19px;
+ position: absolute;
+ top: ${theme.spaces[1]};
+ left: -${theme.spaces[6]};
+
+ background: transparent;
+ border: 4px solid ${theme.colors.neutral300};
+ border-top: transparent;
+ border-right: transparent;
+ border-radius: 0 0 0 100%;
+ }
+ `};
+`;
+
+export default Wrapper;
diff --git a/admin/src/pages/HomePage/components/NavigationItemListItem/index.tsx b/admin/src/pages/HomePage/components/NavigationItemListItem/index.tsx
new file mode 100644
index 00000000..65bcad86
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemListItem/index.tsx
@@ -0,0 +1,442 @@
+import { Card, CardBody, Divider, Flex, Link, TextButton, Typography } from '@strapi/design-system';
+import { ArrowRight, Cog, Earth, Link as LinkIcon, Plus } from '@strapi/icons';
+import { isEmpty, isNumber } from 'lodash';
+import { useCallback, useEffect, useRef } from 'react';
+import { DropTargetMonitor, useDrag, useDrop } from 'react-dnd';
+import { useIntl } from 'react-intl';
+import { useTheme } from 'styled-components';
+
+import { NavigationItemSchema, StrapiContentTypeItemSchema } from '../../../../api/validators';
+import { getTrad } from '../../../../translations';
+import { Effect } from '../../../../types';
+import { useConfig, useContentTypeItems, useContentTypes } from '../../hooks';
+import { extractRelatedItemLabel, mapServerNavigationItem } from '../../utils';
+import { CollapseButton } from '../CollapseButton';
+import { NavigationItemFormSchema } from '../NavigationItemForm';
+import { List } from '../NavigationItemList';
+import { ItemCardBadge } from './ItemCardBadge';
+import { ItemCardHeader } from './ItemCardHeader';
+import { ItemCardRemovedOverlay } from './ItemCardRemovedOverlay';
+import Wrapper from './Wrapper';
+
+export type OnItemReorderEffect = Effect<{
+ item: NavigationItemFormSchema;
+ newOrder: number;
+}>;
+
+export type OnItemLevelAddEffect = (
+ event: MouseEvent,
+ viewParentId?: number,
+ isMenuAllowedLevel?: boolean,
+ levelPath?: string,
+ parentAttachedToMenu?: boolean,
+ structureId?: string,
+ maxOrder?: number
+) => void;
+
+export type OnItemEditEffect = Effect<{
+ item: NavigationItemFormSchema & {
+ isMenuAllowedLevel?: boolean;
+ isParentAttachedToMenu?: boolean;
+ };
+ levelPath: string;
+ isParentAttachedToMenu?: boolean;
+}>;
+
+export type OnItemRemoveEffect = Effect;
+
+export type OnItemRestoreEffect = Effect;
+
+export type OnItemCollapseEffect = Effect;
+
+interface Props {
+ isParentAttachedToMenu?: boolean;
+ item: NavigationItemSchema;
+ level?: number;
+ levelPath?: string;
+ onItemEdit: OnItemEditEffect;
+ onItemLevelAdd: OnItemLevelAddEffect;
+ onItemRemove: OnItemRemoveEffect;
+ onItemRestore: OnItemRestoreEffect;
+ onItemReOrder: OnItemReorderEffect;
+ onItemToggleCollapse: OnItemCollapseEffect;
+ displayFlat?: boolean;
+ permissions: { canUpdate: boolean; canAccess: boolean };
+ isLast?: boolean;
+ displayChildren?: boolean;
+ structureId: string;
+ viewParentId?: number;
+ locale: string;
+}
+
+export const Item: React.FC = ({
+ item,
+ isLast = false,
+ level = 0,
+ levelPath = '',
+ isParentAttachedToMenu,
+ onItemLevelAdd,
+ onItemRemove,
+ onItemRestore,
+ onItemEdit,
+ onItemReOrder,
+ onItemToggleCollapse,
+ displayChildren,
+ permissions,
+ structureId,
+ viewParentId,
+ locale,
+}) => {
+ const mappedItem = mapServerNavigationItem(item, true);
+
+ const { formatMessage } = useIntl();
+
+ const configQuery = useConfig();
+
+ const isExternal = mappedItem.type === 'EXTERNAL';
+ const isWrapper = mappedItem.type === 'WRAPPER';
+ // TODO: is handled by publish flow
+ const isHandledByPublishFlow = true;
+
+ const isNextMenuAllowedLevel = isNumber(configQuery.data?.allowedLevels)
+ ? level < configQuery.data.allowedLevels - 1
+ : true;
+ const isMenuAllowedLevel = isNumber(configQuery.data?.allowedLevels)
+ ? level < configQuery.data.allowedLevels
+ : true;
+
+ const hasChildren = !isEmpty(item.items) && !isExternal && !displayChildren;
+ const absolutePath = isExternal
+ ? undefined
+ : `${levelPath === '/' ? '' : levelPath}/${mappedItem.path === '/' ? '' : mappedItem.path}`.replace(
+ '//',
+ '/'
+ );
+
+ const contentTypeItemsQuery = useContentTypeItems({
+ uid: mappedItem.type === 'INTERNAL' ? (mappedItem.relatedType ?? '') : '',
+ locale,
+ });
+
+ const contentTypesQuery = useContentTypes();
+
+ const contentType = contentTypesQuery.data?.find((_) =>
+ mappedItem.type === 'INTERNAL' ? _.uid === mappedItem.relatedType : false
+ );
+
+ const relatedItem = contentTypeItemsQuery.data?.find((contentTypeItem) =>
+ mappedItem.type === 'INTERNAL' ? contentTypeItem.documentId === mappedItem.related : false
+ ) ?? { documentId: '', id: 0 };
+
+ const isPublished = !!relatedItem?.publishedAt;
+
+ const relatedItemLabel = !isExternal
+ ? extractRelatedItemLabel(relatedItem, configQuery.data)
+ : '';
+
+ const relatedTypeLabel = contentType?.info.displayName ?? '';
+
+ const relatedBadgeColor = isPublished ? 'success' : 'secondary';
+
+ const canUpdate = permissions.canUpdate;
+
+ const dragRef = useRef(null);
+ const dropRef = useRef(null);
+ const previewRef = useRef(null);
+
+ const [, drop] = useDrop({
+ accept: `navigation-item_${levelPath}`,
+ hover(
+ hoveringItem: NavigationItemSchema,
+ monitor: DropTargetMonitor
+ ) {
+ const dragIndex = hoveringItem.order ?? 0;
+ const dropIndex = item.order ?? 0;
+
+ // Don't replace items with themselves
+ if (dragIndex === dropIndex) {
+ return;
+ }
+
+ const hoverBoundingRect = dropRef.current!.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+
+ if (!clientOffset) {
+ return;
+ }
+
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // Place the hovering item before or after the drop target
+ const isAfter = hoverClientY > hoverMiddleY;
+ const newOrder = isAfter ? (item.order ?? 0) + 0.5 : (item.order ?? 0) - 0.5;
+
+ if (dragIndex < dropIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+ // Dragging upwards
+ if (dragIndex > dropIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ onItemReOrder({
+ item: mapServerNavigationItem(hoveringItem, true),
+ newOrder,
+ });
+ },
+ collect: (monitor) => ({
+ isOverCurrent: monitor.isOver({ shallow: true }),
+ }),
+ });
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: `navigation-item_${levelPath}`,
+ item: () => item,
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ });
+
+ const refs = {
+ dragRef: drag(dragRef) as any,
+ dropRef: drop(dropRef) as any,
+ previewRef: dragPreview(previewRef) as any,
+ };
+
+ const generatePreviewUrl = (entity?: StrapiContentTypeItemSchema) => {
+ const isSingle = contentType?.kind === 'singleType';
+ const entityLocale = entity?.locale ? `?plugins[i18n][locale]=${entity?.locale}` : '';
+
+ return `/admin/content-manager/${isSingle ? 'single-types' : 'collection-types'}/${contentType?.uid}${!isSingle ? '/' + entity?.documentId : ''}${entityLocale}`;
+ };
+
+ const onNewItemClick = useCallback(
+ (event: MouseEvent) =>
+ canUpdate &&
+ onItemLevelAdd(
+ event,
+ mappedItem.viewId,
+ isNextMenuAllowedLevel,
+ absolutePath,
+ mappedItem.menuAttached,
+ `${structureId}.${mappedItem.items?.length ?? 0}`,
+ Math.max(...(mappedItem.items?.map(({ order }) => order) ?? []))
+ ),
+ [
+ mappedItem.viewId,
+ isNextMenuAllowedLevel,
+ absolutePath,
+ mappedItem.menuAttached,
+ structureId,
+ mappedItem.items,
+ canUpdate,
+ ]
+ );
+
+ useEffect(() => {
+ if (mappedItem.isSearchActive) {
+ refs.dropRef?.current?.scrollIntoView?.({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'center',
+ });
+ }
+ }, [mappedItem.isSearchActive, refs.dropRef.current]);
+
+ const theme = useTheme();
+
+ return (
+
+
+ {mappedItem.removed && }
+
+
+ : isWrapper ? : }
+ onItemRemove={() => onItemRemove({ ...item, viewParentId })}
+ onItemEdit={() => {
+ const { __type: relatedType, documentId: related } = item.related ?? {};
+
+ if (
+ item.type !== 'EXTERNAL' &&
+ item.type !== 'INTERNAL' &&
+ item.type !== 'WRAPPER'
+ ) {
+ return;
+ }
+
+ onItemEdit({
+ item:
+ item.type === 'INTERNAL'
+ ? {
+ ...item,
+ type: 'INTERNAL',
+ isMenuAllowedLevel,
+ isParentAttachedToMenu,
+ isSearchActive: false,
+ relatedType: relatedType ?? '',
+ related: related ?? '',
+ additionalFields: item.additionalFields ?? {},
+ items: item.items ?? [],
+ autoSync: item.autoSync ?? true,
+ externalPath: undefined,
+ viewParentId,
+ audience: item.audience?.map(({ documentId }) => documentId) ?? [],
+ }
+ : item.type === 'EXTERNAL'
+ ? {
+ ...item,
+ type: 'EXTERNAL',
+ isMenuAllowedLevel,
+ isParentAttachedToMenu,
+ isSearchActive: false,
+ relatedType: undefined,
+ related: undefined,
+ additionalFields: item.additionalFields ?? {},
+ items: item.items ?? [],
+ autoSync: item.autoSync ?? true,
+ externalPath: item.externalPath ?? '',
+ viewParentId,
+ audience: item.audience?.map(({ documentId }) => documentId) ?? [],
+ }
+ : {
+ ...item,
+ type: 'WRAPPER',
+ isMenuAllowedLevel,
+ isParentAttachedToMenu,
+ isSearchActive: false,
+ additionalFields: item.additionalFields ?? {},
+ items: item.items ?? [],
+ autoSync: item.autoSync ?? true,
+ viewParentId,
+ audience: item.audience?.map(({ documentId }) => documentId) ?? [],
+ },
+ levelPath,
+ isParentAttachedToMenu,
+ });
+ }}
+ onItemRestore={() => onItemRestore({ ...item, viewParentId })}
+ dragRef={refs.dragRef}
+ removed={mappedItem.removed}
+ canUpdate={canUpdate}
+ isSearchActive={mappedItem.isSearchActive}
+ />
+
+
+ {!isExternal && (
+
+
+
+ {!isEmpty(item.items) && (
+ onItemToggleCollapse({ ...item, viewParentId })}
+ collapsed={mappedItem.collapsed}
+ itemsCount={item.items?.length ?? 0}
+ />
+ )}
+ {canUpdate && isNextMenuAllowedLevel && (
+ }
+ onClick={onNewItemClick}
+ >
+
+ {formatMessage(getTrad('components.navigationItem.action.newItem'))}
+
+
+ )}
+
+ {mappedItem.type === 'INTERNAL' && mappedItem.related && !relatedItem.id ? (
+
+
+ {relatedTypeLabel} /
+
+
+ {formatMessage(getTrad('components.navigationItem.related.localeMissing'))}
+
+
+ ) : null}
+ {relatedItemLabel && (
+
+ {isHandledByPublishFlow && (
+
+ {formatMessage(
+ getTrad(
+ `components.navigationItem.badge.${isPublished ? 'published' : 'draft'}`
+ )
+ )}
+
+ )}
+
+ {relatedTypeLabel} /
+
+
+ {relatedItemLabel}
+
+ }
+ >
+
+
+
+ )}
+
+
+ )}
+
+
+ {hasChildren && !mappedItem.removed && !mappedItem.collapsed && (
+
+ )}
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupFooter.tsx b/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupFooter.tsx
new file mode 100644
index 00000000..2548093f
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupFooter.tsx
@@ -0,0 +1,41 @@
+import { FC } from 'react';
+
+import { Button, Modal } from '@strapi/design-system';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../translations';
+import { Effect } from '../../../../types';
+
+interface Props {
+ handleCancel: Effect;
+ handleSubmit?: Effect;
+ submitDisabled?: boolean;
+ canUpdate?: boolean;
+}
+
+export const NavigationItemPopupFooter: FC = ({
+ handleCancel,
+ handleSubmit,
+ submitDisabled,
+ canUpdate,
+}) => {
+ const { formatMessage } = useIntl();
+
+ if (!canUpdate) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupHeader.tsx b/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupHeader.tsx
new file mode 100644
index 00000000..4f1ae413
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemPopup/NavigationItemPopupHeader.tsx
@@ -0,0 +1,34 @@
+import { Modal, Typography } from '@strapi/design-system';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../translations';
+
+export const NavigationItemPopupHeader = ({
+ isNewItem,
+ canUpdate,
+}: {
+ isNewItem?: boolean;
+ canUpdate?: boolean;
+}) => {
+ const { formatMessage } = useIntl();
+
+ let modalType = 'view';
+
+ if (canUpdate) {
+ modalType = isNewItem ? 'new' : 'edit';
+ }
+
+ return (
+
+
+ {formatMessage(getTrad(`popup.item.header.${modalType}`))}
+
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationItemPopup/index.tsx b/admin/src/pages/HomePage/components/NavigationItemPopup/index.tsx
new file mode 100644
index 00000000..2703f654
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationItemPopup/index.tsx
@@ -0,0 +1,91 @@
+import { Modal } from '@strapi/design-system';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+import { NavigationSchema, StrapiContentTypeItemSchema } from '../../../../api/validators';
+import { getTrad } from '../../../../translations';
+import { Effect } from '../../../../types';
+import { isRelationPublished } from '../../utils';
+import { NavigationItemForm, NavigationItemFormSchema, SubmitEffect } from '../NavigationItemForm';
+import { NavigationItemPopupHeader } from './NavigationItemPopupHeader';
+
+interface Props {
+ currentItem?: Partial;
+ isOpen: boolean;
+ isLoading: boolean;
+ onSubmit: SubmitEffect;
+ onClose: Effect;
+ availableLocale: Array;
+ locale: string;
+ permissions?: { canUpdate?: boolean };
+ currentNavigation: Pick;
+}
+
+const NavigationItemPopUp: FC = ({
+ availableLocale,
+ isOpen,
+ isLoading,
+ currentItem = {},
+ onSubmit,
+ onClose,
+ locale,
+ permissions,
+ currentNavigation,
+}) => {
+ const { formatMessage } = useIntl();
+
+ const handleOnSubmit = (payload: NavigationItemFormSchema) => {
+ onSubmit(payload);
+ };
+
+ const appendLabelPublicationStatus = (
+ label: string,
+ item: StrapiContentTypeItemSchema,
+ isCollection: boolean
+ ) => {
+ const appendix = isRelationPublished({
+ relatedRef: item,
+ type: item.isSingle ? 'INTERNAL' : item.type,
+ isCollection,
+ })
+ ? ''
+ : `[${formatMessage(getTrad('notification.navigation.item.relation.status.draft'))}] `.toUpperCase();
+
+ return `${appendix}${label}`;
+ };
+
+ const hasViewId = !!currentItem.viewId;
+
+ return (
+ {
+ if (!isOpen) {
+ onClose({
+ preventDefault() {},
+ stopPropagation() {},
+ target: {},
+ });
+ }
+ }}
+ open={isOpen}
+ >
+
+
+
+
+
+ );
+};
+
+export default NavigationItemPopUp;
diff --git a/admin/src/pages/View/components/NavigationManager/AllNavigations/icons.tsx b/admin/src/pages/HomePage/components/NavigationManager/AllNavigations/icons.tsx
similarity index 50%
rename from admin/src/pages/View/components/NavigationManager/AllNavigations/icons.tsx
rename to admin/src/pages/HomePage/components/NavigationManager/AllNavigations/icons.tsx
index f648e843..44c0e2dd 100644
--- a/admin/src/pages/View/components/NavigationManager/AllNavigations/icons.tsx
+++ b/admin/src/pages/HomePage/components/NavigationManager/AllNavigations/icons.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { Pencil, Trash, Brush } from "@strapi/icons";
+import { Pencil, Trash, Feather } from "@strapi/icons";
export const edit = ;
export const deleteIcon = ;
-export const brushIcon = ;
+export const featherIcon = ;
diff --git a/admin/src/pages/HomePage/components/NavigationManager/AllNavigations/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/AllNavigations/index.tsx
new file mode 100644
index 00000000..6314653d
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/AllNavigations/index.tsx
@@ -0,0 +1,264 @@
+import {
+ Box,
+ Button,
+ Checkbox,
+ Flex,
+ Grid,
+ IconButton,
+ Table,
+ Tbody,
+ Td,
+ Th,
+ Thead,
+ Tr,
+ Typography,
+} from '@strapi/design-system';
+import { prop } from 'lodash/fp';
+import { useCallback, useMemo } from 'react';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../../translations';
+import { useConfig, useLocale } from '../../../hooks';
+import { Footer, FooterBase } from '../Footer';
+import { INITIAL_NAVIGATION } from '../NewNavigation';
+import { CommonProps, ListState, Navigation } from '../types';
+import * as icons from './icons';
+
+interface Props extends ListState, CommonProps {}
+
+export const AllNavigations = ({ navigations, selected, setState }: Props) => {
+ const configQuery = useConfig();
+
+ const hasAnySelected = !!selected.length;
+
+ const { formatMessage } = useIntl();
+
+ const localeQuery = useLocale();
+
+ const toggleSelected = useCallback(
+ () =>
+ setState({
+ navigations,
+ selected: hasAnySelected ? [] : navigations.map((n) => n),
+ view: 'LIST',
+ }),
+ [setState, navigations, hasAnySelected]
+ );
+
+ const currentlySelectedSet = useMemo(() => new Set(selected.map(prop('documentId'))), [selected]);
+
+ const handleSelect = (navigation: Navigation, isSelected: boolean) => () => {
+ setState({
+ navigations,
+ selected: isSelected
+ ? selected.filter(({ documentId }) => documentId !== navigation.documentId)
+ : selected.concat([navigation]),
+ view: 'LIST',
+ });
+ };
+
+ const edit = (navigation: Navigation) => () => {
+ setState({
+ view: 'EDIT',
+ current: navigation,
+ navigation,
+ alreadyUsedNames: navigations.reduce(
+ (acc, { name }) => (name !== navigation.name ? acc.concat([name]) : acc),
+ []
+ ),
+ });
+ };
+
+ const _delete = (navigations: Array) => () => {
+ setState({
+ view: 'DELETE',
+ navigations,
+ });
+ };
+
+ const purgeCache = (navigations: Array) => () => {
+ setState({
+ view: 'CACHE_PURGE',
+ navigations,
+ });
+ };
+
+ const deleteSelected = useCallback(_delete(selected), [_delete]);
+
+ const purgeSelected = useCallback(purgeCache(selected), [purgeCache]);
+
+ const getLocalizations = (focused: Navigation) =>
+ [focused].concat(
+ navigations.filter(
+ (navigation) =>
+ navigation.documentId === focused.documentId &&
+ navigation.locale !== focused.locale
+ )
+ );
+
+ return (
+ <>
+
+
+ {hasAnySelected ? (
+
+
+ {formatMessage(getTrad('popup.navigation.manage.table.hasSelected'), {
+ count: selected.length,
+ })}
+
+
+ {configQuery.data?.isCacheEnabled ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
+ |
+
+
+ {formatMessage(getTrad('popup.navigation.manage.table.id'))}
+
+ |
+
+
+ {formatMessage(getTrad('popup.navigation.manage.table.name'))}
+
+ |
+
+
+ {formatMessage(getTrad('popup.navigation.manage.table.locale'))}
+
+ |
+
+
+ {formatMessage(getTrad('popup.navigation.manage.table.visibility'))}
+
+ |
+
+ {configQuery.data?.isCacheEnabled ? (
+
+
+
+
+
+ ) : null}
+ |
+
+
+
+ {navigations
+ .filter(({ locale }) => locale === localeQuery.data?.defaultLocale)
+ .map((navigation) => (
+
+
+
+ |
+
+ {navigation.documentId}
+ |
+
+ {navigation.name}
+ |
+
+
+ {getLocalizations(navigation).map(prop('locale')).join(', ')}
+
+ |
+
+
+ {navigation.visible
+ ? formatMessage(getTrad('popup.navigation.manage.navigation.visible'))
+ : formatMessage(getTrad('popup.navigation.manage.navigation.hidden'))}
+
+ |
+
+
+
+
+
+
+
+
+ {configQuery.data?.isCacheEnabled ? (
+
+
+
+ ) : null}
+
+ |
+
+ ))}
+
+
+ >
+ );
+};
+
+export const AllNavigationsFooter: Footer = ({
+ onClose,
+ state,
+ setState,
+ navigations,
+ isLoading,
+}) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+ setState({
+ view: 'CREATE',
+ alreadyUsedNames: navigations.map(({ name }) => name),
+ current: INITIAL_NAVIGATION,
+ }),
+ variant: 'default',
+ disabled: isLoading,
+ children: formatMessage(getTrad('popup.navigation.manage.button.create')),
+ }}
+ />
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationManager/DeletionConfirm/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/DeletionConfirm/index.tsx
new file mode 100644
index 00000000..ccac3278
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/DeletionConfirm/index.tsx
@@ -0,0 +1,53 @@
+import { Flex, Grid, Typography } from '@strapi/design-system';
+import { prop } from 'lodash/fp';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../../translations';
+import { Footer, FooterBase } from '../Footer';
+import { CommonProps, DeleteState, Navigation } from '../types';
+
+interface Props extends DeleteState, CommonProps {}
+
+export const DeletionConfirm = ({ navigations }: Props) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+
+
+
+ {formatMessage(getTrad('popup.navigation.manage.delete.header'))}
+
+
+
+
+
+ {renderItems(navigations)}
+
+
+
+ );
+};
+
+export const DeleteConfirmFooter: Footer = ({ state, onSubmit, onReset, isLoading }) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+ );
+};
+
+const renderItems = (navigations: Array) => navigations.map(prop('name')).join(', ');
diff --git a/admin/src/pages/HomePage/components/NavigationManager/ErrorDetails/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/ErrorDetails/index.tsx
new file mode 100644
index 00000000..39ae9359
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/ErrorDetails/index.tsx
@@ -0,0 +1,48 @@
+import { Grid } from '@strapi/design-system';
+import { useNotification } from '@strapi/strapi/admin';
+import { useEffect } from 'react';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../../translations';
+import { Footer, FooterBase } from '../Footer';
+import { CommonProps, ErrorState } from '../types';
+
+interface Props extends ErrorState, CommonProps {}
+
+export const ErrorDetails = ({ errors }: Props) => {
+ const { formatMessage } = useIntl();
+
+ const { toggleNotification } = useNotification();
+
+ useEffect(() => {
+ errors.map((error) => {
+ toggleNotification({
+ type: 'warning',
+ message: formatMessage({ id: '', defaultMessage: error.message }),
+ });
+ console.error(error);
+ });
+ }, []);
+
+ return (
+
+
+ {formatMessage(getTrad('popup.navigation.manage.error.message'))}
+
+
+ );
+};
+
+export const ErrorDetailsFooter: Footer = ({ onReset }) => {
+ const { formatMessage } = useIntl();
+
+ return (
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationManager/Footer/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/Footer/index.tsx
new file mode 100644
index 00000000..e3b51085
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/Footer/index.tsx
@@ -0,0 +1,44 @@
+import { Button, Modal } from '@strapi/design-system';
+
+import { NavigationSchema } from '../../../../../api/validators';
+import { VoidEffect } from '../../../../../types';
+import { SetState, State } from '../types';
+
+interface FooterBaseProps {
+ end?: ActionProps;
+ start?: ActionProps;
+}
+
+export type Footer = React.FC<{
+ navigations: Array;
+ onClose?: VoidEffect;
+ onReset: VoidEffect;
+ onSubmit: VoidEffect;
+ setState: SetState;
+ state: State;
+ disabled?: boolean;
+ isLoading: boolean;
+}>;
+
+interface ActionProps {
+ children: React.ReactNode;
+ disabled?: boolean;
+ onClick?: VoidEffect;
+ variant: 'danger' | 'secondary' | 'tertiary' | 'default';
+}
+
+export const FooterBase: React.FC = ({ start, end }) => {
+ return (
+
+ {renderActions(start)}
+ {renderActions(end)}
+
+ );
+};
+
+const renderActions = (actions: ActionProps | undefined): React.ReactNode =>
+ actions ? (
+
+ ) : null;
diff --git a/admin/src/pages/HomePage/components/NavigationManager/Form/hooks.ts b/admin/src/pages/HomePage/components/NavigationManager/Form/hooks.ts
new file mode 100644
index 00000000..52c4af44
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/Form/hooks.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+export const formSchema = ({ alreadyUsedNames }: { alreadyUsedNames: string[] }) =>
+ z.object({
+ name: z
+ .string()
+ .min(2) // TODO: add translation
+ .and(z.string().refine((name) => !alreadyUsedNames.includes(name), 'Name already used')), // TODO: add translation
+ visible: z.boolean(),
+ });
diff --git a/admin/src/pages/HomePage/components/NavigationManager/Form/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/Form/index.tsx
new file mode 100644
index 00000000..742961d4
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/Form/index.tsx
@@ -0,0 +1,157 @@
+import { Grid, TextInput, Toggle } from '@strapi/design-system';
+import { Form as StrapiForm } from '@strapi/strapi/admin';
+import { get, isEmpty, isNil, isObject, isString, set } from 'lodash';
+import { useEffect, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Field } from '@sensinum/strapi-utils';
+
+import { getTrad } from '../../../../../translations';
+import { Effect, FormChangeEvent, FormItemErrorSchema } from '../../../../../types';
+import { Navigation } from '../types';
+import { formSchema } from './hooks';
+
+interface Props> {
+ navigation: T;
+ onChange: Effect;
+ isLoading?: boolean;
+ alreadyUsedNames?: Array;
+}
+
+export const Form = >({
+ navigation,
+ onChange,
+ alreadyUsedNames = [],
+ isLoading,
+}: Props) => {
+ const [formValue, setFormValue] = useState({} as T);
+ const [formError, setFormError] = useState>();
+
+ const { formatMessage } = useIntl();
+
+ const {
+ name,
+ visible,
+ } = formValue;
+
+ const handleChange = (eventOrPath: FormChangeEvent, value?: any, nativeOnChange?: (eventOrPath: FormChangeEvent, value?: any) => void) => {
+ if (nativeOnChange) {
+
+ let fieldName = eventOrPath;
+ let fieldValue = value;
+
+ if (isObject(eventOrPath)) {
+ const { name: targetName, value: targetValue } = eventOrPath.target;
+ fieldName = targetName;
+ fieldValue = isNil(fieldValue) ? targetValue : fieldValue;
+ }
+
+ if (isString(fieldName)) {
+ setFormValueItem(fieldName, fieldValue);
+ }
+
+ return nativeOnChange(eventOrPath as FormChangeEvent, fieldValue);
+ }
+ };
+
+ const setFormValueItem = (path: string, value: any) => {
+ setFormValue(set({
+ ...formValue,
+ }, path, value));
+ };
+
+ const renderError = (error: string): string | undefined => {
+ const errorOccurence = get(formError, error);
+ if (errorOccurence) {
+ return errorOccurence;
+ }
+ return undefined;
+ };
+
+ useEffect(() => {
+ if (navigation) {
+ if (navigation.name) {
+ setFormValue({
+ ...navigation
+ } as T);
+ } else {
+ setFormValue({
+ name: 'New navigation',
+ visible: true,
+ } as T);
+
+ onChange({
+ name: 'New navigation',
+ visible: true,
+ disabled: true,
+ } as unknown as T);
+ }
+ }
+ }, []);
+
+ useEffect(() => {
+ if ((`${name}-${visible}` !== `${navigation.name}-${navigation.visible}`)) {
+ const { error } = formSchema({ alreadyUsedNames }).safeParse(formValue);
+
+ onChange({
+ ...navigation,
+ name,
+ visible,
+ disabled: !isEmpty(error?.issues),
+ });
+ if (error) {
+ setFormError(error.issues.reduce((acc, err) => {
+ return {
+ ...acc,
+ [err.path.join('.')]: err.message
+ }
+ }, {} as FormItemErrorSchema));
+ } else {
+ setFormError(undefined);
+ }
+ }
+ }, [name, visible]);
+
+ return (
+ {({ values, onChange }) => {
+ return (
+
+
+ handleChange(eventOrPath, value, onChange)}
+ value={values.name}
+ disabled={isLoading}
+ />
+
+
+
+
+ handleChange(eventOrPath, !values.visible, onChange)}
+ onLabel={formatMessage(getTrad('popup.navigation.form.visible.toggle.visible'))}
+ offLabel={formatMessage(getTrad('popup.navigation.form.visible.toggle.hidden'))}
+ disabled={isLoading}
+ width="100%"
+ />
+
+
+ );
+ }}
+
+ );
+};
diff --git a/admin/src/pages/HomePage/components/NavigationManager/NavigationUpdate/index.tsx b/admin/src/pages/HomePage/components/NavigationManager/NavigationUpdate/index.tsx
new file mode 100644
index 00000000..ee7286de
--- /dev/null
+++ b/admin/src/pages/HomePage/components/NavigationManager/NavigationUpdate/index.tsx
@@ -0,0 +1,63 @@
+import { useCallback, useMemo } from 'react';
+import { useIntl } from 'react-intl';
+
+import { getTrad } from '../../../../../translations';
+import { Effect } from '../../../../../types';
+import { Footer, FooterBase } from '../Footer';
+import { Form } from '../Form';
+import { CommonProps, EditState, Navigation } from '../types';
+
+interface Props extends EditState, CommonProps {}
+
+export const NavigationUpdate = ({
+ alreadyUsedNames,
+ current,
+ isLoading,
+ navigation: initialValue,
+ setState,
+}: Props) => {
+ const navigation: Navigation = useMemo(() => current ?? initialValue, [current]);
+
+ const onChange: Effect = useCallback(
+ ({disabled, ...updated}: Navigation & { disabled?: boolean }) => {
+ setState({
+ view: 'EDIT',
+ alreadyUsedNames,
+ current: updated,
+ disabled,
+ navigation: initialValue,
+ });
+ },
+ [setState, initialValue, alreadyUsedNames]
+ );
+
+ return (
+
);
} else {
- return (
- } onClick={() => setIsOpen(!isOpen)} />
- );
+ return } onClick={() => setIsOpen(!isOpen)} />;
}
};
-
-export default Search;
diff --git a/admin/src/pages/HomePage/components/index.ts b/admin/src/pages/HomePage/components/index.ts
new file mode 100644
index 00000000..e5022e8b
--- /dev/null
+++ b/admin/src/pages/HomePage/components/index.ts
@@ -0,0 +1 @@
+export * from './NavigationHeader';
diff --git a/admin/src/pages/HomePage/hooks/index.tsx b/admin/src/pages/HomePage/hooks/index.tsx
new file mode 100644
index 00000000..97e5795f
--- /dev/null
+++ b/admin/src/pages/HomePage/hooks/index.tsx
@@ -0,0 +1,216 @@
+import { getFetchClient } from '@strapi/strapi/admin';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useCallback, useMemo, useState } from 'react';
+
+import { getApiClient } from '../../../api';
+import { NavigationItemSchema, NavigationSchema } from '../../../api/validators';
+import { Effect } from '../../../types';
+import { ConfirmEffect, I18nCopyNavigationItemsModal } from '../components/I18nCopyNavigationItems';
+
+export const useLocale = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readLocaleIndex(),
+ queryFn: apiClient.readLocale,
+ staleTime: Infinity,
+ });
+};
+
+export interface UseContentTypeItemsInput {
+ uid: string;
+ locale?: string;
+ query?: string;
+}
+
+export const useContentTypeItems = (input: UseContentTypeItemsInput) => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readContentTypeItemsIndex(input),
+ queryFn: () => apiClient.readContentTypeItems(input),
+ staleTime: 1000 * 60 * 3,
+ enabled: !!input.uid,
+ });
+};
+
+export const useContentTypes = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readContentTypeIndex(),
+ queryFn: () => apiClient.readContentType(),
+ staleTime: 1000 * 60 * 3,
+ });
+};
+
+export const useNavigations = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readAllIndex(),
+ queryFn() {
+ return apiClient.readAll().then((navigations) =>
+ navigations.map(
+ (navigation): NavigationSchema => ({
+ ...navigation,
+ items: navigation.items.map(appendViewId),
+ })
+ )
+ );
+ },
+ staleTime: 1000 * 60 * 5,
+ });
+};
+
+export const useHardReset = () => {
+ const client = useQueryClient();
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useCallback(() => {
+ client.invalidateQueries({
+ queryKey: apiClient.getIndexPrefix(),
+ });
+ }, [client, fetch, apiClient]);
+};
+
+export const useDeleteNavigations = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn(documentIds: Array) {
+ return Promise.all(documentIds.map(apiClient.delete));
+ },
+ });
+};
+
+export const useCopyNavigationItemI18n = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn: apiClient.copyNavigationItemLocale,
+ });
+};
+
+export const useCopyNavigationI18n = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+ // TODO: nicer cache update
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: apiClient.copyNavigationLocale,
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: apiClient.readAllIndex(),
+ });
+ },
+ });
+};
+
+export const useCreateNavigation = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+ // TODO: nicer cache update
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: apiClient.create,
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: apiClient.readAllIndex(),
+ });
+ },
+ });
+};
+
+export const useUpdateNavigation = (onSuccess?: Effect) => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+ // TODO: nicer cache update
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: apiClient.update,
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: apiClient.readAllIndex(),
+ });
+ onSuccess?.();
+ },
+ });
+};
+
+export const usePurgeNavigation = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn(documentIds?: Array): Promise {
+ if (!documentIds?.length) {
+ return apiClient.purge({});
+ }
+
+ return Promise.all(documentIds.map((documentId) => apiClient.purge({ documentId, withLangVersions: true })));
+ },
+ });
+};
+
+export const useConfig = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readConfigIndex(),
+ queryFn: apiClient.readConfig,
+ });
+};
+
+export const useI18nCopyNavigationItemsModal = (onConfirm: ConfirmEffect) => {
+ const [isOpened, setIsOpened] = useState(false);
+ const [sourceLocale, setSourceLocale] = useState(undefined);
+ const onCancel = useCallback(() => {
+ setIsOpened(false);
+ }, [setIsOpened]);
+ const onConfirmWithModalClose = useCallback(() => {
+ if (!sourceLocale) {
+ return;
+ }
+
+ onConfirm(sourceLocale);
+ setIsOpened(false);
+ }, [onConfirm, sourceLocale]);
+
+ const modal = useMemo(
+ () =>
+ isOpened ? (
+
+ ) : null,
+ [isOpened, onConfirmWithModalClose, onCancel]
+ );
+
+ return useMemo(
+ () => ({
+ setI18nCopyModalOpened: setIsOpened,
+ setI18nCopySourceLocale: setSourceLocale,
+ i18nCopyItemsModal: modal,
+ i18nCopySourceLocale: sourceLocale,
+ }),
+ [setSourceLocale, setIsOpened, modal, sourceLocale]
+ );
+};
+
+const appendViewId = (item: NavigationItemSchema): NavigationItemSchema => {
+ return {
+ ...item,
+ viewId: Math.floor(Math.random() * 1520000),
+ items: item.items?.map(appendViewId),
+ };
+};
diff --git a/admin/src/pages/HomePage/index.tsx b/admin/src/pages/HomePage/index.tsx
new file mode 100644
index 00000000..fbb41094
--- /dev/null
+++ b/admin/src/pages/HomePage/index.tsx
@@ -0,0 +1,556 @@
+import { Data } from '@strapi/strapi';
+import {
+ Box,
+ Button,
+ DesignSystemProvider,
+ Flex,
+ SingleSelect,
+ SingleSelectOption,
+ Typography,
+} from '@strapi/design-system';
+import { Layouts, Page, useRBAC } from '@strapi/strapi/admin';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { first, isEmpty } from 'lodash';
+import { SyntheticEvent, useCallback, useEffect, useMemo, useState } from 'react';
+import { useIntl } from 'react-intl';
+
+import { usePluginTheme } from '@sensinum/strapi-utils';
+import { ListPlus, Plus } from '@strapi/icons';
+import { NavigationItemSchema, NavigationSchema } from '../../api/validators';
+import { getTrad } from '../../translations';
+import { ToBeFixed } from '../../types';
+import pluginPermissions from '../../utils/permissions';
+import { NavigationHeader } from './components';
+import { NavigationContentHeader } from './components/NavigationContentHeader';
+import { NavigationItemFormSchema } from './components/NavigationItemForm';
+import { List } from './components/NavigationItemList';
+import NavigationItemPopUp from './components/NavigationItemPopup';
+import { Search } from './components/Search';
+import {
+ useConfig,
+ useCopyNavigationI18n,
+ useI18nCopyNavigationItemsModal,
+ useLocale,
+ useNavigations,
+ usePurgeNavigation,
+ useUpdateNavigation,
+} from './hooks';
+import {
+ changeCollapseItemDeep,
+ getPendingAction,
+ mapServerNavigationItem,
+ transformItemToViewPayload,
+} from './utils';
+
+const queryClient = new QueryClient();
+
+const Inner = () => {
+ const { formatMessage } = useIntl();
+
+ const localeQuery = useLocale();
+
+ const [recentNavigation, setRecentNavigation] = useState<{ documentId?: string, id?: Data.ID }>();
+ const [currentNavigation, setCurrentNavigation] = useState();
+
+ const [activeNavigationItem, setActiveNavigationItemState] = useState<
+ Partial | undefined
+ >();
+
+ const [isItemPopupVisible, setIsItemPopupVisible] = useState(false);
+
+ const [structureChanged, setStructureChanged] = useState(false);
+
+ const [currentLocale, setCurrentLocale] = useState();
+
+ const viewPermissions = useMemo(
+ () => ({
+ access: pluginPermissions.access || pluginPermissions.update,
+ update: pluginPermissions.update,
+ }),
+ []
+ );
+
+ const {
+ isLoading: isLoadingForPermissions,
+ allowedActions: { canUpdate, canAccess },
+ } = useRBAC(viewPermissions);
+
+ const navigationsQuery = useNavigations();
+
+ const configQuery = useConfig();
+
+ const purgeMutation = usePurgeNavigation();
+
+ const pending = getPendingAction([navigationsQuery, { isPending: isLoadingForPermissions }]);
+
+ const copyNavigationI18nMutation = useCopyNavigationI18n();
+
+ const [{ value: searchValue, index: searchIndex }, setSearchValue] = useState({
+ value: '',
+ index: 0,
+ });
+ const isSearchEmpty = isEmpty(searchValue);
+ const normalisedSearchValue = (searchValue || '').toLowerCase();
+
+ const filteredListFactory = (
+ items: Array,
+ doUse: (item: NavigationItemSchema) => boolean,
+ activeIndex?: number
+ ): NavigationItemSchema[] => {
+ const filteredItems = items.reduce>((acc, item) => {
+ const subItems = !!item.items?.length ? filteredListFactory(item.items ?? [], doUse) : [];
+
+ if (doUse(item)) {
+ return [item, ...subItems, ...acc];
+ } else {
+ return [...subItems, ...acc];
+ }
+ }, []);
+
+ if (activeIndex !== undefined) {
+ const index = activeIndex % filteredItems.length;
+
+ return filteredItems.map((item, currentIndex) => {
+ return index === currentIndex ? { ...item, isSearchActive: true } : item;
+ });
+ }
+
+ return filteredItems;
+ };
+
+ const filteredList = !isSearchEmpty
+ ? filteredListFactory(
+ currentNavigation?.items.map((_) => ({ ..._ })) ?? [],
+ (item) => (item?.title || '').toLowerCase().includes(normalisedSearchValue),
+ normalisedSearchValue ? searchIndex : undefined
+ )
+ : [];
+
+ const changeNavigationItemPopupState = useCallback(
+ (visible: boolean, editedItem = {}) => {
+ setActiveNavigationItemState(editedItem);
+
+ setIsItemPopupVisible(visible);
+ },
+ [setIsItemPopupVisible]
+ );
+
+ const addNewNavigationItem = useCallback(
+ (
+ event: MouseEvent,
+ viewParentId?: number,
+ isMenuAllowedLevel = true,
+ levelPath = '',
+ parentAttachedToMenu = true,
+ structureId = '0',
+ maxOrder = 0
+ ) => {
+ if (canUpdate) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ changeNavigationItemPopupState(true, {
+ viewParentId,
+ isMenuAllowedLevel,
+ levelPath,
+ parentAttachedToMenu,
+ structureId,
+ viewId: undefined,
+ order: maxOrder + 1,
+ });
+ }
+ },
+ [changeNavigationItemPopupState, canUpdate]
+ );
+
+ const availableLocale = useMemo(
+ () =>
+ (localeQuery.data
+ ? [localeQuery.data.defaultLocale, ...localeQuery.data.restLocale]
+ : []
+ ).filter((locale) => locale !== currentLocale),
+ [localeQuery.data, currentLocale]
+ );
+
+ const {
+ i18nCopyItemsModal,
+ i18nCopySourceLocale,
+ setI18nCopyModalOpened,
+ setI18nCopySourceLocale,
+ } = useI18nCopyNavigationItemsModal(
+ useCallback(
+ (sourceLocale) => {
+ const source = navigationsQuery.data?.find(
+ ({ locale, documentId }) =>
+ locale === sourceLocale && documentId === currentNavigation?.documentId
+ );
+
+ if (source) {
+ if (source.documentId && currentNavigation?.documentId) {
+ copyNavigationI18nMutation.mutate(
+ {
+ source: source.locale,
+ target: currentNavigation.locale,
+ documentId: source.documentId,
+ },
+ {
+ onSuccess(res) {
+ copyNavigationI18nMutation.reset();
+ setCurrentNavigation(res.data);
+ },
+ }
+ );
+ }
+ }
+ },
+ [currentNavigation]
+ )
+ );
+
+ const openI18nCopyModalOpened = useCallback(() => {
+ i18nCopySourceLocale && setI18nCopyModalOpened(true);
+ }, [setI18nCopyModalOpened, i18nCopySourceLocale]);
+
+ const updateNavigationMutation = useUpdateNavigation(() => {
+ setRecentNavigation({
+ documentId: currentNavigation?.documentId,
+ id: currentNavigation?.id,
+ });
+ setCurrentNavigation(undefined);
+ });
+
+ const submit = () => {
+ if (currentNavigation) {
+ updateNavigationMutation.mutate(currentNavigation);
+ }
+ };
+
+ const onPopUpClose = (e: SyntheticEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Using Strapi design system select components inside a modal causes
+ // extraneous close events to be fired. It is most likely related to how
+ // the select component handles onOutsideClick events. In those situations
+ // the event target element is the root HTML element.
+ // This is a workaround to prevent the modal from closing in those cases.
+ if ((e.target as any).tagName !== 'HTML') {
+ changeNavigationItemPopupState(false);
+ }
+ };
+
+ const handleItemReOrder = ({
+ item,
+ newOrder,
+ }: {
+ item: NavigationItemFormSchema;
+ newOrder: number;
+ }) => {
+ handleSubmitNavigationItem({
+ ...item,
+ order: newOrder,
+ });
+ };
+
+ const handleItemRemove = (item: NavigationItemSchema) => {
+ handleSubmitNavigationItem(
+ mapServerNavigationItem(
+ {
+ ...item,
+ removed: true,
+ },
+ true
+ )
+ );
+ };
+
+ const handleItemRestore = (item: NavigationItemSchema) => {
+ handleSubmitNavigationItem(
+ mapServerNavigationItem(
+ {
+ ...item,
+ removed: false,
+ },
+ true
+ )
+ );
+ };
+
+ const handleItemToggleCollapse = (item: NavigationItemSchema) => {
+ handleSubmitNavigationItem(
+ mapServerNavigationItem(
+ {
+ ...item,
+ collapsed: !item.collapsed,
+ updated: true,
+ isSearchActive: false,
+ },
+ true
+ )
+ );
+ };
+
+ const handleItemEdit = ({
+ item,
+ levelPath = '',
+ parentAttachedToMenu = true,
+ }: {
+ item: NavigationItemFormSchema;
+ levelPath?: string;
+ parentAttachedToMenu?: boolean;
+ }) => {
+ changeNavigationItemPopupState(true, {
+ ...item,
+ levelPath,
+ parentAttachedToMenu,
+ });
+ };
+
+ const handleSubmitNavigationItem = (payload: NavigationItemFormSchema) => {
+ if (currentNavigation && configQuery.data) {
+ const items = transformItemToViewPayload(
+ payload,
+ currentNavigation?.items ?? [],
+ configQuery.data
+ );
+
+ setCurrentNavigation({
+ ...currentNavigation,
+ items,
+ });
+
+ setStructureChanged(true);
+
+ setIsItemPopupVisible(false);
+ }
+ };
+
+ const listItems = isSearchEmpty ? (currentNavigation?.items ?? []) : filteredList;
+
+ const handleExpandAll = useCallback(() => {
+ if (currentNavigation) {
+ setCurrentNavigation({
+ ...currentNavigation,
+ items: currentNavigation.items.map((item) => changeCollapseItemDeep(item, false)),
+ });
+ }
+ }, [setCurrentNavigation, currentNavigation, changeCollapseItemDeep]);
+
+ const handleCollapseAll = useCallback(() => {
+ if (currentNavigation) {
+ setCurrentNavigation({
+ ...currentNavigation,
+ items: currentNavigation.items.map((item) => changeCollapseItemDeep(item, true)),
+ });
+ }
+ }, [setCurrentNavigation, currentNavigation, changeCollapseItemDeep]);
+
+ const endActions = [
+ {
+ onClick: handleExpandAll,
+ type: 'submit',
+ variant: 'tertiary',
+ tradId: 'header.action.expandAll',
+ margin: '8px',
+ },
+ {
+ onClick: handleCollapseAll,
+ type: 'submit',
+ variant: 'tertiary',
+ tradId: 'header.action.collapseAll',
+ margin: '8px',
+ },
+ ] as Array;
+
+ if (canUpdate) {
+ endActions.push({
+ onClick: addNewNavigationItem as ToBeFixed,
+ type: 'submit',
+ variant: 'primary',
+ tradId: 'header.action.newItem',
+ startIcon: ,
+ margin: '8px',
+ });
+ }
+
+ useEffect(() => {
+ if (!currentNavigation && navigationsQuery.data?.[0]) {
+
+ let navigation;
+ if (recentNavigation?.documentId) {
+ navigation = navigationsQuery.data.find((nav) =>
+ (nav.documentId === recentNavigation.documentId) &&
+ (nav.id === recentNavigation.id)
+ )
+ }
+
+ setRecentNavigation(undefined);
+ setCurrentNavigation(navigation ? navigation : first(navigationsQuery.data));
+ }
+ }, [navigationsQuery.data]);
+
+ useEffect(() => {
+ if (!currentLocale && localeQuery.data?.defaultLocale) {
+ setCurrentLocale(localeQuery.data?.defaultLocale);
+ }
+ }, [navigationsQuery.data]);
+
+ useEffect(() => {
+ if (currentNavigation && currentLocale !== currentNavigation.locale) {
+ setRecentNavigation(undefined);
+
+ const nextNavigation = navigationsQuery.data?.find(
+ (navigation) =>
+ navigation.documentId === currentNavigation.documentId &&
+ navigation.locale === currentLocale
+ );
+
+ if (
+ nextNavigation &&
+ nextNavigation.documentId === currentNavigation.documentId &&
+ nextNavigation.locale !== currentNavigation.locale
+ ) {
+ setCurrentNavigation(nextNavigation);
+ }
+ }
+ }, [currentNavigation, currentLocale, navigationsQuery.data]);
+
+ useEffect(() => {
+ if (!currentLocale && localeQuery.data?.defaultLocale) {
+ setCurrentLocale(localeQuery.data.defaultLocale);
+ }
+ }, [navigationsQuery.data]);
+
+ if (!navigationsQuery.data || !localeQuery.data || !!pending) {
+ return ;
+ }
+
+ return (
+
+
+
+ purgeMutation.mutate(undefined)}
+ handleChangeSelection={setCurrentNavigation}
+ handleLocalizationSelection={setCurrentLocale}
+ handleSave={submit}
+ locale={localeQuery.data}
+ structureHasChanged={structureChanged}
+ permissions={{ canUpdate }}
+ currentLocale={currentLocale}
+ />
+
+
+ }
+ endActions={endActions.map(({ tradId, margin, ...item }, i) => (
+
+
+
+ ))}
+ />
+ {!currentNavigation?.items.length ? (
+
+
+
+ {formatMessage(getTrad('empty.description'))}
+
+
+ {canUpdate && (
+ }
+ label={formatMessage(getTrad('empty.cta'))}
+ onClick={addNewNavigationItem}
+ >
+ {formatMessage(getTrad('empty.cta'))}
+
+ )}
+ {canUpdate && availableLocale.length ? (
+
+
+
+ {formatMessage(getTrad('view.i18n.fill.cta.header'))}
+
+
+
+
+
+ {availableLocale.map((locale) => (
+
+ {formatMessage(getTrad('view.i18n.fill.option'), { locale })}
+
+ ))}
+
+
+
+
+
+
+
+ ) : null}
+
+ ) : (
+
+ )}
+
+ {isItemPopupVisible && currentLocale && currentNavigation && (
+
+ )}
+
+ {canUpdate && i18nCopyItemsModal}
+
+
+
+ );
+};
+
+export default function HomePage() {
+ const theme = usePluginTheme();
+
+ return (
+
+
+
+
+
+ );
+}
+
+export { HomePage };
diff --git a/admin/src/pages/HomePage/utils/index.ts b/admin/src/pages/HomePage/utils/index.ts
new file mode 100644
index 00000000..c0ed51a4
--- /dev/null
+++ b/admin/src/pages/HomePage/utils/index.ts
@@ -0,0 +1,27 @@
+import { UseQueryResult } from '@tanstack/react-query';
+
+import { NavigationItemSchema } from '../../../api/validators';
+
+export * from './parsers';
+
+export const getPendingAction = (actions: Array>) =>
+ actions.find(({ isPending }) => isPending);
+
+export const changeCollapseItemDeep = (
+ item: NavigationItemSchema,
+ isCollapsed: boolean
+): NavigationItemSchema => {
+ if (item.collapsed !== isCollapsed) {
+ return {
+ ...item,
+ collapsed: isCollapsed,
+ updated: true,
+ items: item.items?.map((el) => changeCollapseItemDeep(el, isCollapsed)),
+ };
+ }
+
+ return {
+ ...item,
+ items: item.items?.map((el) => changeCollapseItemDeep(el, isCollapsed)),
+ };
+};
diff --git a/admin/src/pages/HomePage/utils/parsers.ts b/admin/src/pages/HomePage/utils/parsers.ts
new file mode 100644
index 00000000..3f2fca8b
--- /dev/null
+++ b/admin/src/pages/HomePage/utils/parsers.ts
@@ -0,0 +1,327 @@
+import { capitalize, first, get, isEmpty, orderBy } from 'lodash';
+
+import {
+ ConfigFromServerSchema,
+ NavigationItemSchema,
+ NavigationItemTypeSchema,
+ StrapiContentTypeItemSchema,
+} from '../../../api/validators';
+import { NavigationItemFormSchema } from '../components/NavigationItemForm';
+
+const reOrderItems = (items: NavigationItemSchema[] = []) =>
+ orderBy(items, ['order'], ['asc']).map((item, n) => {
+ const order = n + 1;
+ return {
+ ...item,
+ order,
+ updated: item.updated || order !== item.order,
+ };
+ });
+
+const toNavigationItem = (
+ payload: NavigationItemFormSchema,
+ config: ConfigFromServerSchema
+): NavigationItemSchema => {
+ return payload.type === 'INTERNAL'
+ ? {
+ type: 'INTERNAL',
+ collapsed: !!payload.collapsed,
+ id: payload.id!,
+ documentId: payload.documentId!,
+ menuAttached: !!payload.menuAttached,
+ order: payload.order ?? 0,
+ path: payload.path,
+ title: payload.title,
+ uiRouterKey: payload.uiRouterKey,
+ additionalFields: payload.additionalFields,
+ audience:
+ payload.audience?.map(
+ (documentId) =>
+ config.availableAudience.find((audience) => audience.documentId === documentId)!
+ ) ?? [],
+ autoSync: payload.autoSync,
+ items: payload.items?.length
+ ? transformItemToViewPayload(payload, payload.items, config)
+ : payload.items,
+ related: {
+ __type: payload.relatedType,
+ documentId: payload.related,
+ },
+ viewId: payload.viewId,
+ viewParentId: payload.viewParentId,
+ structureId: payload.structureId,
+ removed: payload.removed,
+ updated: payload.updated,
+ }
+ : payload.type === 'EXTERNAL'
+ ? {
+ type: 'EXTERNAL',
+ collapsed: !!payload.collapsed,
+ id: payload.id!,
+ documentId: payload.documentId!,
+ menuAttached: !!payload.menuAttached,
+ order: payload.order ?? 0,
+ title: payload.title,
+ uiRouterKey: payload.uiRouterKey,
+ additionalFields: payload.additionalFields,
+ autoSync: payload.autoSync,
+ items: payload.items?.length
+ ? transformItemToViewPayload(payload, payload.items, config)
+ : payload.items,
+ path: '',
+ viewId: payload.viewId,
+ structureId: payload.structureId,
+ viewParentId: payload.viewParentId,
+ removed: payload.removed,
+ updated: payload.updated,
+ externalPath: payload.externalPath ?? '',
+ audience:
+ payload.audience?.map(
+ (documentId) =>
+ config.availableAudience.find((audience) => audience.documentId === documentId)!
+ ) ?? [],
+ }
+ : {
+ type: 'WRAPPER',
+ collapsed: !!payload.collapsed,
+ id: payload.id!,
+ documentId: payload.documentId!,
+ menuAttached: !!payload.menuAttached,
+ order: payload.order ?? 0,
+ path: payload.path ?? '',
+ title: payload.title,
+ uiRouterKey: payload.uiRouterKey,
+ additionalFields: payload.additionalFields,
+ audience:
+ payload.audience?.map(
+ (documentId) =>
+ config.availableAudience.find((audience) => audience.documentId === documentId)!
+ ) ?? [],
+ autoSync: payload.autoSync,
+ items: payload.items?.length
+ ? transformItemToViewPayload(payload, payload.items, config)
+ : payload.items,
+ viewId: payload.viewId,
+ viewParentId: payload.viewParentId,
+ structureId: payload.structureId,
+ removed: payload.removed,
+ updated: payload.updated,
+ };
+};
+
+export const transformItemToViewPayload = (
+ payload: NavigationItemFormSchema,
+ items: (NavigationItemSchema & { viewId?: number })[] = [],
+ config: ConfigFromServerSchema
+): Array => {
+ if (!payload.viewParentId) {
+ if (payload.viewId) {
+ const updatedRootLevel = items.map((item): NavigationItemSchema => {
+ if (item.viewId === payload.viewId) {
+ return toNavigationItem(payload, config);
+ }
+
+ return {
+ ...item,
+ items: item.items?.length
+ ? transformItemToViewPayload(payload, item.items, config)
+ : item.items,
+ };
+ });
+
+ return reOrderItems(updatedRootLevel);
+ }
+
+ return [
+ ...reOrderItems([...items, toNavigationItem({ ...payload, viewId: Date.now() }, config)]),
+ ];
+ }
+
+ const updatedLevel = items.map((item) => {
+ const branchItems = item.items || [];
+
+ if (payload.viewParentId === item.viewId) {
+ if (!payload.viewId) {
+ return {
+ ...item,
+ items: [
+ ...reOrderItems([
+ ...branchItems,
+ toNavigationItem({ ...payload, viewId: Date.now() }, config),
+ ]),
+ ],
+ };
+ }
+
+ const updatedBranchItems = branchItems.map((item) => {
+ if (item.viewId === payload.viewId) {
+ return toNavigationItem(payload, config);
+ }
+
+ return item;
+ });
+
+ return {
+ ...item,
+ items: reOrderItems(updatedBranchItems),
+ };
+ }
+
+ return {
+ ...item,
+ items: item.items?.length
+ ? transformItemToViewPayload(payload, item.items, config)
+ : item.items,
+ };
+ });
+
+ return reOrderItems(updatedLevel);
+};
+
+export const extractRelatedItemLabel = (
+ item: StrapiContentTypeItemSchema,
+ config?: ConfigFromServerSchema
+) => {
+ const contentTypes = config?.contentTypes ?? [];
+ const fields = config?.contentTypesNameFields ?? {};
+ const defaultFields = fields.default ?? [];
+
+ const { __collectionUid } = item;
+
+ const contentType = contentTypes.find(({ uid }) => uid === __collectionUid);
+
+ if (contentType?.isSingle) {
+ return contentType.labelSingular;
+ }
+
+ const defaultFieldsWithCapitalizedOptions = [
+ ...defaultFields,
+ ...defaultFields.map((_) => capitalize(_)),
+ ];
+ const labelFields = get(
+ fields,
+ `${contentType ? contentType.uid : __collectionUid}`,
+ defaultFieldsWithCapitalizedOptions
+ );
+ const itemLabels = (isEmpty(labelFields) ? defaultFieldsWithCapitalizedOptions : labelFields)
+ .map((_) => item[_])
+ .filter((_) => _);
+
+ return first(itemLabels) || '';
+};
+
+export const isRelationCorrect = (item: Partial) => {
+ switch (item.type) {
+ case 'EXTERNAL':
+ case 'WRAPPER':
+ return true;
+ case 'INTERNAL':
+ return !!item.related;
+ }
+};
+
+export const isRelationPublished = ({
+ relatedRef,
+ relatedType = {},
+ type,
+ isCollection,
+}: {
+ relatedRef: StrapiContentTypeItemSchema;
+ relatedType?: { available?: boolean };
+ type: NavigationItemTypeSchema;
+ isCollection: boolean;
+}) => {
+ if (isCollection) {
+ return relatedType.available || relatedRef.available;
+ }
+ if (type === 'INTERNAL') {
+ const isHandledByPublishFlow = relatedRef ? 'publishedAt' in relatedRef : false;
+
+ if (isHandledByPublishFlow) {
+ return get(relatedRef, 'publishedAt', true);
+ }
+ }
+ return true;
+};
+
+export const mapServerNavigationItem = (
+ item: NavigationItemSchema,
+ stopAtFirstLevel = false
+): NavigationItemFormSchema => {
+ const { __type: relatedType, documentId: related } =
+ item.type === 'INTERNAL' && item.related
+ ? item.related
+ : {
+ __type: "",
+ documentId: "",
+ };
+
+ return item.type === 'INTERNAL'
+ ? {
+ type: 'INTERNAL',
+ id: item.id,
+ documentId: item.documentId,
+ additionalFields: item.additionalFields ?? {},
+ path: item.path ?? '',
+ relatedType,
+ related,
+ title: item.title,
+ uiRouterKey: item.uiRouterKey,
+ autoSync: item.autoSync ?? undefined,
+ collapsed: item.collapsed,
+ externalPath: undefined,
+ order: item.order ?? 0,
+ menuAttached: item.menuAttached,
+ viewId: item.viewId,
+ viewParentId: item.viewParentId,
+ items: stopAtFirstLevel
+ ? (item.items as unknown as NavigationItemFormSchema[])
+ : (item.items?.map((_) => mapServerNavigationItem(_)) ?? undefined),
+ removed: item.removed,
+ updated: item.updated,
+ isSearchActive: item.isSearchActive,
+ }
+ : item.type === 'EXTERNAL'
+ ? {
+ type: 'EXTERNAL',
+ id: item.id,
+ documentId: item.documentId,
+ additionalFields: item.additionalFields ?? {},
+ title: item.title,
+ uiRouterKey: item.uiRouterKey,
+ autoSync: item.autoSync ?? undefined,
+ collapsed: item.collapsed,
+ externalPath: item.externalPath!,
+ order: item.order ?? 0,
+ menuAttached: item.menuAttached,
+ viewId: item.viewId,
+ viewParentId: item.viewParentId,
+ items: stopAtFirstLevel
+ ? (item.items as unknown as NavigationItemFormSchema[])
+ : (item.items?.map((_) => mapServerNavigationItem(_)) ?? undefined),
+ removed: item.removed,
+ updated: item.updated,
+ isSearchActive: item.isSearchActive,
+ }
+ : {
+ type: 'WRAPPER',
+ id: item.id,
+ documentId: item.documentId,
+ additionalFields: item.additionalFields ?? {},
+ title: item.title,
+ uiRouterKey: item.uiRouterKey,
+ autoSync: item.autoSync ?? undefined,
+ collapsed: item.collapsed,
+ order: item.order ?? 0,
+ menuAttached: item.menuAttached,
+ viewId: item.viewId,
+ viewParentId: item.viewParentId,
+ items: stopAtFirstLevel
+ ? (item.items as unknown as NavigationItemFormSchema[])
+ : (item.items?.map((_) => mapServerNavigationItem(_)) ?? undefined),
+ removed: item.removed,
+ updated: item.updated,
+ isSearchActive: item.isSearchActive,
+ path: item.path ?? '',
+ };
+};
diff --git a/admin/src/pages/NoAccessPage/index.tsx b/admin/src/pages/NoAccessPage/index.tsx
deleted file mode 100644
index a10d8a15..00000000
--- a/admin/src/pages/NoAccessPage/index.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * NoAcccessPage
- *
- * This is the page we show when the user do not have appropriate credentials
- *
- */
-
-// TODO
-// @ts-nocheck
-
-import React from 'react';
-import { useFocusWhenNavigate, LinkButton } from '@strapi/helper-plugin';
-import { Main } from '@strapi/design-system/Main';
-import { ContentLayout, HeaderLayout } from '@strapi/design-system/Layout';
-import { EmptyStateLayout } from '@strapi/design-system/EmptyStateLayout';
-import { EmptyPictures, ArrowRight } from "@strapi/icons";
-import { useIntl } from 'react-intl';
-
-const NoAcccessPage = () => {
- const { formatMessage } = useIntl();
- useFocusWhenNavigate();
-
- return (
-
-
-
- } to="/">
- {formatMessage({
- id: 'components.notAccessPage.back',
- defaultMessage: 'Back to homepage',
- })}
-
- }
- content={formatMessage({
- id: 'page.auth.not.allowed',
- defaultMessage: "Oops! It seems like You do not have access to this page...",
- })}
- hasRadius
- icon={}
- shadow="tableShadow"
- />
-
-
- );
-};
-
-export default NoAcccessPage;
diff --git a/admin/src/pages/SettingsPage/common/const.ts b/admin/src/pages/SettingsPage/common/const.ts
index 358f9e34..cf2c5db3 100644
--- a/admin/src/pages/SettingsPage/common/const.ts
+++ b/admin/src/pages/SettingsPage/common/const.ts
@@ -1 +1 @@
-export const customFieldsTypes = ["string", "boolean", "select", "media"];
+export const customFieldsTypes = ["string", "boolean", "select", "media"] as const;
diff --git a/admin/src/pages/SettingsPage/components/CustomFieldForm/index.tsx b/admin/src/pages/SettingsPage/components/CustomFieldForm/index.tsx
index 117e4f82..df0d78b4 100644
--- a/admin/src/pages/SettingsPage/components/CustomFieldForm/index.tsx
+++ b/admin/src/pages/SettingsPage/components/CustomFieldForm/index.tsx
@@ -1,180 +1,236 @@
-import React, { useCallback, useMemo } from 'react';
-//@ts-ignore
-import { ModalBody, ModalFooter } from '@strapi/design-system/ModalLayout';
-//@ts-ignore
-import { Button } from '@strapi/design-system/Button';
-import { GenericInput, GenericInputProps } from '@strapi/helper-plugin';
-//@ts-ignore
-import { Grid, GridItem } from '@strapi/design-system/Grid';
-import { useFormik } from 'formik';
-import { Effect, NavigationItemCustomField, VoidEffect } from '../../../../../../types';
-import * as formDefinition from '../../utils/form';
-import { getMessage } from '../../../../utils';
-import { isEmpty, isNil } from 'lodash';
-import { getTrad } from '../../../../translations';
+import React, { useEffect, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Form } from '@strapi/strapi/admin';
+import { Button, Grid, Modal, SingleSelect, SingleSelectOption, TextInput, Toggle } from '@strapi/design-system';
+import { Field } from "@sensinum/strapi-utils";
+
import TextArrayInput from '../../../../components/TextArrayInput';
+import { navigationItemCustomField, NavigationItemCustomField } from '../../../../schemas';
+import { getTrad } from '../../../../translations';
+import { Effect, FormChangeEvent, FormItemErrorSchema, ToBeFixed, VoidEffect } from '../../../../types';
import { customFieldsTypes } from '../../common';
-const tradPrefix = 'pages.settings.form.customFields.popup.'
+import { get, isNil, isObject, isString, set } from 'lodash';
+
+const tradPrefix = 'pages.settings.form.customFields.popup.';
interface ICustomFieldFormProps {
customField: Partial;
isEditForm: boolean;
onSubmit: Effect;
onClose: VoidEffect;
- usedCustomFieldNames: string[];
}
-const prepareSelectOptions = (options: string[]) => options.map((option, index) => ({
- key: `${option}-${index}`,
- metadatas: {
- intlLabel: {
- id: option,
- defaultMessage: option,
+const prepareSelectOptions = (options: ReadonlyArray) =>
+ options.map((option, index) => ({
+ key: `${option}-${index}`,
+ metadatas: {
+ intlLabel: {
+ id: option,
+ defaultMessage: option,
+ },
+ hidden: false,
+ disabled: false,
},
- hidden: false,
- disabled: false,
- },
- value: option,
- label: option,
-}));
-
-const CustomFieldForm: React.FC = ({ isEditForm, customField, onSubmit, onClose, usedCustomFieldNames }) => {
+ value: option,
+ label: option,
+ }));
+
+const CustomFieldForm: React.FC = ({
+ isEditForm,
+ customField,
+ onSubmit,
+ onClose,
+}) => {
const typeSelectOptions = prepareSelectOptions(customFieldsTypes);
- const initialValues = useMemo(() => {
- if (isNil(customField.type)) {
- return formDefinition.defaultValues;
- } else if (customField.type === 'select') {
- return {
- type: customField.type,
- name: customField.name || formDefinition.defaultValues.name,
- label: customField.label || formDefinition.defaultValues.label,
- required: customField.required || formDefinition.defaultValues.required,
- options: customField.options || formDefinition.defaultValues.options,
- multi: customField.multi || formDefinition.defaultValues.multi,
- enabled: customField.enabled,
+
+ const { formatMessage } = useIntl();
+
+ const [formValue, setFormValue] = useState({} as NavigationItemCustomField);
+ const [formError, setFormError] = useState>();
+
+ const { type } = formValue;
+
+ useEffect(() => {
+ if (customField) {
+ setFormValue({
+ ...customField
+ } as NavigationItemCustomField);
+ }
+ }, [customField])
+
+ const handleChange = (eventOrPath: FormChangeEvent, value?: any, nativeOnChange?: (eventOrPath: FormChangeEvent, value?: any) => void) => {
+ if (nativeOnChange) {
+
+ let fieldName = eventOrPath;
+ let fieldValue = value;
+ if (isObject(eventOrPath)) {
+ const { name: targetName, value: targetValue } = eventOrPath.target;
+ fieldName = targetName;
+ fieldValue = isNil(fieldValue) ? targetValue : fieldValue;
}
- } else {
- return {
- type: customField.type,
- name: customField.name || formDefinition.defaultValues.name,
- label: customField.label || formDefinition.defaultValues.label,
- required: customField.required || formDefinition.defaultValues.required,
- options: [],
- multi: false,
- enabled: customField.enabled,
+
+ if (isString(fieldName)) {
+ setFormValueItem(fieldName, fieldValue);
}
+
+ return nativeOnChange(eventOrPath, fieldValue);
+ }
+ };
+
+ const setFormValueItem = (path: string, value: any) => {
+ setFormValue(set({
+ ...formValue,
+ }, path, value));
+ };
+
+ const renderError = (error: string): string | undefined => {
+ const errorOccurence = get(formError, error);
+ if (errorOccurence) {
+ return formatMessage(getTrad(error));
+ }
+ return undefined;
+ };
+
+
+ const submit = (e: React.MouseEvent, values: NavigationItemCustomField) => {
+ const { success, data, error } = navigationItemCustomField.safeParse(values);
+ if (success) {
+ onSubmit(values);
+ } else if (error) {
+ setFormError(error.issues.reduce((acc, err) => {
+ return {
+ ...acc,
+ [err.path.join('.')]: err.message
+ }
+ }, {} as FormItemErrorSchema));
}
- }, [customField]);
-
- const {
- handleChange,
- setFieldValue,
- values,
- errors,
- handleSubmit,
- isSubmitting,
- } = useFormik({
- initialValues,
- onSubmit,
- validationSchema: formDefinition.schemaFactory(usedCustomFieldNames),
- validateOnChange: false,
- });
- const defaultProps = useCallback((fieldName: keyof NavigationItemCustomField): Omit => {
- const error = mapError(errors[fieldName]);
-
- return {
- intlLabel: getTrad(`${tradPrefix}${fieldName}.label`),
- onChange: handleChange,
- name: fieldName,
- value: values[fieldName],
- error,
- };
- }, [values, errors, handleChange]);
+ }
return (
-
+
+
+
+ >
);
-}
+};
export default CustomFieldForm;
-
-const mapError = (err: unknown): GenericInputProps["error"] => {
- if (typeof err === "string") {
- return err;
- }
-
- if (
- typeof err === "object" &&
- err &&
- ((err as any).id || (err as any).description || (err as any).defaultMessage)
- ) {
- return err;
- }
-};
diff --git a/admin/src/pages/SettingsPage/components/CustomFieldModal/index.tsx b/admin/src/pages/SettingsPage/components/CustomFieldModal/index.tsx
index b2a42b6f..2cd65b4c 100644
--- a/admin/src/pages/SettingsPage/components/CustomFieldModal/index.tsx
+++ b/admin/src/pages/SettingsPage/components/CustomFieldModal/index.tsx
@@ -1,20 +1,18 @@
+import { Modal, Typography } from '@strapi/design-system';
import React from 'react';
-//@ts-ignore
-import { Typography } from '@strapi/design-system/Typography';
-//@ts-ignore
-import { ModalLayout, ModalHeader } from '@strapi/design-system/ModalLayout';
-import CustomFieldForm from '../CustomFieldForm';
-import { Effect, NavigationItemCustomField, VoidEffect } from '../../../../../../types';
-import { getMessage } from '../../../../utils';
import { pick } from 'lodash';
+import { useIntl } from 'react-intl';
+import { NavigationItemCustomField } from '../../../../schemas';
+import { getTrad } from '../../../../translations';
+import { Effect, VoidEffect } from '../../../../types';
+import CustomFieldForm from '../CustomFieldForm';
interface ICustomFieldModalProps {
data: NavigationItemCustomField | null;
isOpen: boolean;
onClose: VoidEffect;
onSubmit: Effect;
- usedCustomFieldNames: string[];
}
const CustomFieldModal: React.FC = ({
@@ -22,25 +20,46 @@ const CustomFieldModal: React.FC = ({
onClose,
onSubmit,
data,
- usedCustomFieldNames,
}) => {
const isEditMode = !!data;
+
+ const { formatMessage } = useIntl();
+
return (
-
-
-
- {getMessage(`pages.settings.form.customFields.popup.header.${isEditMode ? 'edit' : 'new'}`)}
-
-
-
-
+ {
+ if (!isOpen) {
+ onClose();
+ }
+ }}
+ open={isOpen}
+ labelledBy="custom-field-modal"
+ >
+
+
+
+ {formatMessage(
+ getTrad(
+ `pages.settings.form.customFields.popup.header.${isEditMode ? 'edit' : 'new'}`
+ )
+ )}
+
+
+
+
+
);
-}
+};
export default CustomFieldModal;
diff --git a/admin/src/pages/SettingsPage/components/CustomFieldTable/index.tsx b/admin/src/pages/SettingsPage/components/CustomFieldTable/index.tsx
index f7b84b11..be6d979f 100644
--- a/admin/src/pages/SettingsPage/components/CustomFieldTable/index.tsx
+++ b/admin/src/pages/SettingsPage/components/CustomFieldTable/index.tsx
@@ -1,53 +1,59 @@
+import { VisuallyHidden } from '@strapi/design-system';
+import { Check, Eye, EyeStriked, Minus, Pencil, Plus, PriceTag, Trash } from '@strapi/icons';
+import { useNotification } from '@strapi/strapi/admin';
import { sortBy } from 'lodash';
-import React, { useCallback, useMemo, useState } from 'react';
-//@ts-ignore
-import { useNotification } from "@strapi/helper-plugin";
-//@ts-ignore
-import { VisuallyHidden } from '@strapi/design-system/VisuallyHidden';
-//@ts-ignore
-import { Table, Thead, Tr, Th, Tbody, Td, TFooter } from '@strapi/design-system/Table';
-//@ts-ignore
-import { Plus, Trash, Pencil, Refresh, Check, Minus, EyeStriked, Eye } from '@strapi/icons';
-//@ts-ignore
-import { Typography } from '@strapi/design-system/Typography';
-//@ts-ignore
-import { Tooltip } from '@strapi/design-system/Tooltip';
-//@ts-ignore
-import { Stack } from '@strapi/design-system/Stack';
-//@ts-ignore
-import { IconButton } from '@strapi/design-system/IconButton';
+import { useCallback, useMemo, useState } from 'react';
-import { getMessage } from '../../../../utils';
-import { Effect, NavigationItemCustomField } from '../../../../../../types';
-import ConfirmationDialog from '../../../../components/ConfirmationDialog';
-import { getTradId } from '../../../../translations';
+import {
+ Flex,
+ IconButton,
+ TFooter,
+ Table,
+ Tbody,
+ Td,
+ Th,
+ Thead,
+ Tooltip,
+ Tr,
+ Typography,
+} from '@strapi/design-system';
+import { useIntl } from 'react-intl';
+import { ConfirmationDialog } from '../../../../components/ConfirmationDialog';
+import { NavigationItemCustomField } from '../../../../schemas';
+import { getTrad, getTradId } from '../../../../translations';
+import { Effect } from '../../../../types';
interface ICustomFieldTableProps {
- data: NavigationItemCustomField[];
+ data: (NavigationItemCustomField | string)[];
onOpenModal: (field: NavigationItemCustomField | null) => void;
onRemoveCustomField: Effect;
onToggleCustomField: Effect;
}
-const refreshIcon = ;
+const refreshIcon = ;
const plusIcon = ;
-const tradPrefix = "pages.settings.form.customFields.table.";
+const tradPrefix = 'pages.settings.form.customFields.table.';
const CustomFieldTable: React.FC = ({
- data,
+ data = [],
onOpenModal,
onRemoveCustomField,
onToggleCustomField,
}) => {
const [confirmationVisible, setIsConfirmationVisible] = useState(false);
const [fieldToRemove, setFieldToRemove] = useState(null);
- const toggleNotification = useNotification();
- const customFields = useMemo(() => sortBy(data, "name"), [data]);
+ const { toggleNotification } = useNotification();
+ const customFields = useMemo(() => sortBy(data, 'name'), [data]);
- const handleRemove = useCallback((field: NavigationItemCustomField) => {
- setFieldToRemove(field);
- setIsConfirmationVisible(true);
- }, [setFieldToRemove, setIsConfirmationVisible]);
+ const { formatMessage } = useIntl();
+
+ const handleRemove = useCallback(
+ (field: NavigationItemCustomField) => {
+ setFieldToRemove(field);
+ setIsConfirmationVisible(true);
+ },
+ [setFieldToRemove, setIsConfirmationVisible]
+ );
const cleanup = useCallback(() => {
setFieldToRemove(null);
@@ -58,10 +64,7 @@ const CustomFieldTable: React.FC = ({
if (fieldToRemove === null) {
toggleNotification({
type: 'warning',
- message: {
- id: getTradId(`${tradPrefix}confirmation.error`),
- defaultMessage: 'Something went wrong',
- }
+ message: formatMessage(getTrad(`${tradPrefix}confirmation.error`)),
});
} else {
onRemoveCustomField(fieldToRemove);
@@ -70,28 +73,31 @@ const CustomFieldTable: React.FC = ({
cleanup();
}, [cleanup, fieldToRemove, getTradId, onRemoveCustomField, toggleNotification]);
-
return (
<>
{ e.preventDefault(); onOpenModal(null); }}
+ onClick={(e: React.FormEvent) => {
+ e.preventDefault();
+ onOpenModal(null);
+ }}
icon={plusIcon}
>
- {getMessage(`${tradPrefix}footer`)}
+ {formatMessage(getTrad(`${tradPrefix}footer`))}
}
>
@@ -99,22 +105,22 @@ const CustomFieldTable: React.FC = ({
- {getMessage(`${tradPrefix}header.name`)}
+ {formatMessage(getTrad(`${tradPrefix}header.name`))}
|
- {getMessage(`${tradPrefix}header.label`)}
+ {formatMessage(getTrad(`${tradPrefix}header.label`))}
|
- {getMessage(`${tradPrefix}header.type`)}
+ {formatMessage(getTrad(`${tradPrefix}header.type`))}
|
- {getMessage(`${tradPrefix}header.required`)}
+ {formatMessage(getTrad(`${tradPrefix}header.required`))}
|
@@ -123,58 +129,62 @@ const CustomFieldTable: React.FC = ({
|
- {customFields.map(customField => (
-
-
-
- {customField.name}
-
- |
-
-
- {customField.label}
-
- |
-
-
- {customField.type}
-
- |
-
-
-
- {customField.required ? : }
+ {customFields.map((customField) =>
+ typeof customField !== 'string' ? (
+
+
+
+ {customField.name}
-
- |
-
-
- onOpenModal(customField)}
- label={getMessage(`${tradPrefix}edit`)}
- icon={}
- noBorder
- />
- onToggleCustomField(customField)}
- label={getMessage(`${tradPrefix}${customField.enabled ? 'disable' : 'enable'}`)}
- icon={customField.enabled ? : }
- noBorder
- />
- handleRemove(customField)}
- label={getMessage(`${tradPrefix}remove`)}
- icon={}
- noBorder
- />
-
- |
-
- ))}
+ |
+
+ {customField.label}
+ |
+
+ {customField.type}
+ |
+
+
+
+ {customField.required ? : }
+
+
+ |
+
+
+ onOpenModal(customField)}
+ label={formatMessage(getTrad(`${tradPrefix}edit`))}
+ children={}
+ style={{ minWidth: 50 }}
+ />
+ onToggleCustomField(customField)}
+ label={formatMessage(
+ getTrad(`${tradPrefix}${customField.enabled ? 'disable' : 'enable'}`)
+ )}
+ children={customField.enabled ? : }
+ style={{ minWidth: 50 }}
+ />
+ handleRemove(customField)}
+ label={formatMessage(getTrad(`${tradPrefix}remove`))}
+ children={}
+ style={{ minWidth: 50 }}
+ />
+
+ |
+
+ ) : null
+ )}
>
);
-}
+};
export default CustomFieldTable;
diff --git a/admin/src/pages/SettingsPage/components/DisableI18nModal/index.tsx b/admin/src/pages/SettingsPage/components/DisableI18nModal/index.tsx
deleted file mode 100644
index 21135e29..00000000
--- a/admin/src/pages/SettingsPage/components/DisableI18nModal/index.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import React, { useCallback, useMemo, useState, FC } from "react";
-import ConfirmationDialog from "../../../../components/ConfirmationDialog";
-import { getMessage } from "../../../../utils";
-// @ts-ignore
-import { Formik } from "formik";
-// @ts-ignore
-import { Check, Refresh, Play, Information } from "@strapi/icons";
-// @ts-ignore
-import { Stack } from "@strapi/design-system/Stack";
-// @ts-ignore
-import { Box } from "@strapi/design-system/Box";
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-// @ts-ignore
-import { ToggleInput } from "@strapi/design-system/ToggleInput";
-// @ts-ignore
-import { Typography } from "@strapi/design-system/Typography";
-import { ToBeFixed } from "../../../../../../types";
-
-interface Form {
- pruneNavigations: boolean;
- enabled: boolean;
-}
-
-interface FormBooleanItemInput {
- target: { checked: boolean };
-}
-
-interface SubmitEffect {
- ({ pruneNavigations }: Form): void;
-}
-
-interface CancelEffect {
- (): void;
-}
-
-interface Props {
- onSubmit: SubmitEffect;
- onCancel: CancelEffect;
-}
-
-const refreshIcon = <>>;
-
-const INITIAL_VALUES: Form = { pruneNavigations: false, enabled: true };
-
-export const DisableI18nModal: FC = ({ onSubmit, onCancel }) => {
- const [state, setState] = useState(INITIAL_VALUES);
- const onConfirm = useCallback(() => {
- onSubmit(state);
- }, [onSubmit, state]);
-
- return (
-
-
- {({ setFieldValue, values }: ToBeFixed) => (
- <>
-
-
-
- {getMessage(
- "pages.settings.actions.disableI18n.confirmation.description.line1"
- )}
-
-
- {getMessage(
- "pages.settings.actions.disableI18n.confirmation.description.line2"
- )}
-
-
-
- {getMessage(
- "pages.settings.actions.disableI18n.confirmation.description.line3"
- )}
-
-
-
-
-
-
-
-
- {
- setFieldValue("pruneNavigations", checked, false);
- setState((state) => ({
- ...state,
- pruneNavigations: checked,
- }));
- }}
- onLabel={getMessage(
- "pages.settings.actions.disableI18n.prune.on"
- )}
- offLabel={getMessage(
- "pages.settings.actions.disableI18n.prune.off"
- )}
- />
-
-
-
-
- >
- )}
-
-
- );
-};
-
-export const useDisableI18nModal = (onSubmit: SubmitEffect) => {
- const [isOpened, setIsOpened] = useState(false);
- const [onCancel, setOnCancel] = useState(() => () => {});
- const onSubmitWithModalClose = useCallback(
- (val) => {
- onSubmit(val);
- setIsOpened(false);
- },
- [onSubmit, setIsOpened]
- );
- const onCancelWithModalClose = () => {
- onCancel();
- setIsOpened(false);
- };
- const modal = useMemo(
- () =>
- isOpened ? (
-
- ) : null,
- [isOpened, onSubmitWithModalClose, onCancelWithModalClose]
- );
-
- return useMemo(
- () => ({
- setDisableI18nModalOpened: setIsOpened,
- setI18nModalOnCancel: setOnCancel,
- disableI18nModal: modal,
- }),
- [setIsOpened, modal, setOnCancel]
- );
-};
diff --git a/admin/src/pages/SettingsPage/hooks/index.ts b/admin/src/pages/SettingsPage/hooks/index.ts
new file mode 100644
index 00000000..671c75f6
--- /dev/null
+++ b/admin/src/pages/SettingsPage/hooks/index.ts
@@ -0,0 +1,116 @@
+import { getFetchClient } from '@strapi/strapi/admin';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { z } from 'zod';
+
+import { getApiClient } from '../../../api';
+import { configSchema } from '../../../schemas';
+import { resolveGlobalLikeId } from '../utils';
+
+export const useConfig = () => {
+ // TODO: Resolve useQuery issues to use useFetchClient
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readSettingsConfigIndex(),
+ queryFn() {
+ return apiClient.readSettingsConfig();
+ },
+ staleTime: 1000 * 60 * 5,
+ });
+};
+
+export const useRestart = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ const healthCheck = useQuery({
+ queryKey: apiClient.healthCheckIndex(),
+ queryFn: () => apiClient.healthCheck(),
+ retry: true,
+ retryDelay: 1000 * 5,
+ enabled: false,
+ });
+
+ return useMutation({
+ mutationFn: () => {
+ return apiClient.restart()
+ .then(() => healthCheck.refetch());
+ },
+ });
+};
+
+export const useRestoreConfig = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn: () => apiClient.restoreConfig(),
+ });
+};
+
+export const useContentTypes = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useQuery({
+ queryKey: apiClient.readContentTypeIndex(),
+ queryFn: () => apiClient.readContentType(),
+ staleTime: Infinity,
+ });
+};
+
+export const useSaveConfig = () => {
+ const fetch = getFetchClient();
+ const apiClient = getApiClient(fetch);
+
+ return useMutation({
+ mutationFn(data: UiFormSchema) {
+ return apiClient.updateConfig({
+ ...data,
+ contentTypesNameFields: Object.fromEntries(
+ data.contentTypesNameFields.map(({ key, fields }) => [key, fields])
+ ),
+ contentTypesPopulate: Object.fromEntries(
+ data.contentTypesPopulate.map(({ key, fields }) => [key, fields])
+ ),
+ pathDefaultFields: Object.fromEntries(
+ data.pathDefaultFields.map(({ key, fields }) => [key, fields])
+ ),
+ additionalFields: data.audienceFieldChecked
+ ? [...data.additionalFields, 'audience']
+ : data.additionalFields,
+ gql: {
+ navigationItemRelated: data.contentTypes.map((uid: string) => {
+ return resolveGlobalLikeId(uid);
+ }),
+ },
+ });
+ },
+ });
+};
+
+
+export type UiFormSchema = z.infer;
+
+export const uiFormSchema = configSchema.omit({ contentTypesNameFields: true }).extend({
+ audienceFieldChecked: z.boolean(),
+ contentTypesNameFields: z
+ .object({
+ key: z.string(),
+ fields: z.string().array(),
+ })
+ .array(),
+ contentTypesPopulate: z
+ .object({
+ key: z.string(),
+ fields: z.string().array(),
+ })
+ .array(),
+ pathDefaultFields: z
+ .object({
+ key: z.string(),
+ fields: z.string().array(),
+ })
+ .array(),
+});
\ No newline at end of file
diff --git a/admin/src/pages/SettingsPage/index.tsx b/admin/src/pages/SettingsPage/index.tsx
index 4d776bab..6fd71da8 100644
--- a/admin/src/pages/SettingsPage/index.tsx
+++ b/admin/src/pages/SettingsPage/index.tsx
@@ -1,602 +1,825 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { isEmpty, capitalize, isEqual, orderBy, get, sortBy } from 'lodash';
-import { Formik, Form } from 'formik';
+
+import { Field, usePluginTheme } from "@sensinum/strapi-utils";
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { get, isEmpty, isNaN, isNil, isObject, isString, set, sortBy } from 'lodash';
+import { useEffect, useMemo, useState } from 'react';
+import { useIntl } from 'react-intl';
+
import {
- CheckPermissions,
- LoadingIndicatorPage,
- useOverlayBlocker,
- useAutoReloadOverlayBlocker,
- SettingsPageTitle,
- useRBAC,
- //@ts-ignore
-} from '@strapi/helper-plugin';
-//@ts-ignore
-import { Main } from '@strapi/design-system/Main';
-//@ts-ignore
-import { ContentLayout, HeaderLayout } from '@strapi/design-system/Layout';
-//@ts-ignore
-import { Accordion, AccordionToggle, AccordionContent, AccordionGroup } from '@strapi/design-system/Accordion';
-//@ts-ignore
-import { Button } from '@strapi/design-system/Button';
-//@ts-ignore
-import { Box } from '@strapi/design-system/Box';
-//@ts-ignore
-import { Divider } from '@strapi/design-system/Divider';
-//@ts-ignore
-import { Stack } from '@strapi/design-system/Stack';
-//@ts-ignore
-import { Typography } from '@strapi/design-system/Typography';
-//@ts-ignore
-import { Grid, GridItem } from '@strapi/design-system/Grid';
-//@ts-ignore
-import { ToggleInput } from '@strapi/design-system/ToggleInput';
-//@ts-ignore
-import { NumberInput } from '@strapi/design-system/NumberInput';
-//@ts-ignore
-import { Select, Option } from '@strapi/design-system/Select';
-//@ts-ignore
-import { Tooltip } from '@strapi/design-system/Tooltip';
-//@ts-ignore
-import { Check, Refresh, Play, Information, ExclamationMarkCircle } from '@strapi/icons';
-
-import permissions from '../../permissions';
-import useNavigationConfig from '../../hooks/useNavigationConfig';
-import useAllContentTypes from '../../hooks/useAllContentTypes';
-import { navigationItemAdditionalFields, prepareNewValueForRecord } from '../../utils';
-import ConfirmationDialog from '../../components/ConfirmationDialog';
-import RestartAlert from '../../components/RestartAlert';
-import { getMessage } from '../../utils';
-import { isContentTypeEligible, resolveGlobalLikeId } from './utils/functions';
-import { PermanentAlert } from '../../components/Alert/styles';
-import { useDisableI18nModal } from './components/DisableI18nModal';
-
-import { NavigationItemAdditionalField, NavigationItemCustomField, ToBeFixed } from '../../../../types';
+ Accordion,
+ Box,
+ Button,
+ DesignSystemProvider,
+ Flex,
+ Grid,
+ MultiSelect,
+ MultiSelectOption,
+ NumberInput,
+ Toggle,
+ Typography,
+} from '@strapi/design-system';
+
+import { Check, Play, Typhoon } from '@strapi/icons';
+import { Form, Layouts, Page, useAuth } from '@strapi/strapi/admin';
+
+import { ConfirmationDialog } from '../../components/ConfirmationDialog';
+import { RestartAlert } from '../../components/RestartAlert';
+import { NavigationItemCustomField } from '../../schemas';
+import { FormChangeEvent, FormItemErrorSchema } from '../../types';
+import { getTrad } from '../../utils/getTranslation';
+import pluginPermissions from '../../utils/permissions';
import CustomFieldModal from './components/CustomFieldModal';
import CustomFieldTable from './components/CustomFieldTable';
-import { HandleSetContentTypeExpanded, OnPopupClose, OnSave, PreparePayload, RawPayload, RestartReasons, RestartStatus, StrapiContentTypeSchema } from './types';
-import pluginPermissions from '../../permissions';
-import NoAcccessPage from '../NoAccessPage';
-
-const RESTART_NOT_REQUIRED: RestartStatus = { required: false }
-const RESTART_REQUIRED: RestartStatus = { required: true, reasons: [] }
-const RELATION_ATTRIBUTE_TYPES = ['relation', 'media', 'component'];
-const STRING_ATTRIBUTE_TYPES = ['string', 'uid'];
+import {
+ UiFormSchema,
+ uiFormSchema,
+ useConfig,
+ useContentTypes,
+ useRestart,
+ useRestoreConfig,
+ useSaveConfig,
+} from './hooks';
+import { RestartStatus } from './types';
+import { isContentTypeEligible, waitForServerRestart } from './utils';
+
const BOX_DEFAULT_PROPS = {
- background: "neutral0",
+ background: 'neutral0',
hasRadius: true,
- shadow: "filterShadow",
+ shadow: 'filterShadow',
padding: 6,
};
-const noopFallback = () => {}
+const queryClient = new QueryClient();
+
+const Inner = () => {
+ const configQuery = useConfig();
+ const contentTypesQuery = useContentTypes();
+ const configSaveMutation = useSaveConfig();
+ const restoreMutation = useRestoreConfig();
+ const restartMutation = useRestart();
+
+ const { formatMessage } = useIntl();
+
+ const [restartStatus, setRestartStatus] = useState({ required: false });
+ const [isReloading, setIsReloading] = useState(false);
-const SettingsPage = () => {
- const { lockApp = noopFallback, unlockApp = noopFallback } = useOverlayBlocker();
- const { lockAppWithAutoreload = noopFallback, unlockAppWithAutoreload = noopFallback } = useAutoReloadOverlayBlocker();
- const [restartStatus, setRestartStatus] = useState(RESTART_NOT_REQUIRED);
- const [pruneObsoleteI18nNavigations, setPruneObsoleteI18nNavigations] = useState(false);
+ const readPermissions = useAuth('SettingsPage', (state) => state.permissions);
+ const hasSettingsPermissions = useMemo(() => {
+ return !!readPermissions.find(({ action }) => action === pluginPermissions.settings[0].action);
+ }, [readPermissions]);
+ const hasSettingsReadPermissions = useMemo(() => {
+ return !!readPermissions.find(({ action }) => action === pluginPermissions.access[0].action);
+ }, [readPermissions]);
+
+ const isLoading =
+ configQuery.isPending ||
+ contentTypesQuery.isPending ||
+ configSaveMutation.isPending ||
+ restartMutation.isPending ||
+ restoreMutation.isPending;
+
+ const [formValue, setFormValue] = useState({} as UiFormSchema);
+ const [formError, setFormError] = useState>();
const [isCustomFieldModalOpen, setIsCustomFieldModalOpen] = useState(false);
- const [customFieldSelected, setCustomFieldSelected] = useState(null);
- const [customFields, setCustomFields] = useState([]);
const [isRestorePopupOpen, setIsRestorePopupOpen] = useState(false);
- const [contentTypeExpanded, setContentTypeExpanded] = useState(undefined);
- const { data: navigationConfigData, isLoading: isConfigLoading, error: configErr, submitMutation, restoreMutation, restartMutation }: ToBeFixed = useNavigationConfig();
- const { data: allContentTypesData, isLoading: isContentTypesLoading, error: contentTypesErr }: ToBeFixed = useAllContentTypes();
-
- const viewPermissions: ToBeFixed = useMemo(
- () => ({
- settings: pluginPermissions.settings
- }),
- [],
+ const [customFieldSelected, setCustomFieldSelected] = useState(
+ null
);
const {
- isLoading: isLoadingForPermissions,
- allowedActions: {
- canSettings: canManageSettings,
- },
- } = useRBAC(viewPermissions);
-
- const isLoading = isConfigLoading || isContentTypesLoading;
- const isError = configErr || contentTypesErr;
- const configContentTypes: StrapiContentTypeSchema[] = navigationConfigData?.contentTypes || [];
-
- const formikInitialValues = useMemo(() => ({
- allowedLevels: get(navigationConfigData, "allowedLevels", 2),
- audienceFieldChecked: get(navigationConfigData, "additionalFields", []).includes(navigationItemAdditionalFields.AUDIENCE),
- cascadeMenuAttachedChecked: get(navigationConfigData, "cascadeMenuAttached", true),
- i18nEnabled: get(navigationConfigData, "i18nEnabled", false),
- nameFields: get(navigationConfigData, "contentTypesNameFields", {}),
- pathDefaultFields: get(navigationConfigData, "pathDefaultFields", {}),
- populate: get(navigationConfigData, "contentTypesPopulate", {}),
- selectedContentTypes: configContentTypes.map(item => item.uid),
- isCacheEnabled: get(navigationConfigData, "isCacheEnabled", false),
- preferCustomContentTypes: get(navigationConfigData, "preferCustomContentTypes", true) ?? true,
- }), [configContentTypes, navigationConfigData, navigationItemAdditionalFields]);
+ contentTypesNameFields: contentTypeNameFieldsCurrent,
+ contentTypes: contentTypesCurrent,
+ additionalFields,
+ preferCustomContentTypes,
+ } = formValue;
- const {
- disableI18nModal,
- setDisableI18nModalOpened,
- setI18nModalOnCancel,
- } = useDisableI18nModal(({ pruneNavigations }) => {
- setPruneObsoleteI18nNavigations(pruneNavigations)
- });
+ const handleChange = (eventOrPath: FormChangeEvent, value?: any, nativeOnChange?: (eventOrPath: FormChangeEvent, value?: any) => void) => {
+ if (nativeOnChange) {
+ let fieldName = eventOrPath;
+ let fieldValue = value;
- useEffect(() => {
- const additionalFields = navigationConfigData?.additionalFields
- ?.filter((field: NavigationItemAdditionalField) => field !== navigationItemAdditionalFields.AUDIENCE);
- setCustomFields(additionalFields || []);
- }, [navigationConfigData]);
-
- const preparePayload = useCallback(({
- form: {
- allowedLevels,
- audienceFieldChecked,
- cascadeMenuAttachedChecked,
- i18nEnabled,
- nameFields,
- pathDefaultFields,
- populate,
- selectedContentTypes,
- isCacheEnabled,
- preferCustomContentTypes,
- },
- pruneObsoleteI18nNavigations
- }) => ({
- additionalFields: audienceFieldChecked ? ['audience', ...customFields] : [...customFields],
- allowedLevels,
- cascadeMenuAttached: cascadeMenuAttachedChecked,
- contentTypes: selectedContentTypes,
- contentTypesNameFields: nameFields,
- contentTypesPopulate: populate,
- i18nEnabled,
- pathDefaultFields,
- pruneObsoleteI18nNavigations,
- gql: {
- navigationItemRelated: selectedContentTypes.map((uid: string) => resolveGlobalLikeId(uid)),
- },
- isCacheEnabled,
- preferCustomContentTypes,
- }), [customFields]);
-
- const onSave: OnSave = async (form) => {
- lockApp();
- const payload = preparePayload({ form, pruneObsoleteI18nNavigations });
- await submitMutation({ body: payload });
- const isContentTypesChanged = !isEqual(payload.contentTypes, navigationConfigData.contentTypes);
- const isI18nChanged = !isEqual(payload.i18nEnabled, navigationConfigData.i18nEnabled);
- const isCacheChanged = !isEqual(payload.isCacheEnabled, navigationConfigData.isCacheEnabled);
- const restartReasons: RestartReasons[] = []
- if (isI18nChanged) {
- restartReasons.push('I18N');
- }
- if (isCacheChanged) {
- restartReasons.push('CACHE');
- }
- if (isContentTypesChanged && navigationConfigData.isGQLPluginEnabled) {
- restartReasons.push('GRAPH_QL');
+ if (isObject(eventOrPath)) {
+ const { name: targetName, value: targetValue } = eventOrPath.target;
+ fieldName = targetName;
+ fieldValue = isNil(fieldValue) ? targetValue : fieldValue;
+ }
+
+ if (isString(fieldName)) {
+ setFormValueItem(fieldName, fieldValue);
+ }
+
+ return nativeOnChange(eventOrPath as FormChangeEvent, fieldValue);
}
- if (pruneObsoleteI18nNavigations) {
- restartReasons.push('I18N_NAVIGATIONS_PRUNE')
+ };
+
+ const setFormValueItem = (path: string, value: any) => {
+ setFormValue((current) =>
+ set(
+ {
+ ...current,
+ },
+ path,
+ value
+ )
+ );
+ };
+
+ const renderError = (error: string): string | undefined => {
+ const errorOccurence = get(formError, error);
+ if (errorOccurence) {
+ return formatMessage(getTrad(error));
}
- if (restartReasons.length) {
- setRestartStatus({
- ...RESTART_REQUIRED,
- reasons: restartReasons,
+ return undefined;
+ };
+
+ const handleOpenCustomFieldModal = (field: NavigationItemCustomField | null) => {
+ setCustomFieldSelected(field);
+ setIsCustomFieldModalOpen(true);
+ };
+
+ const handleRemoveCustomField = (field: NavigationItemCustomField) => {
+ const filteredFields = additionalFields.filter((f) =>
+ typeof f !== 'string' ? f.name !== field.name : true
+ );
+
+ setFormValueItem('additionalFields', filteredFields);
+
+ setCustomFieldSelected(null);
+ setIsCustomFieldModalOpen(false);
+ };
+
+ const handleToggleCustomField = (current: NavigationItemCustomField) => {
+ const next = { ...current, enabled: !current.enabled };
+
+ const nextAdditionalFields = additionalFields.map((field) =>
+ typeof field !== 'string' && current.name === field.name ? next : field
+ );
+
+ setFormValueItem('additionalFields', nextAdditionalFields);
+ };
+
+ const handleSubmitCustomField = (next: NavigationItemCustomField) => {
+ const hasFieldAlready = !!additionalFields.find((field) =>
+ typeof field !== 'string' ? field.name === next.name : false
+ );
+ const nextAdditionalFields = hasFieldAlready
+ ? additionalFields.map((field) =>
+ typeof field !== 'string' && next.name === field.name ? next : field
+ )
+ : [...additionalFields, next];
+
+ setFormValueItem('additionalFields', nextAdditionalFields);
+
+ setCustomFieldSelected(null);
+ setIsCustomFieldModalOpen(false);
+ };
+
+ const allContentTypes = !isLoading
+ ? sortBy(
+ Object.values(contentTypesQuery.data ?? [])
+ .filter(({ uid }) =>
+ isContentTypeEligible(uid, {
+ allowedContentTypes: configQuery.data?.allowedContentTypes,
+ restrictedContentTypes: configQuery.data?.restrictedContentTypes,
+ preferCustomContentTypes,
+ contentTypes: contentTypesCurrent,
+ })
+ )
+ .map((ct) => {
+ const type = contentTypesQuery.data?.find((_) => _.uid === ct.uid);
+
+ if (type) {
+ const { isDisplayed: available, kind } = type;
+ const isSingle = kind === 'singleType';
+
+ return {
+ ...ct,
+ available,
+ isSingle,
+ };
+ }
+ return ct;
+ }),
+ (ct) => ct.info.displayName
+ )
+ : [];
+
+ const submit = (e: React.MouseEvent, rawData: unknown) => {
+ const { success, data, error } = uiFormSchema.safeParse(rawData);
+
+ if (success) {
+ configSaveMutation.mutate(data, {
+ onSuccess() {
+ setRestartStatus({ required: true });
+
+ configSaveMutation.reset();
+ },
});
+ } else if (error) {
+ setFormError(error.issues.reduce((acc, err) => {
+ return {
+ ...acc,
+ [err.path.join('.')]: err.message
+ }
+ }, {} as FormItemErrorSchema));
+ console.warn('Invalid form data', error);
}
- setDisableI18nModalOpened(false);
- setPruneObsoleteI18nNavigations(false);
- unlockApp();
- }
+ };
- const onPopupClose: OnPopupClose = async (isConfirmed) => {
+ const onPopupClose = async (isConfirmed: boolean) => {
setIsRestorePopupOpen(false);
+
if (isConfirmed) {
- lockApp();
- await restoreMutation();
- unlockApp();
- setRestartStatus(RESTART_REQUIRED);
+ restoreMutation.mutate();
+
+ setRestartStatus({ required: true });
}
- }
+ };
const handleRestart = async () => {
- lockAppWithAutoreload();
- await restartMutation();
- unlockAppWithAutoreload();
- setRestartStatus(RESTART_NOT_REQUIRED);
- };
- const handleRestartDiscard = () => setRestartStatus(RESTART_NOT_REQUIRED);
- const handleSetContentTypeExpanded: HandleSetContentTypeExpanded = key => setContentTypeExpanded(key === contentTypeExpanded ? undefined : key);
+ restartMutation.mutate(undefined, {
+ onSuccess() {
+ setIsReloading(true);
- if (!(isLoadingForPermissions || canManageSettings)) {
- return ()
- }
+ waitForServerRestart(true).then((isReady) => {
+ if (isReady) {
+ window.location.reload();
+ }
+ });
+ },
+ onError() {
+ setRestartStatus({ required: false });
+ },
+ });
- if (isLoading || isError) {
- return (
- <>
-
-
- {/* TODO: use translation */}
- Fetching plugin config...
-
- >
- )
- }
-
- const isI18NPluginEnabled = navigationConfigData?.isI18NPluginEnabled;
- const isCachePluginEnabled = navigationConfigData?.isCachePluginEnabled;
- const defaultLocale = navigationConfigData?.defaultLocale;
+ };
- const handleSubmitCustomField = (field: NavigationItemCustomField) => {
- const filteredFields = customFields.filter(f => f.name !== field.name);
- setCustomFields([...filteredFields, field]);
- setCustomFieldSelected(null);
- setIsCustomFieldModalOpen(false);
- }
+ const handleRestartDiscard = () => setRestartStatus({ required: false });
- const handleOpenCustomFieldModal = (field: NavigationItemCustomField | null) => {
- setCustomFieldSelected(field);
- setIsCustomFieldModalOpen(true);
- }
+ useEffect(() => {
+ if (configQuery.data) {
+ setFormValue({
+ ...configQuery.data,
+ additionalFields: configQuery.data.additionalFields.filter((field) => typeof field !== 'string'),
+ audienceFieldChecked: configQuery.data.additionalFields.includes('audience'),
+ contentTypesNameFields: Object.entries(configQuery.data.contentTypesNameFields).map(
+ ([key, fields]) => ({
+ key,
+ fields,
+ })
+ ),
+ contentTypesPopulate: Object.entries(configQuery.data.contentTypesPopulate).map(
+ ([key, fields]) => ({
+ key,
+ fields,
+ })
+ ),
+ pathDefaultFields: Object.entries(configQuery.data.pathDefaultFields).map(([key, fields]) => ({
+ key,
+ fields,
+ })),
+ } as UiFormSchema);
+ }
+ }, [configQuery.data]);
- const handleRemoveCustomField = (field: NavigationItemCustomField) => {
- const filteredFields = customFields.filter(f => f.name !== field.name);
- setCustomFields(filteredFields);
- setCustomFieldSelected(null);
- setIsCustomFieldModalOpen(false);
+ if (!hasSettingsPermissions) {
+ return ;
}
- const handleToggleCustomField = (field: NavigationItemCustomField) => {
- const updatedField = { ...field, enabled: !get(field, 'enabled', false) }
- const filteredFields = customFields.filter(f => f.name !== field.name);
- setCustomFields([...filteredFields, updatedField]);
+ if (isLoading || isReloading) {
+ return ;
}
return (
- <>
-
-
-
- {({ handleSubmit, setFieldValue, values }) => {
- const allContentTypes: StrapiContentTypeSchema[] = !isLoading ? sortBy(Object.values(allContentTypesData).filter(({ uid }) => isContentTypeEligible(uid, {
- allowedContentTypes: navigationConfigData?.allowedContentTypes,
- restrictedContentTypes: navigationConfigData?.restrictedContentTypes,
- selectedContentTypes: values?.selectedContentTypes,
- preferCustomContentTypes: values?.preferCustomContentTypes,
- })).map(ct => {
- const type = configContentTypes.find(_ => _.uid === ct.uid);
- if (type) {
- const { available, isSingle } = type;
- return {
- ...ct,
- available,
- isSingle,
- };
- }
- return ct;
- }), ct => ct.info.displayName) : [];
-
- return (
-
- )}}
-
-
- {isCustomFieldModalOpen &&
+
+
+ {hasSettingsReadPermissions ? (
+ }
+ onClick={() => setIsRestorePopupOpen(true)}
+ >
+ {formatMessage(getTrad('pages.settings.actions.restore.label'))}
+
+ ) : null}
+ }
+ onConfirm={() => onPopupClose(true)}
+ onCancel={() => onPopupClose(false)}
+ >
+ {formatMessage(
+ getTrad('pages.settings.actions.restore.confirmation.description')
+ )}
+
+
+
+
+
+ )
+ }}
+
+
+
+ {isCustomFieldModalOpen && (
setIsCustomFieldModalOpen(false)}
onSubmit={handleSubmitCustomField}
isOpen={isCustomFieldModalOpen}
data={customFieldSelected}
- usedCustomFieldNames={customFields.filter(f => f.name !== customFieldSelected?.name).map(f => f.name)}
/>
- }
- >
+ )}
+
);
-}
+};
+export default function SettingsPage() {
+ queryClient.invalidateQueries();
+ const theme = usePluginTheme();
-export default SettingsPage;
+ return (
+
+
+
+
+
+ );
+}
diff --git a/admin/src/pages/SettingsPage/types.ts b/admin/src/pages/SettingsPage/types.ts
index ef73b738..ee3255f9 100644
--- a/admin/src/pages/SettingsPage/types.ts
+++ b/admin/src/pages/SettingsPage/types.ts
@@ -1,22 +1,2 @@
-import { StrapiContentTypeFullSchema } from "strapi-typed";
-import { Effect, NavigationPluginConfig } from "../../../../types";
-export type RawPayload = {
- allowedLevels: number;
- audienceFieldChecked: boolean;
- cascadeMenuAttachedChecked: boolean;
- i18nEnabled: boolean;
- nameFields: Record;
- pathDefaultFields: Record;
- populate: Record;
- selectedContentTypes: string[];
- isCacheEnabled: boolean;
- preferCustomContentTypes: boolean;
-}
-export type StrapiContentTypeSchema = StrapiContentTypeFullSchema & { available: boolean, isSingle: boolean, plugin: string, label: string }
-
-export type PreparePayload = (payload: { form: RawPayload, pruneObsoleteI18nNavigations: boolean }) => NavigationPluginConfig;
-export type OnSave = Effect;
-export type OnPopupClose = Effect;
-export type HandleSetContentTypeExpanded = Effect;
-export type RestartReasons = 'I18N' | 'GRAPH_QL' | 'I18N_NAVIGATIONS_PRUNE' | 'CACHE';
-export type RestartStatus = { required: true, reasons?: RestartReasons[] } | { required: false };
+export type RestartReasons = 'GRAPH_QL';
+export type RestartStatus = { required: true; reasons?: RestartReasons[] } | { required: false };
\ No newline at end of file
diff --git a/admin/src/pages/SettingsPage/utils/form.ts b/admin/src/pages/SettingsPage/utils/form.ts
deleted file mode 100644
index 35ef2c98..00000000
--- a/admin/src/pages/SettingsPage/utils/form.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { object, string, mixed, bool, array } from "yup";
-import { translatedErrors } from "@strapi/helper-plugin";
-import { getTradId } from "../../../translations";
-import { NavigationItemCustomField, NavigationItemCustomFieldType } from "../../../../../types";
-import { customFieldsTypes } from "../common";
-
-export const schemaFactory = (usedCustomFieldNames: string[]) => {
- return object({
- name: string().matches(/^\S+$/, "Invalid name string. Name cannot contain spaces").required(translatedErrors.required).notOneOf(usedCustomFieldNames, translatedErrors.unique),
- label: string().required(translatedErrors.required),
- type: mixed().required(translatedErrors.required).oneOf(customFieldsTypes, getTradId("notification.error.customField.type")),
- required: bool().required(translatedErrors.required),
- multi: mixed().when(
- 'type', {
- is: (val: NavigationItemCustomFieldType) => val === 'select',
- then: bool().required(translatedErrors.required),
- otherwise: bool().notRequired()
- }),
- options: mixed().when(
- 'type', {
- is: (val: NavigationItemCustomFieldType) => val === 'select',
- then: array().of(string()),
- otherwise: mixed().notRequired(),
- }),
- enabled: bool().notRequired(),
- });
-};
-
-export const defaultValues: NavigationItemCustomField = {
- name: "",
- label: "",
- type: "string",
- required: false,
- multi: false,
- options: [],
- enabled: true,
-};
diff --git a/admin/src/pages/SettingsPage/utils/functions.js b/admin/src/pages/SettingsPage/utils/functions.js
deleted file mode 100644
index ae0bb51d..00000000
--- a/admin/src/pages/SettingsPage/utils/functions.js
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict';
-
-const { capitalize } = require("lodash");
-
-const UID_REGEX = /^(?[a-z0-9-]+)\:{2}(?[a-z0-9-]+)\.{1}(?[a-z0-9-]+)$/i;
-
-const splitTypeUid = (uid = '') => {
- return uid.split(UID_REGEX).filter((s) => s && s.length > 0);
-};
-
-module.exports = {
- resolveGlobalLikeId(uid = '') {
- const parse = (str) => str.split('-')
- .map(_ => capitalize(_))
- .join('');
-
- const [type, scope, contentTypeName] = splitTypeUid(uid);
- if (type === 'api') {
- return parse(contentTypeName);
- }
- return `${parse(scope)}${parse(contentTypeName)}`;
- },
-
- isContentTypeEligible(uid = '', config = {}) {
- const {
- allowedContentTypes: baseAllowedContentTypes = [],
- restrictedContentTypes = [],
- preferCustomContentTypes = false,
- selectedContentTypes = [],
- } = config;
-
- const allowedContentTypes = preferCustomContentTypes ? ["api::", ...selectedContentTypes] : baseAllowedContentTypes;
-
- const isOneOfAllowedType = allowedContentTypes.filter(_ => uid.includes(_) || (uid === _)).length > 0;
- const isNoneOfRestricted = restrictedContentTypes.filter(_ => uid.includes(_) || (uid === _)).length === 0;
-
- return !!uid && isOneOfAllowedType && isNoneOfRestricted;
- },
-}
\ No newline at end of file
diff --git a/admin/src/pages/SettingsPage/utils/index.ts b/admin/src/pages/SettingsPage/utils/index.ts
new file mode 100644
index 00000000..f8ae0e53
--- /dev/null
+++ b/admin/src/pages/SettingsPage/utils/index.ts
@@ -0,0 +1,83 @@
+import { capitalize } from 'lodash';
+
+type Config = {
+ allowedContentTypes?: string[];
+ restrictedContentTypes?: string[];
+ preferCustomContentTypes?: boolean;
+ contentTypes?: string[];
+};
+
+const UID_REGEX = /^(?[a-z0-9-]+)\:{2}(?[a-z0-9-]+)\.{1}(?[a-z0-9-]+)$/i;
+
+export const isContentTypeEligible = (uid = '', config: Config = {}) => {
+ const {
+ allowedContentTypes: baseAllowedContentTypes = [],
+ restrictedContentTypes = [],
+ contentTypes = [],
+ preferCustomContentTypes = false,
+ } = config;
+
+ const allowedContentTypes = preferCustomContentTypes
+ ? ['api::', ...contentTypes]
+ : baseAllowedContentTypes;
+
+ const isOneOfAllowedType =
+ allowedContentTypes.filter((_) => uid.includes(_) || uid === _).length > 0;
+ const isNoneOfRestricted =
+ restrictedContentTypes.filter((_) => uid.includes(_) || uid === _).length === 0;
+
+ return !!uid && isOneOfAllowedType && isNoneOfRestricted;
+};
+
+export const resolveGlobalLikeId = (uid = '') => {
+ const parse = (str: string) =>
+ str
+ .split('-')
+ .map((_) => capitalize(_))
+ .join('');
+
+ const [type, scope, contentTypeName] = splitTypeUid(uid);
+ if (type === 'api') {
+ return parse(contentTypeName);
+ }
+ return `${parse(scope)}${parse(contentTypeName)}`;
+};
+
+const splitTypeUid = (uid = '') => {
+ return uid.split(UID_REGEX).filter((s) => s && s.length > 0);
+};
+
+const SERVER_OFFLINE_MESSAGE = "SERVER OFFLINE";
+
+export const waitForServerRestart = (response: any, didShutDownServer?: boolean) => {
+ return new Promise((resolve) => {
+ // @ts-ignore
+ fetch(`${window.strapi.backendURL}/_health`, {
+ method: 'HEAD',
+ mode: 'no-cors',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Keep-Alive': 'false',
+ },
+ })
+ .then((res) => {
+ if (res.status >= 400) {
+ throw new Error();
+ }
+
+ if (!didShutDownServer) {
+ throw new Error(SERVER_OFFLINE_MESSAGE);
+ }
+
+ resolve(response);
+ })
+ .catch((err) => {
+ setTimeout(() => {
+ return waitForServerRestart(
+ response,
+ err.message !== SERVER_OFFLINE_MESSAGE
+ ).then(resolve);
+ }, 100);
+ });
+ });
+}
diff --git a/admin/src/pages/View/components/I18nCopyNavigationItems/index.tsx b/admin/src/pages/View/components/I18nCopyNavigationItems/index.tsx
deleted file mode 100644
index e6028060..00000000
--- a/admin/src/pages/View/components/I18nCopyNavigationItems/index.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React, { VFC } from "react";
-import ConfirmationDialog from "../../../../components/ConfirmationDialog";
-import { getMessage } from "../../../../utils";
-
-export interface ConfirmEffect {
- (source: string): void;
-}
-
-export interface CancelEffect {
- (): void;
-}
-
-interface Props {
- onConfirm: ConfirmEffect;
- onCancel: CancelEffect;
-}
-
-const refreshIcon = <>>;
-
-export const I18nCopyNavigationItemsModal: VFC = ({
- onConfirm,
- onCancel,
-}) => {
- return (
-
- {getMessage("pages.view.actions.i18nCopyItems.confirmation.content")}
-
- );
-};
diff --git a/admin/src/pages/View/components/NavigationContentHeader/index.js b/admin/src/pages/View/components/NavigationContentHeader/index.js
deleted file mode 100644
index 05e3a302..00000000
--- a/admin/src/pages/View/components/NavigationContentHeader/index.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-
-import { Flex } from '@strapi/design-system/Flex';
-
-const NavigationContentHeader = ({ startActions, endActions }) => {
- return (
-
-
- {startActions}
-
-
- {endActions}
-
-
- );
-}
-
-export default NavigationContentHeader;
diff --git a/admin/src/pages/View/components/NavigationHeader/index.js b/admin/src/pages/View/components/NavigationHeader/index.js
deleted file mode 100644
index 33c7379f..00000000
--- a/admin/src/pages/View/components/NavigationHeader/index.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import React, { useMemo } from 'react';
-import { useIntl } from 'react-intl';
-import { HeaderLayout } from '@strapi/design-system/Layout';
-import { Stack } from '@strapi/design-system/Stack';
-import { Button } from '@strapi/design-system/Button';
-import Check from '@strapi/icons/Check';
-import More from '@strapi/icons/More';
-import Information from '@strapi/icons/Information';
-import { Tag } from '@strapi/design-system/Tag';
-import { getTrad } from '../../../../translations';
-import { MoreButton } from './styles';
-import { Select, Option } from '@strapi/design-system/Select';
-import { Box } from '@strapi/design-system/Box'
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-import { uniqBy } from 'lodash';
-import { useNavigationManager } from '../../../../hooks/useNavigationManager';
-
-const submitIcon = ;
-const pickDefaultLocaleNavigation = ({ activeNavigation, config }) => config.i18nEnabled
- ? activeNavigation
- ? activeNavigation.localeCode === config.defaultLocale
- ? activeNavigation
- : activeNavigation?.localizations.find(
- ({ localeCode }) => localeCode === config.defaultLocale
- )
- : null
- : activeNavigation;
-
-const NavigationHeader = ({
- activeNavigation,
- availableNavigations,
- structureHasErrors,
- structureHasChanged,
- handleChangeSelection,
- handleLocalizationSelection,
- handleSave,
- handleCachePurge,
- config,
- permissions = {},
-}) => {
- const { formatMessage } = useIntl();
- const allLocaleVersions = useMemo(
- () =>
- activeNavigation?.localizations.length && config.i18nEnabled
- ? uniqBy([activeNavigation, ...(activeNavigation.localizations ?? [])].sort((a, b) => a.localeCode.localeCompare(b.localeCode)), 'id')
- : [],
- [activeNavigation, config]
- );
- const hasLocalizations = config.i18nEnabled && allLocaleVersions.length;
- const passedActiveNavigation = pickDefaultLocaleNavigation({ activeNavigation, config });
- const { closeNavigationManagerModal, openNavigationManagerModal, navigationManagerModal } = useNavigationManager();
- const { canUpdate } = permissions;
-
- return (
- }>
- {
- activeNavigation
- ? formatMessage(
- getTrad('header.meta'),
- {
- id: activeNavigation?.id,
- key: activeNavigation?.slug
- })
- : null
- }
-
- }
- primaryAction={
-
-
- {/* TODO: Reorganise to use flex */}
-
- {!hasLocalizations ? () : null}
- {canUpdate && (
-
- )}
-
-
-
- {hasLocalizations
- ?
-
-
- : null
- }
- {canUpdate && (
-
- )}
- {config.isCacheEnabled && (
-
- )}
-
-
- {canUpdate && navigationManagerModal}
-
- }
- title={formatMessage({
- id: getTrad('header.title'),
- defaultMessage: 'UI Navigation',
- })}
- subtitle={formatMessage({
- id: getTrad('header.description'),
- defaultMessage: 'Define your portal navigation',
- })}
- />
- );
-};
-
-export default NavigationHeader;
diff --git a/admin/src/pages/View/components/NavigationItemForm/index.tsx b/admin/src/pages/View/components/NavigationItemForm/index.tsx
deleted file mode 100644
index 4b55d736..00000000
--- a/admin/src/pages/View/components/NavigationItemForm/index.tsx
+++ /dev/null
@@ -1,686 +0,0 @@
-import { FormikProps, useFormik } from 'formik';
-import { find, first, get, isEmpty, isEqual, isNil, isObject, isString, sortBy } from 'lodash';
-import { prop } from 'lodash/fp';
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-
-//@ts-ignore
-import { ModalBody } from '@strapi/design-system/ModalLayout';
-//@ts-ignore
-import { Option, Select } from '@strapi/design-system/Select';
-//@ts-ignore
-import { Grid, GridItem } from '@strapi/design-system/Grid';
-import { GenericInput } from '@strapi/helper-plugin';
-//@ts-ignore
-import { Button } from '@strapi/design-system/Button';
-
-import { Id } from 'strapi-typed';
-import { Audience, Effect, NavigationItemAdditionalField, NavigationItemType, ToBeFixed, assertString } from '../../../../../../types';
-import AdditionalFieldInput from '../../../../components/AdditionalFieldInput';
-import { getTrad, getTradId } from '../../../../translations';
-import { ResourceState, getDefaultCustomFields, getMessage, navigationItemType } from '../../../../utils';
-import { checkFormValidity } from '../../utils/form';
-import { extractRelatedItemLabel } from '../../utils/parsers';
-import { GenericInputOnChangeInput } from '../../utils/types';
-import { NavigationItemPopupFooter } from '../NavigationItemPopup/NavigationItemPopupFooter';
-import { ContentTypeSearchQuery, NavigationItemFormData, NavigationItemFormProps, RawFormPayload, SanitizedFormPayload, Slugify } from './types';
-import * as formDefinition from './utils/form';
-
-const NavigationItemForm: React.FC = ({
- config,
- availableLocale,
- isLoading: isPreloading,
- inputsPrefix,
- data,
- contentTypes = [],
- contentTypeEntities = [],
- usedContentTypeEntities = [],
- availableAudience = [],
- additionalFields = [],
- contentTypesNameFields = {},
- onSubmit,
- onCancel,
- getContentTypeEntities,
- usedContentTypesData,
- appendLabelPublicationStatus = appendLabelPublicationStatusFallback,
- locale,
- readNavigationItemFromLocale,
- slugify,
- permissions = {},
-}) => {
- const [isLoading, setIsLoading] = useState(isPreloading);
- const [hasBeenInitialized, setInitializedState] = useState(false);
- const [hasChanged, setChangedState] = useState(false);
- const [contentTypeSearchQuery, setContentTypeSearchQuery] = useState(undefined);
- const { canUpdate } = permissions;
- const formik: FormikProps = useFormik({
- initialValues: formDefinition.defaultValues,
- onSubmit: loadingAware(
- async (payload) => onSubmit(
- await sanitizePayload(slugify, payload, data)
- ),
- setIsLoading
- ),
- validate: loadingAware(
- async (values) => checkFormValidity(
- await sanitizePayload(slugify, values, {}),
- formDefinition.schemaFactory(isSingleSelected, additionalFields)
- ),
- setIsLoading
- ),
- validateOnChange: false,
- });
- const initialRelatedTypeSelected = get(data, 'relatedType.value');
- const relatedTypeSelectValue = formik.values.relatedType;
- const relatedSelectValue = formik.values.related;
- const isI18nBootstrapAvailable = !!(config.i18nEnabled && availableLocale && availableLocale.length);
- const availableLocaleOptions = useMemo(() => availableLocale.map((locale, index) => ({
- key: `${locale}-${index}`,
- value: locale,
- label: locale,
- metadatas: {
- intlLabel: {
- id: `i18n.locale.${locale}`,
- defaultMessage: locale,
- },
- hidden: false,
- disabled: false,
- },
- })), [availableLocale]);
-
- const relatedFieldName = `${inputsPrefix}related`;
-
- if (!hasBeenInitialized && !isEmpty(data)) {
- setInitializedState(true);
- formik.setValues({
- autoSync: get(data, "autoSync", formDefinition.defaultValues.autoSync) ?? true,
- type: get(data, "type", formDefinition.defaultValues.type),
- related: get(data, "related.value", formDefinition.defaultValues.related),
- relatedType: get(data, "relatedType.value", formDefinition.defaultValues.relatedType),
- audience: get(data, "audience", formDefinition.defaultValues.audience).map((item: Audience | Id) => isObject(item) ? item.id.toString() : item.toString()),
- additionalFields: getDefaultCustomFields({
- additionalFields,
- customFieldsValues: get(data, "additionalFields", [] as ToBeFixed),
- defaultCustomFieldsValues: formDefinition.defaultValues.additionalFields
- }),
- menuAttached: get(data, "menuAttached", formDefinition.defaultValues.menuAttached),
- path: get(data, "path", formDefinition.defaultValues.path),
- externalPath: get(data, "externalPath", formDefinition.defaultValues.externalPath),
- title: get(data, "title", formDefinition.defaultValues.title),
- updated: formDefinition.defaultValues.updated,
- });
- }
-
- const audienceOptions = useMemo(() => availableAudience.map((item) => ({
- value: get(item, 'id', " "),
- label: get(item, 'name', " "),
- })), [availableAudience]);
-
- const generatePreviewPath = () => {
- if (!isExternal) {
- const itemPath = isEmpty(formik.values.path) || formik.values.path === '/'
- ? getDefaultPath()
- : formik.values.path || "";
-
- const value = `${data.levelPath !== '/' ? `${data.levelPath}` : ''}/${itemPath}`;
-
- return {
- id: getTradId('popup.item.form.type.external.description'),
- defaultMessage: '',
- values: { value }
- }
- }
-
- return undefined;
- };
-
- const getDefaultTitle =
- useCallback((related: string | undefined, relatedType: string | undefined, isSingleSelected: boolean) => {
- let selectedEntity;
- if (isSingleSelected) {
- selectedEntity = contentTypeEntities.find(_ => (_.uid === relatedType) || (_.__collectionUid === relatedType));
- if (!selectedEntity) {
- return contentTypes.find(_ => _.uid === relatedType)?.label
- }
- } else {
- selectedEntity = {
- ...contentTypeEntities.find(_ => _.id === related),
- __collectionUid: relatedType
- };
- }
- return extractRelatedItemLabel(selectedEntity, contentTypesNameFields, { contentTypes });
- }, [contentTypeEntities, contentTypesNameFields, contentTypes]);
-
- const sanitizePayload = async (slugify: Slugify, payload: RawFormPayload, data: Partial): Promise => {
- const { related, relatedType, menuAttached, type, ...purePayload } = payload;
- const relatedId = related;
- const singleRelatedItem = isSingleSelected ? first(contentTypeEntities) : undefined;
- const relatedCollectionType = relatedType;
- const title = !!payload.title?.trim()
- ? payload.title
- : getDefaultTitle(related, relatedType, isSingleSelected)
- const uiRouterKey = await generateUiRouterKey(slugify, title, relatedId, relatedCollectionType);
-
- return {
- ...data,
- ...purePayload,
- title,
- type,
- menuAttached: isNil(menuAttached) ? false : menuAttached,
- path: type !== navigationItemType.EXTERNAL ? purePayload.path || getDefaultPath() : undefined,
- externalPath: type === navigationItemType.EXTERNAL ? purePayload.externalPath : undefined,
- related: type === navigationItemType.INTERNAL ? relatedId : undefined,
- relatedType: type === navigationItemType.INTERNAL ? relatedCollectionType : undefined,
- isSingle: isSingleSelected,
- singleRelatedItem,
- uiRouterKey,
- };
- };
-
- const onChange = ({ name, value }: { name: string, value: unknown }) => {
- formik.setValues(prevState => ({
- ...prevState,
- updated: true,
- [name]: value,
- }));
-
- if (name === "related" && relatedTypeSelectValue && formik.values.autoSync) {
- const { contentTypesNameFields, pathDefaultFields } = config;
- const selectedRelated = contentTypeEntities.find(({ id, __collectionUid }) =>
- `${id}` === `${value}` && __collectionUid === relatedTypeSelectValue
- );
- const newPath = pathDefaultFields[relatedTypeSelectValue]?.reduce((acc, field) => {
- return acc ? acc : selectedRelated?.[field] as string | null;
- }, null);
- const newTitle = (contentTypesNameFields[relatedTypeSelectValue] ?? []).concat(contentTypesNameFields.default || []).reduce((acc, field) => {
- return acc ? acc : selectedRelated?.[field] as string | null;
- }, null);
- const batch = [];
-
- if (newPath) {
- batch.push({ name: "path", value: newPath });
- }
-
- if (newTitle) {
- batch.push({ name: "title", value: newTitle });
- }
-
- batch.forEach((next, i) => {
- setTimeout(() => {
- onChange(next);
- }, i * 100);
- });
- }
-
- if (name === "type") {
- formik.setErrors({});
- }
- if (!hasChanged) {
- setChangedState(true);
- }
- };
-
- const getDefaultPath: () => string = useCallback(() => {
- if (formik.values.type !== "INTERNAL") return "";
- assertString(relatedTypeSelectValue);
-
- const pathDefaultFields = get(config, ["pathDefaultFields", relatedTypeSelectValue], []);
- if (isEmpty(formik.values.path) && !isEmpty(pathDefaultFields)) {
- const selectedEntity = isSingleSelected
- ? first(contentTypeEntities)
- : contentTypeEntities.find(i => String(i.id) === relatedSelectValue);
- const pathDefaultValues = pathDefaultFields
- .map((field) => get(selectedEntity, field, ""))
- .filter(value => !isNil(value) && String(value).match(/^\S+$/));
- return String(first(pathDefaultValues) || "");
- }
- return "";
- }, [relatedTypeSelectValue, formik, config]);
-
- const onAudienceChange = useCallback((value: string) => {
- onChange({ name: "audience", value });
- }, [onChange]);
-
- const onAdditionalFieldChange = (name: string, newValue: string | boolean | string[], fieldType: string) => {
- const fieldsValue = formik.values.additionalFields;
- const value = {
- ...fieldsValue,
- [name]: fieldType === "media" && newValue ? JSON.stringify(newValue) : newValue
- }
- onChange({
- name: "additionalFields",
- value,
- });
- }
-
- const generateUiRouterKey = async (slugify: Slugify, title: string, related?: string, relatedType?: string): Promise => {
- if (title) {
- return isString(title) && !isEmpty(title) ? await slugify(title).then(prop("slug")) : undefined;
- } else if (related) {
- const relationTitle = extractRelatedItemLabel({
- ...contentTypeEntities.find(_ => String(_.id) === String(related)),
- __collectionUid: relatedType
- }, contentTypesNameFields, { contentTypes });
- return isString(relationTitle) && !isEmpty(relationTitle) ? await slugify(relationTitle).then(prop("slug")) : undefined;
- }
- return undefined;
- };
-
- const isSingleSelected = useMemo(
- () => relatedTypeSelectValue ? contentTypes.find(_ => _.uid === relatedTypeSelectValue)?.isSingle || false : false,
- [relatedTypeSelectValue, contentTypes],
- );
-
- const navigationItemTypeOptions = (Object.keys(navigationItemType) as NavigationItemType[]).map((key) => {
- const value = navigationItemType[key].toLowerCase();
- return {
- key,
- value: navigationItemType[key],
- metadatas: {
- intlLabel: {
- id: getTradId(`popup.item.form.type.${value}.label`),
- defaultMessage: getTradId(`popup.item.form.type.${value}.label`),
- },
- hidden: false,
- disabled: false,
- }
- }
- });
-
- // TODO?: useMemo
- const relatedSelectOptions = sortBy(contentTypeEntities
- .filter((item) => {
- const usedContentTypeEntitiesOfSameType = usedContentTypeEntities
- .filter(uctItem => relatedTypeSelectValue === uctItem.__collectionUid);
- return !find(usedContentTypeEntitiesOfSameType, uctItem => (item.id === uctItem.id && uctItem.id !== formik.values.related));
- })
- .map((item) => {
- const label = appendLabelPublicationStatus(
- extractRelatedItemLabel({
- ...item,
- __collectionUid: get(relatedTypeSelectValue, 'value', relatedTypeSelectValue),
- }, contentTypesNameFields, { contentTypes }),
- item
- );
- return ({
- key: get(item, 'id').toString(),
- metadatas: {
- intlLabel: {
- id: label || `${item.__collectionUid} ${item.id}`,
- defaultMessage: label || `${item.__collectionUid} ${item.id}`,
- },
- hidden: false,
- disabled: false,
- },
- value: item.id.toString(),
- label: label,
- })
- }), item => item.metadatas.intlLabel.id);
-
- const isExternal = formik.values.type === navigationItemType.EXTERNAL;
- const pathSourceName = isExternal ? 'externalPath' : 'path';
-
- const submitDisabled =
- (formik.values.type === navigationItemType.INTERNAL && !isSingleSelected && isNil(formik.values.related)) || isLoading;
-
- const onChangeRelatedType = ({ target: { name, value } }: { target: { name: string, value: unknown } }) => {
- const relatedTypeBeingReverted = data.relatedType && (data.relatedType.value === get(value, 'value', value));
- setContentTypeSearchQuery(undefined);
- formik.setValues(prevState => ({
- ...prevState,
- updated: true,
- related: relatedTypeBeingReverted ? data.related?.value : undefined,
- [name]: value,
- }));
-
- if (!hasChanged) {
- setChangedState(true);
- }
- };
-
- const relatedTypeSelectOptions = useMemo(
- () => sortBy(contentTypes
- .filter((contentType) => {
- if (contentType.isSingle) {
- if (relatedTypeSelectValue && [relatedTypeSelectValue, initialRelatedTypeSelected].includes(contentType.uid)) {
- return true;
- }
- return !usedContentTypesData.some((_: ToBeFixed) => _.__collectionUid === contentType.uid && _.__collectionUid !== formik.values.relatedType);
- }
- return true;
- })
- .map((item) => ({
- key: get(item, 'uid'),
- metadatas: {
- intlLabel: {
- id: get(item, 'label', get(item, 'name')),
- defaultMessage: get(item, 'label', get(item, 'name')),
- },
- disabled: false,
- hidden: false,
- },
- value: get(item, 'uid'),
- label: get(item, 'label', get(item, 'name')),
- })), item => item.metadatas.intlLabel.id),
- [contentTypes, usedContentTypesData, relatedTypeSelectValue],
- );
-
- const thereAreNoMoreContentTypes = isEmpty(relatedSelectOptions) && !contentTypeSearchQuery;
-
- useEffect(
- () => {
- const value = get(relatedSelectOptions, '0');
- if (isSingleSelected && relatedSelectOptions.length === 1 && !isEqual(value, relatedSelectValue)) {
- onChange({ name: "related", value });
- }
- },
- [isSingleSelected, relatedSelectOptions],
- );
-
- useEffect(() => {
- const value = formik.values.relatedType;
- if (value) {
- const item = find(
- contentTypes,
- (_) => _.uid === value,
- );
- if (item) {
- getContentTypeEntities({
- modelUID: item.uid,
- query: contentTypeSearchQuery,
- locale,
- }, item.plugin);
- }
- }
- }, [formik.values.relatedType, contentTypeSearchQuery]);
-
- const resetCopyItemFormErrors = () => {
- formik.setErrors({
- ...formik.errors,
- [itemLocaleCopyField]: null,
- });
- }
- const itemLocaleCopyField = `${inputsPrefix}i18n.locale`;
- const itemLocaleCopyValue = get(formik.values, itemLocaleCopyField);
- const onCopyFromLocale = useCallback(async (event: React.BaseSyntheticEvent) => {
- event.preventDefault();
- event.stopPropagation();
-
- setIsLoading(true);
- resetCopyItemFormErrors();
-
- try {
- const result = await readNavigationItemFromLocale({
- locale: itemLocaleCopyValue,
- structureId: data.structureId
- });
-
- if (result.type === ResourceState.RESOLVED) {
- const { value: { related, ...rest } } = result;
-
- formik.setValues((prevState) => ({
- ...prevState,
- ...rest,
- }));
-
- if (related) {
- const relatedType = relatedTypeSelectOptions
- .find(({ value }) => value === related.__contentType)?.value;
-
- formik.setValues((prevState) => ({
- ...prevState,
- relatedType,
- [relatedFieldName]: related.id,
- }));
- }
- }
-
- if (result.type === ResourceState.ERROR) {
- formik.setErrors({
- ...formik.errors,
- [itemLocaleCopyField]: getMessage(result.errors[0]),
- });
- }
- } catch (error) {
- formik.setErrors({
- ...formik.errors,
- [itemLocaleCopyField]: getMessage('popup.item.form.i18n.locale.error.generic'),
- });
- }
-
- setIsLoading(false);
- }, [setIsLoading, formik.setValues, formik.setErrors]);
-
- const onChangeLocaleCopy = useCallback(({ target: { value } }: GenericInputOnChangeInput) => {
- resetCopyItemFormErrors();
- onChange({ name: itemLocaleCopyField, value })
- }, [onChange, itemLocaleCopyField]);
-
- const itemCopyProps = useMemo(() => ({
- intlLabel: {
- id: getTradId('popup.item.form.i18n.locale.label'),
- defaultMessage: 'Copy details from'
- },
- placeholder: {
- id: getTradId('popup.item.form.i18n.locale.placeholder'),
- defaultMessage: 'locale'
- },
- }), [getTradId]);
-
- useEffect(() => {
- const value = formik.values.relatedType;
- const fetchContentTypeEntities = async () => {
- if (value) {
- const item = find(
- contentTypes,
- (_) => _.uid === value,
- );
- if (item) {
- await getContentTypeEntities({
- modelUID: item.uid,
- query: contentTypeSearchQuery,
- locale,
- }, item.plugin);
- }
- }
- };
- fetchContentTypeEntities();
- }, [formik.values.relatedType, contentTypeSearchQuery]);
-
- return (
- <>
-
-
-
-
- onChange({ name, value })}
- value={formik.values.title}
- />
-
-
- onChange({ name, value })}
- value={formik.values.type}
- />
-
-
- onChange({ name, value })}
- value={formik.values.menuAttached}
- disabled={!canUpdate || (config.cascadeMenuAttached ? !(data.isMenuAllowedLevel && data.parentAttachedToMenu) : false)}
- />
-
-
- onChange({ name, value })}
- value={formik.values[pathSourceName]}
- description={generatePreviewPath()}
- />
-
- {formik.values.type === navigationItemType.INTERNAL && (
- <>
-
- onChange({ name, value })}
- value={formik.values.autoSync}
- />
-
-
-
-
- {formik.values.relatedType && !isSingleSelected && (
-
- onChange({ name, value })}
- options={relatedSelectOptions}
- value={formik.values.related}
- disabled={isLoading || thereAreNoMoreContentTypes || !canUpdate}
- description={
- !isLoading && thereAreNoMoreContentTypes
- ? {
- id: getTradId('popup.item.form.related.empty'),
- defaultMessage: 'There are no more entities',
- values: { contentTypeName: relatedTypeSelectValue },
- }
- : undefined
- }
- />
-
- )}
- >
- )}
- {additionalFields.map((additionalField: NavigationItemAdditionalField) => {
- if (additionalField === 'audience') {
- return (
-
-
-
- )
- } else {
- return (
-
-
-
- );
- }
- })}
-
- {
- isI18nBootstrapAvailable ? (
-
-
-
-
- {canUpdate && (
-
- )}
-
- ) : null
- }
-
-
-
- >
- );
-};
-
-const appendLabelPublicationStatusFallback = () => "";
-
-const loadingAware =
- (action: (i: T) => U, isLoading: Effect) =>
- async (input: T) => {
- try {
- isLoading(true);
-
- return await action(input);
- } catch (_) {
- } finally {
- isLoading(false);
- }
- };
-
-export default NavigationItemForm;
diff --git a/admin/src/pages/View/components/NavigationItemForm/types.ts b/admin/src/pages/View/components/NavigationItemForm/types.ts
deleted file mode 100644
index ff86882c..00000000
--- a/admin/src/pages/View/components/NavigationItemForm/types.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- Audience,
- Effect,
- ContentTypeEntity,
- NavigationItemAdditionalField,
- NavigationItemAdditionalFieldValues,
- NavigationItemType,
- NavigationPluginConfig,
- PluginConfigNameFields,
- PluginPermissions,
- ToBeFixed,
- VoidEffect
-} from '../../../../../../types';
-import { Id } from 'strapi-typed';
-import { StrapiContentTypeSchema } from '../../../SettingsPage/types';
-
-export type FormEventTarget = {
- name: string,
- value: TValue
-}
-
-type GetContentTypeEntitiesPayload = {
- modelUID: string;
- query: ContentTypeSearchQuery;
- locale: ToBeFixed;
-}
-
-export type NavigationItemFormData = {
- isMenuAllowedLevel: boolean;
- levelPath: string;
- parentAttachedToMenu: boolean;
- audience?: Audience[];
- collapsed?: boolean;
- externalPath?: string | null;
- id?: Id;
- isParentAttachedToMenu?: boolean;
- items?: ToBeFixed[];
- menuAttached?: boolean;
- order?: number;
- parent?: ToBeFixed;
- path?: string | null;
- title?: string;
- type?: NavigationItemType;
- uiRouterKey?: string;
- updated?: boolean;
- viewId?: string;
- viewParentId: string | null;
- related?: {
- value: string;
- label: string;
- };
- relatedType?: {
- value: string;
- label: string;
- };
- relatedRef?: ToBeFixed;
- structureId: ToBeFixed;
- additionalFields?: ToBeFixed;
-}
-
-export type NavigationItemFormProps = {
- additionalFields: NavigationItemAdditionalField[];
- appendLabelPublicationStatus: (label: string, entity: ContentTypeEntity) => string;
- availableAudience: string[];
- contentTypeEntities: ContentTypeEntity[];
- contentTypes: StrapiContentTypeSchema[];
- contentTypesNameFields: PluginConfigNameFields;
- data: NavigationItemFormData;
- getContentTypeEntities: (value: GetContentTypeEntitiesPayload, plugin: string) => ContentTypeEntity;
- isLoading: boolean;
- locale: string;
- onCancel: VoidEffect;
- onSubmit: Effect;
- usedContentTypeEntities: ToBeFixed[];
- usedContentTypesData: ToBeFixed;
- config: NavigationPluginConfig;
- availableLocale: string[];
- readNavigationItemFromLocale: ToBeFixed;
- inputsPrefix: string;
- slugify: (q: string) => Promise<{slug: string}>
- permissions: Partial;
-}
-
-export type ContentTypeSearchQuery = ToBeFixed;
-export type RawFormPayload = {
- type: NavigationItemType;
- autoSync?: boolean;
- related?: string;
- relatedType?: string;
- audience: Id[];
- menuAttached: boolean;
- title: string;
- externalPath: string | null;
- path: string | null;
- additionalFields: NavigationItemAdditionalFieldValues; // { cf_name: cf_value }
- updated: boolean;
-}
-
-export type SanitizedFormPayload = {
- title: string;
- type: NavigationItemType;
- menuAttached: boolean;
- path?: string | null;
- externalPath?: string | null;
- related: Id | undefined;
- relatedType: string | undefined;
- isSingle: boolean;
- singleRelatedItem: ContentTypeEntity | undefined;
- uiRouterKey: string | undefined;
-}
-
-export type Slugify = (q: string) => Promise<{
- slug: string;
-}>;
diff --git a/admin/src/pages/View/components/NavigationItemForm/utils/form.ts b/admin/src/pages/View/components/NavigationItemForm/utils/form.ts
deleted file mode 100644
index aca20bc4..00000000
--- a/admin/src/pages/View/components/NavigationItemForm/utils/form.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import * as yup from "yup";
-import { isNil } from "lodash";
-import { translatedErrors } from "@strapi/helper-plugin";
-import { navigationItemType } from "../../../../../utils";
-import { NavigationItemAdditionalField, NavigationItemType } from "../../../../../../../types";
-import { RawFormPayload } from "../types";
-import pluginId from "../../../../../pluginId";
-
-const externalPathRegexps = [
- /^mailto:[\w-\.]+@([\w-]+\.)+[\w-]{2,}$/,
- /^tel:(\+\d{1,3})?[\s]?(\(?\d{2,3}\)?)?[\s.-]?(\d{3})?[\s.-]?\d{3,4}$/,
- /^#.*/,
- /(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,})/,
-];
-
-export const schemaFactory = (isSingleSelected: boolean, additionalFields: NavigationItemAdditionalField[]) => {
- return yup.object({
- autoSync: yup.bool().optional(),
- title: yup.string()
- .when('type', {
- is: (val: NavigationItemType) => val !== navigationItemType.INTERNAL,
- then: yup.string()
- .required(translatedErrors.required),
- otherwise: yup.string().notRequired(),
- }),
- uiRouterKey: yup.string().required(translatedErrors.required),
- type: yup.string().required(translatedErrors.required),
- path: yup.string()
- .when('type', {
- is: (val: NavigationItemType) => val !== navigationItemType.EXTERNAL || isNil(val),
- then: yup.string().matches(/^\S+$/, "Invalid path string").required(translatedErrors.required),
- otherwise: yup.string().notRequired(),
- }),
- externalPath: yup.string()
- .when('type', {
- is: (val: NavigationItemType) => val === navigationItemType.EXTERNAL,
- then: yup.string()
- .required(translatedErrors.required)
- .test(
- `${pluginId}.popup.item.form.externalPath.validation.type`,
- externalPath =>
- externalPath ? externalPathRegexps.some(re => re.test(externalPath)) : true
- ),
- otherwise: yup.string().notRequired(),
- }),
- menuAttached: yup.boolean(),
- relatedType: yup.mixed()
- .when('type', {
- is: (val: NavigationItemType) => val === navigationItemType.INTERNAL || isNil(val),
- then: yup.string().required(translatedErrors.required).min(1, translatedErrors.required),
- otherwise: yup.mixed().notRequired(),
- }),
- related: yup.mixed()
- .when('type', {
- is: (val: NavigationItemType) => val === navigationItemType.INTERNAL || isNil(val),
- then: isSingleSelected ? yup.mixed().notRequired() : yup.string().required(translatedErrors.required).min(1, translatedErrors.required),
- otherwise: yup.mixed().notRequired(),
- }),
- additionalFields: yup.object({
- ...additionalFields.reduce((acc, current) => {
- var value;
- if (typeof current === 'string')
- return acc;
-
- if (current.type === 'boolean')
- value = yup.bool();
- else if (current.type === 'string')
- value = yup.string();
- else if (current.type === 'select' && current.multi)
- value = yup.array().of(yup.string());
- else if (current.type === 'select' && !current.multi)
- value = yup.string();
- else if (current.type === 'media')
- value = yup.mixed();
- else
- throw new Error(`Type "${current.type}" is unsupported by custom fields`);
-
- if (current.required)
- value = value.required(translatedErrors.required);
- else
- value = value.notRequired();
-
- return { ...acc, [current.name]: value }
- }, {})
- })
- });
-};
-
-export const defaultValues: RawFormPayload = {
- autoSync: true,
- type: "INTERNAL",
- related: "",
- relatedType: "",
- audience: [],
- menuAttached: false,
- title: "",
- externalPath: "",
- path: "",
- additionalFields: {
- boolean: false,
- string: "",
- },
- updated: false,
-}
diff --git a/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupFooter.js b/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupFooter.js
deleted file mode 100644
index e55c44b6..00000000
--- a/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupFooter.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Button } from '@strapi/design-system/Button';
-import { ModalFooter } from '@strapi/design-system/ModalLayout';
-import { getMessage } from '../../../../utils';
-
-export const NavigationItemPopupFooter = ({ handleCancel, handleSubmit, submitDisabled, formViewId, canUpdate }) => {
- if (!canUpdate) {
- return null;
- }
-
- return (
-
- {getMessage('popup.item.form.button.cancel')}
-
- }
- endActions={
-
- }
- />
- );
-};
-
-NavigationItemPopupFooter.defaultProps = {
- onValidate: undefined,
- submitDisabled: false,
- formViewId: undefined,
-};
-
-NavigationItemPopupFooter.propTypes = {
- handleCancel: PropTypes.func.isRequired,
- handleSubmit: PropTypes.func,
- submitDisabled: PropTypes.bool,
- formViewId: PropTypes.object,
- canUpdate: PropTypes.bool,
-};
diff --git a/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupHeader.js b/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupHeader.js
deleted file mode 100644
index 3178e7a4..00000000
--- a/admin/src/pages/View/components/NavigationItemPopup/NavigationItemPopupHeader.js
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-import React from 'react';
-import { Typography } from '@strapi/design-system/Typography';
-import { ModalHeader } from '@strapi/design-system/ModalLayout';
-import { getMessage } from '../../../../utils';
-
-export const NavigationItemPopupHeader = ({isNewItem, canUpdate}) => {
- let modalType = 'view';
- if (canUpdate) {
- modalType = isNewItem ? 'new' : 'edit';
- }
- return (
-
-
- {getMessage(`popup.item.header.${modalType}`)}
-
-
- );
-};
\ No newline at end of file
diff --git a/admin/src/pages/View/components/NavigationItemPopup/index.js b/admin/src/pages/View/components/NavigationItemPopup/index.js
deleted file mode 100644
index 66d476ba..00000000
--- a/admin/src/pages/View/components/NavigationItemPopup/index.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- *
- * NavigationItemPopUp
- *
- */
-
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { find } from 'lodash';
-
-//Design System
-import { ModalLayout } from '@strapi/design-system/ModalLayout';
-
-import NavigationItemForm from '../NavigationItemForm';
-import { extractRelatedItemLabel, isRelationCorrect, isRelationPublished } from '../../utils/parsers';
-import { NavigationItemPopupHeader } from './NavigationItemPopupHeader';
-import { getMessage, navigationItemType } from '../../../../utils';
-
-const NavigationItemPopUp = ({
- availableLocale,
- isOpen,
- isLoading,
- data,
- config = {},
- onSubmit,
- onClose,
- usedContentTypeItems,
- getContentTypeItems,
- usedContentTypesData,
- locale,
- readNavigationItemFromLocale,
- slugify,
- permissions = {},
-}) => {
- const handleOnSubmit = (payload) => {
- onSubmit(payload);
- };
-
- const { related, relatedType } = data;
- const {
- availableAudience = [],
- additionalFields,
- contentTypes,
- contentTypeItems,
- contentTypesNameFields = {},
- } = config;
- const { canUpdate } = permissions;
-
- const appendLabelPublicationStatus = (label = '', item = {}, isCollection = false) => {
- const appendix = isRelationPublished({
- relatedRef: item,
- type: item.isSingle ? navigationItemType.INTERNAL : item.type,
- isCollection,
- }) ? '' : `[${getMessage('notification.navigation.item.relation.status.draft')}] `.toUpperCase();
- return `${appendix}${label}`;
- };
-
- const relatedTypeItem = find(contentTypes, item => item.uid === relatedType);
- const prepareFormData = data => {
- const relatedItem = find(contentTypeItems, item => item.id === related);
- return {
- ...data,
- type: isRelationCorrect(data) ? data.type : undefined,
- related: related && isRelationCorrect(data) ? {
- value: related,
- label: appendLabelPublicationStatus(
- extractRelatedItemLabel({
- ...relatedItem,
- __collectionUid: relatedType,
- }, contentTypesNameFields, config),
- relatedItem,
- ),
- } : undefined,
- relatedType: relatedType && isRelationCorrect(data) ? {
- value: relatedType,
- label: appendLabelPublicationStatus(relatedTypeItem.label || relatedTypeItem.name, relatedTypeItem, true),
- } : undefined,
- };
- };
- const preparedData = useMemo(prepareFormData.bind(null, data), [data]);
- const hasViewId = !!data.viewId;
-
- return (
-
-
-
-
-
- );
-};
-
-NavigationItemPopUp.propTypes = {
- data: PropTypes.object.isRequired,
- config: PropTypes.object.isRequired,
- isOpen: PropTypes.bool,
- isLoading: PropTypes.bool,
- onSubmit: PropTypes.func.isRequired,
- onClose: PropTypes.func.isRequired,
- getContentTypeItems: PropTypes.func.isRequired,
- locale: PropTypes.string,
- readNavigationItemFromLocale: PropTypes.func.isRequired,
- slugify: PropTypes.func.isRequired,
-};
-
-export default NavigationItemPopUp;
diff --git a/admin/src/pages/View/components/NavigationManager/AllNavigations/index.tsx b/admin/src/pages/View/components/NavigationManager/AllNavigations/index.tsx
deleted file mode 100644
index 3f95110c..00000000
--- a/admin/src/pages/View/components/NavigationManager/AllNavigations/index.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-// @ts-ignore
-import { BaseCheckbox } from "@strapi/design-system/BaseCheckbox";
-// @ts-ignore
-import { Box } from "@strapi/design-system/Box";
-// @ts-ignore
-import { Button } from "@strapi/design-system/Button";
-// @ts-ignore
-import { Flex } from "@strapi/design-system/Flex";
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-// @ts-ignore
-import { IconButton } from "@strapi/design-system/IconButton";
-// @ts-ignore
-import { Table, Tbody, Td, Th, Thead, Tr } from "@strapi/design-system/Table";
-// @ts-ignore
-import { Typography } from "@strapi/design-system/Typography";
-import { prop } from "lodash/fp";
-import React, { useCallback, useMemo } from "react";
-import useDataManager from "../../../../../hooks/useDataManager";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { INITIAL_NAVIGATION } from "../NewNavigation";
-import { CommonProps, ListState, Navigation } from "../types";
-import * as icons from "./icons";
-
-interface Props extends ListState, CommonProps {}
-
-export const AllNavigations = ({ navigations, selected, setState }: Props) => {
- const {
- config: { i18nEnabled, isCacheEnabled },
- } = useDataManager();
-
- const hasAnySelected = !!selected.length;
-
- const toggleSelected = useCallback(
- () =>
- setState({
- navigations,
- selected: hasAnySelected ? [] : navigations.map((n) => n),
- view: "LIST",
- }),
- [setState, navigations, hasAnySelected]
- );
-
- const currentlySelectedSet = useMemo(
- () => new Set(selected.map(prop("id"))),
- [selected]
- );
-
- const handleSelect = (navigation: Navigation, isSelected: boolean) => () => {
- setState({
- navigations,
- selected: isSelected
- ? selected.filter(({ id }) => id !== navigation.id)
- : selected.concat([navigation]),
- view: "LIST",
- });
- };
-
- const edit = (navigation: Navigation) => () => {
- setState({
- view: "EDIT",
- navigation,
- alreadyUsedNames: navigations.reduce(
- (acc, { name }) =>
- name !== navigation.name ? acc.concat([name]) : acc,
- []
- ),
- });
- };
-
- const _delete = (navigations: Array) => () => {
- setState({
- view: "DELETE",
- navigations,
- });
- };
-
- const purgeCache = (navigations: Array) => () => {
- setState({
- view: "CACHE_PURGE",
- navigations,
- });
- };
-
- const deleteSelected = useCallback(_delete(selected), [_delete]);
-
- const purgeSelected = useCallback(purgeCache(selected), [purgeCache]);
-
- return (
- <>
-
-
- {hasAnySelected ? (
-
-
- {getMessage({
- id: "popup.navigation.manage.table.hasSelected",
- props: {
- count: selected.length,
- },
- })}
-
-
- {isCacheEnabled ? (
-
- ) : null}
-
- ) : null}
-
-
-
-
-
-
-
- |
-
-
- {getMessage("popup.navigation.manage.table.id")}
-
- |
-
-
- {getMessage("popup.navigation.manage.table.name")}
-
- |
- {i18nEnabled ? (
-
-
- {getMessage("popup.navigation.manage.table.locale")}
-
- |
- ) : null}
-
-
- {getMessage("popup.navigation.manage.table.visibility")}
-
- |
-
- {isCacheEnabled ? (
-
-
-
-
-
- ) : null}
- |
-
-
-
- {navigations.map((navigation) => (
-
-
-
- |
-
- {navigation.id}
- |
-
-
- {navigation.name}
-
- |
- {i18nEnabled ? (
-
-
- {[navigation.localeCode]
- .concat(
- navigation.localizations?.map(prop("localeCode")) || []
- )
- .join(", ")}
-
- |
- ) : null}
-
- {navigation.visible
- ? getMessage("popup.navigation.manage.navigation.visible")
- : getMessage("popup.navigation.manage.navigation.hidden")}
- |
-
-
-
-
-
-
-
-
- {isCacheEnabled ? (
-
-
-
- ) : null}
-
- |
-
- ))}
-
-
- >
- );
-};
-
-export const AllNavigationsFooter: Footer = ({
- onClose,
- state,
- setState,
- navigations,
-}) => (
-
- setState({
- view: "CREATE",
- alreadyUsedNames: navigations.map(({ name }) => name),
- current: INITIAL_NAVIGATION,
- }),
- variant: "default",
- disabled: state.isLoading,
- children: getMessage("popup.navigation.manage.button.create"),
- }}
- />
-);
diff --git a/admin/src/pages/View/components/NavigationManager/DeletionConfirm/index.tsx b/admin/src/pages/View/components/NavigationManager/DeletionConfirm/index.tsx
deleted file mode 100644
index 997ea069..00000000
--- a/admin/src/pages/View/components/NavigationManager/DeletionConfirm/index.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-// @ts-ignore
-import { Button } from "@strapi/design-system/Button";
-// @ts-ignore
-import { Flex } from "@strapi/design-system/Flex";
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-// @ts-ignore
-import { Typography } from "@strapi/design-system/Typography";
-import { prop } from "lodash/fp";
-import React from "react";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { CommonProps, DeleteState, Navigation } from "../types";
-
-interface Props extends DeleteState, CommonProps {}
-
-export const DeletionConfirm = ({ navigations }: Props) => (
-
-
-
-
- {getMessage("popup.navigation.manage.delete.header")}
-
-
-
-
-
- {renderItems(navigations)}
-
-
-
-);
-
-export const DeleteConfirmFooter: Footer = ({ state, onSubmit, onReset }) => (
-
-);
-
-const renderItems = (navigations: Array) =>
- navigations.map(prop("name")).join(", ");
diff --git a/admin/src/pages/View/components/NavigationManager/ErrorDetails/index.tsx b/admin/src/pages/View/components/NavigationManager/ErrorDetails/index.tsx
deleted file mode 100644
index 03dab709..00000000
--- a/admin/src/pages/View/components/NavigationManager/ErrorDetails/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-// @ts-ignore
-import { useNotification } from "@strapi/helper-plugin";
-import React, { useEffect } from "react";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { CommonProps, ErrorState } from "../types";
-
-interface Props extends ErrorState, CommonProps {}
-
-export const ErrorDetails = ({ errors }: Props) => {
- const toggleNotification = useNotification();
-
- useEffect(() => {
- errors.map((error) => {
- toggleNotification({
- type: "warning",
- message: { id: "", defaultMessage: error.message },
- });
- console.error(error);
- });
- }, []);
-
- return (
-
-
- {getMessage("popup.navigation.manage.error.message")}
-
-
- );
-};
-
-export const ErrorDetailsFooter: Footer = ({ onReset }) => (
-
-);
diff --git a/admin/src/pages/View/components/NavigationManager/Footer/index.tsx b/admin/src/pages/View/components/NavigationManager/Footer/index.tsx
deleted file mode 100644
index 7598a604..00000000
--- a/admin/src/pages/View/components/NavigationManager/Footer/index.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// @ts-ignore
-import { Button } from "@strapi/design-system/Button";
-// @ts-ignore
-import { ModalFooter } from "@strapi/design-system/ModalLayout";
-import React from "react";
-import { VoidEffect } from "../../../../../../../types";
-import { Navigation, SetState, State } from "../types";
-
-interface FooterBaseProps {
- end?: ActionProps;
- start?: ActionProps;
-}
-
-export type Footer = React.FC<{
- navigations: Array;
- onClose?: VoidEffect;
- onReset: VoidEffect;
- onSubmit: VoidEffect;
- setState: SetState;
- state: State;
-}>;
-
-interface ActionProps {
- children: React.ReactNode;
- disabled?: boolean;
- onClick?: VoidEffect;
- variant: "danger" | "secondary" | "tertiary" | "default";
-}
-
-export const FooterBase: React.FC = ({ start, end }) => (
-
-);
-
-const renderActions = (actions: ActionProps | undefined): React.ReactNode =>
- actions ? (
-
- ) : null;
diff --git a/admin/src/pages/View/components/NavigationManager/Form/index.tsx b/admin/src/pages/View/components/NavigationManager/Form/index.tsx
deleted file mode 100644
index 14c73231..00000000
--- a/admin/src/pages/View/components/NavigationManager/Form/index.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-import { Form as BaseForm, GenericInput, GenericInputProps } from "@strapi/helper-plugin";
-// @ts-ignore
-import { Formik } from "formik";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { useIntl } from "react-intl";
-import * as yup from "yup";
-import { Effect } from "../../../../../../../types";
-import { getTradId } from "../../../../../translations";
-import { Navigation } from "../types";
-import { get } from "lodash";
-import { GenericInputOnChangeInput } from "../../../utils/types";
-
-interface Props {
- navigation: Partial;
- onChange: Effect;
- isLoading?: boolean;
- validationSchema: ReturnType;
-}
-
-export const Form = ({
- navigation,
- onChange: onChangeBase,
- isLoading,
- validationSchema,
-}: Props) => {
- const initialValues: Navigation= {
- id: get(navigation, "id", ""),
- name: get(navigation, "name", ""),
- }
- const onChange: GenericInputProps["onChange"] = useCallback(
- ({ target: { name, value } }: GenericInputOnChangeInput) => {
- onChangeBase({
- ...navigation,
- [name]: value,
- } as Navigation);
- },
- [onChangeBase, navigation]
- );
-
- const [error, setError] = useState(null);
-
- const errorProps = useMemo(
- () => ({
- name: error?.path === "name" ? error.message : undefined,
- visible: error?.path === "visible" ? error.message : undefined,
- }),
- [error]
- );
-
- useEffect(() => {
- validationSchema
- .validate(navigation)
- .then(() => setError(null))
- .catch(setError);
- }, [navigation, validationSchema, setError]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- //
- );
-};
-
-const formProps = {
- name: {
- intlLabel: {
- id: getTradId("popup.navigation.form.name.label"),
- defaultMessage: "Name",
- },
- placeholder: {
- id: getTradId("popup.navigation.form.name.placeholder"),
- defaultMessage: "Navigations's name",
- },
- },
- visible: {
- intlLabel: {
- id: getTradId("popup.navigation.form.visible.label"),
- defaultMessage: "Visibility",
- },
- },
-};
-
-export const validationSchemaFactory = (
- alreadyUsedNames: Array,
- formatMessage: ReturnType["formatMessage"]
-) =>
- yup.object({
- name: yup
- .string()
- .notOneOf(
- alreadyUsedNames,
- formatMessage({
- id: getTradId("popup.navigation.form.validation.name.alreadyUsed"),
- })
- )
- .required(
- formatMessage({
- id: getTradId("popup.navigation.form.validation.name.required"),
- })
- )
- .min(
- 2,
- formatMessage({
- id: getTradId("popup.navigation.form.validation.name.tooShort"),
- })
- ),
- visible: yup.boolean().required(
- formatMessage({
- id: getTradId("popup.navigation.form.validation.visible.required"),
- })
- ),
- });
diff --git a/admin/src/pages/View/components/NavigationManager/NavigationUpdate/index.tsx b/admin/src/pages/View/components/NavigationManager/NavigationUpdate/index.tsx
deleted file mode 100644
index c9de6322..00000000
--- a/admin/src/pages/View/components/NavigationManager/NavigationUpdate/index.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import React, { useCallback, useMemo } from "react";
-import { useIntl } from "react-intl";
-import { Effect } from "../../../../../../../types";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { Form, validationSchemaFactory } from "../Form";
-import { CommonProps, EditState, Navigation } from "../types";
-
-interface Props extends EditState, CommonProps {}
-
-export const NavigationUpdate = ({
- alreadyUsedNames,
- current,
- isLoading,
- navigation: initialValue,
- setState,
-}: Props) => {
- const { formatMessage } = useIntl();
-
- const onChange: Effect = useCallback(
- (updated) => {
- setState({
- view: "EDIT",
- alreadyUsedNames,
- current: updated,
- navigation: initialValue,
- });
- },
- [setState, initialValue, alreadyUsedNames]
- );
-
- const navigation: Partial = useMemo(
- () => current ?? initialValue,
- [current]
- );
-
- const validationSchema = useMemo(
- () => validationSchemaFactory(alreadyUsedNames, formatMessage),
- [alreadyUsedNames]
- );
-
- return (
-
- );
-};
-
-export const NavigationUpdateFooter: Footer = ({
- state,
- onSubmit,
- onReset,
-}) => (
-
-);
diff --git a/admin/src/pages/View/components/NavigationManager/NewNavigation/index.tsx b/admin/src/pages/View/components/NavigationManager/NewNavigation/index.tsx
deleted file mode 100644
index bef0f603..00000000
--- a/admin/src/pages/View/components/NavigationManager/NewNavigation/index.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-// @ts-ignore
-import { Button } from "@strapi/design-system/Button";
-import React, { useCallback, useMemo } from "react";
-import { useIntl } from "react-intl";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { Form, validationSchemaFactory } from "../Form";
-import {
- CommonProps,
- CreateState,
- Navigation,
-} from "../types";
-
-interface Props extends CreateState, CommonProps {}
-
-export const INITIAL_NAVIGATION = {
- name: "Navigation",
- items: [],
- visible: true,
-} as unknown as Navigation;
-
-export const NewNavigation = ({
- setState,
- current,
- isLoading,
- alreadyUsedNames,
-}: Props) => {
- const { formatMessage } = useIntl();
-
- const onSubmit = useCallback(
- (updated: Navigation) => {
- setState({
- view: "CREATE",
- current: updated,
- alreadyUsedNames,
- });
- },
- [setState]
- );
-
- const validationSchema = useMemo(
- () => validationSchemaFactory(alreadyUsedNames, formatMessage),
- [alreadyUsedNames]
- );
-
- return (
-
- );
-};
-
-export const NewNavigationFooter: Footer = ({
- state,
- onSubmit,
- onReset,
-}) => (
-
-);
diff --git a/admin/src/pages/View/components/NavigationManager/PurgeCacheConfirm/index.tsx b/admin/src/pages/View/components/NavigationManager/PurgeCacheConfirm/index.tsx
deleted file mode 100644
index fbd63796..00000000
--- a/admin/src/pages/View/components/NavigationManager/PurgeCacheConfirm/index.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-// @ts-ignore
-import { Button } from "@strapi/design-system/Button";
-// @ts-ignore
-import { Flex } from "@strapi/design-system/Flex";
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-// @ts-ignore
-import { Typography } from "@strapi/design-system/Typography";
-import { prop } from "lodash/fp";
-import React from "react";
-import { getMessage } from "../../../../../utils";
-import { Footer, FooterBase } from "../Footer";
-import { CommonProps, Navigation, PurgeCacheState } from "../types";
-
-interface Props extends PurgeCacheState, CommonProps {}
-
-export const PurgeCacheConfirm = ({ navigations }: Props) => (
-
-
-
-
- {getMessage("popup.navigation.manage.purge.header")}
-
-
-
-
-
- {renderItems(navigations)}
-
-
-
-);
-
-export const PurgeCacheConfirmFooter: Footer = ({ state, onSubmit, onReset }) => (
-
-);
-
-const renderItems = (navigations: Array) =>
- navigations.map(prop("name")).join(", ");
diff --git a/admin/src/pages/View/components/NavigationManager/index.tsx b/admin/src/pages/View/components/NavigationManager/index.tsx
deleted file mode 100644
index a9507dcd..00000000
--- a/admin/src/pages/View/components/NavigationManager/index.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-// @ts-ignore
-import { Flex } from "@strapi/design-system/Flex";
-// @ts-ignore
-import { Loader } from "@strapi/design-system/Loader";
-import {
- ModalBody,
- ModalHeader,
- ModalLayout,
- // @ts-ignore
-} from "@strapi/design-system/ModalLayout";
-import { sortBy } from "lodash";
-import { prop } from "lodash/fp";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { useIntl } from "react-intl";
-import { VoidEffect } from "../../../../../../types";
-import useDataManager from "../../../../hooks/useDataManager";
-import { getMessage } from "../../../../utils";
-import { AllNavigations, AllNavigationsFooter } from "./AllNavigations";
-import { DeleteConfirmFooter, DeletionConfirm } from "./DeletionConfirm";
-import { ErrorDetails, ErrorDetailsFooter } from "./ErrorDetails";
-import { Footer } from "./Footer";
-import { NavigationUpdate, NavigationUpdateFooter } from "./NavigationUpdate";
-import { NewNavigation, NewNavigationFooter } from "./NewNavigation";
-import { SetState, State } from "./types";
-import {
- PurgeCacheConfirm,
- PurgeCacheConfirmFooter,
-} from "./PurgeCacheConfirm";
-
-interface Props {
- initialState: State;
- isOpened?: boolean;
- onClose?: VoidEffect;
-}
-
-export const NavigationManager = ({
- initialState,
- isOpened,
- onClose,
-}: Props) => {
- const { formatMessage } = useIntl();
- const [state, setState] = useState(initialState);
-
- const {
- items = [],
- handleNavigationsDeletion,
- handleNavigationsPurge,
- handleSubmitNavigation,
- hardReset,
- } = useDataManager();
-
- const navigations = useMemo(() => sortBy(items, "id"), [items]);
-
- const onReset = useCallback(() => setState({ view: "INITIAL" }), [setState]);
-
- const onSubmit = useCallback(async () => {
- const performAction =
- state.view === "DELETE"
- ? async () => {
- await handleNavigationsDeletion(state.navigations.map(prop("id")));
- await hardReset();
- }
- : (state.view === "CREATE" || state.view === "EDIT") && state.current
- ? async () => {
- await handleSubmitNavigation(formatMessage, state.current);
- await hardReset();
- }
- : state.view === "CACHE_PURGE"
- ? async () => {
- await handleNavigationsPurge(state.navigations.map(prop("id")), true, true);
- await hardReset();
- }
- : () => {};
-
- try {
- setState({
- ...state,
- isLoading: true,
- });
- await performAction();
- setState({ view: "INITIAL" });
- } catch (error) {
- setState({
- view: "ERROR",
- errors: error instanceof Error ? [error] : [],
- });
- }
- }, [
- state,
- setState,
- hardReset,
- handleSubmitNavigation,
- handleNavigationsDeletion,
- ]);
-
- useEffect(() => {
- if (state.view === "INITIAL") {
- setState({
- view: "LIST",
- navigations,
- selected: [],
- });
- }
- }, [state.view]);
-
- const header = renderHeader(state);
- const content = renderContent(state, setState);
- const footer = renderFooter({
- state,
- setState,
- onClose,
- onSubmit,
- onReset,
- navigations,
- });
-
- return (
-
- {header}
- {content}
- {footer}
-
- );
-};
-
-const renderHeader = (state: State) => {
- switch (state.view) {
- case "LIST":
- case "CREATE":
- case "ERROR":
- case "CACHE_PURGE":
- case "DELETE": {
- return (
-
- {state.isLoading ? : null}
- {getMessage(`popup.navigation.manage.header.${state.view}`)}
-
- );
- }
- case "EDIT": {
- return (
-
- {state.isLoading ? : null}
- {getMessage({
- id: "popup.navigation.manage.header.EDIT",
- props: {
- name: state.navigation.name,
- },
- })}
-
- );
- }
- case "INITIAL": {
- return null;
- }
- default:
- return handleUnknownState(state);
- }
-};
-
-const renderContent = (state: State, setState: SetState) => {
- const commonProps = {
- setState,
- };
-
- switch (state.view) {
- case "LIST": {
- return ;
- }
- case "EDIT": {
- return ;
- }
- case "CREATE": {
- return ;
- }
- case "DELETE": {
- return ;
- }
- case "CACHE_PURGE": {
- return ;
- }
- case "INITIAL": {
- return ;
- }
- case "ERROR": {
- return ;
- }
- default:
- return handleUnknownState(state);
- }
-};
-
-const renderFooter: Footer = (props) => {
- switch (props.state.view) {
- case "LIST": {
- return ;
- }
- case "CREATE": {
- return ;
- }
- case "EDIT": {
- return ;
- }
- case "DELETE": {
- return ;
- }
- case "CACHE_PURGE": {
- return ;
- }
- case "ERROR": {
- return ;
- }
- case "INITIAL": {
- return null;
- }
- default:
- return handleUnknownState(props.state);
- }
-};
-
-const handleUnknownState = (state: any) => {
- console.warn(`Unknown state "${state?.view}". (${JSON.stringify(state)})`);
-
- return null;
-};
diff --git a/admin/src/pages/View/index.js b/admin/src/pages/View/index.js
deleted file mode 100644
index 490bf149..00000000
--- a/admin/src/pages/View/index.js
+++ /dev/null
@@ -1,417 +0,0 @@
-/*
- *
- * Navigation View
- *
- */
-
-import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';
-import { useIntl } from "react-intl";
-import { isEmpty, get } from "lodash";
-
-// Design System
-import { Main } from '@strapi/design-system/Main';
-import { Flex } from '@strapi/design-system/Flex';
-import { ContentLayout } from '@strapi/design-system/Layout';
-import { Typography } from '@strapi/design-system/Typography';
-import { Box } from '@strapi/design-system/Box';
-import { Icon } from '@strapi/design-system/Icon';
-import { Button } from '@strapi/design-system/Button';
-import { Select, Option } from '@strapi/design-system/Select';
-// @ts-ignore
-import { Grid, GridItem } from "@strapi/design-system/Grid";
-import { LoadingIndicatorPage, useNotification, useRBAC } from "@strapi/helper-plugin";
-import EmptyDocumentsIcon from '@strapi/icons/EmptyDocuments';
-import PlusIcon from "@strapi/icons/Plus";
-
-import pluginPermissions from '../../permissions';
-
-// Components
-import List from '../../components/NavigationItemList';
-import NavigationContentHeader from './components/NavigationContentHeader';
-import NavigationHeader from './components/NavigationHeader';
-import NavigationItemPopUp from "./components/NavigationItemPopup";
-import { useI18nCopyNavigationItemsModal } from '../../hooks/useI18nCopyNavigationItemsModal';
-import Search from '../../components/Search';
-import useDataManager from "../../hooks/useDataManager";
-import { getTrad } from '../../translations';
-import {
- transformItemToViewPayload,
- transformToRESTPayload,
- usedContentTypes,
- validateNavigationStructure,
-} from './utils/parsers';
-import NoAcccessPage from '../NoAccessPage';
-
-const View = () => {
- const toggleNotification = useNotification();
- const {
- items: availableNavigations,
- activeItem: activeNavigation,
- changedActiveItem: changedActiveNavigation,
- config,
- navigationItemPopupOpened,
- isLoading,
- isLoadingForAdditionalDataToBeSet,
- isLoadingForSubmit,
- handleChangeNavigationItemPopupVisibility,
- handleChangeSelection,
- handleChangeNavigationData,
- handleResetNavigationData,
- handleSubmitNavigation,
- handleLocalizationSelection,
- handleI18nCopy,
- getContentTypeItems,
- error,
- availableLocale: allAvailableLocale,
- readNavigationItemFromLocale,
- slugify,
- permissions,
- handleNavigationsPurge,
- } = useDataManager();
-
- const { canAccess, canUpdate } = permissions;
-
- const availableLocale = useMemo(
- () => allAvailableLocale.filter(locale => locale !== changedActiveNavigation?.localeCode),
- [changedActiveNavigation, allAvailableLocale]
- );
- const { i18nCopyItemsModal, i18nCopySourceLocale, setI18nCopyModalOpened, setI18nCopySourceLocale } = useI18nCopyNavigationItemsModal(
- useCallback((sourceLocale) => {
- const source = activeNavigation?.localizations?.find(({ localeCode }) => localeCode === sourceLocale);
-
- if (source) {
- handleI18nCopy(source.id, activeNavigation?.id);
- }
- }, [activeNavigation, handleI18nCopy])
- );
- const openI18nCopyModalOpened = useCallback(() => { i18nCopySourceLocale && setI18nCopyModalOpened(true) }, [setI18nCopyModalOpened, i18nCopySourceLocale]);
-
- const [activeNavigationItem, setActiveNavigationItemState] = useState({});
- const { formatMessage } = useIntl();
-
- const [{ value: searchValue, index: searchIndex }, setSearchValue] = useState({ value: '' });
- const [structureChanged, setStructureChanged] = useState(false);
- const isSearchEmpty = isEmpty(searchValue);
- const normalisedSearchValue = (searchValue || '').toLowerCase();
-
- const structureHasErrors = !validateNavigationStructure((changedActiveNavigation || {}).items);
-
- useEffect(() => {
- if(structureHasErrors) {
- toggleNotification({
- type: 'warning',
- message: getTrad('notification.error.item.relation'),
- });
- }
- }, [structureHasErrors]);
-
- const navigationSelectValue = get(activeNavigation, "id", null);
- const handleSave = () => isLoadingForSubmit || structureHasErrors
- ? null
- : handleSubmitNavigation(formatMessage, transformToRESTPayload(changedActiveNavigation, config));
- const handleCachePurge = () => {
- handleNavigationsPurge([navigationSelectValue]);
- };
-
- const changeNavigationItemPopupState = (visible, editedItem = {}) => {
- setActiveNavigationItemState(editedItem);
- handleChangeNavigationItemPopupVisibility(visible);
- };
-
- const addNewNavigationItem = useCallback((
- event,
- viewParentId = null,
- isMenuAllowedLevel = true,
- levelPath = '',
- parentAttachedToMenu = true,
- structureId = "0",
- ) => {
- if (canUpdate) {
- event.preventDefault();
- event.stopPropagation();
- changeNavigationItemPopupState(true, {
- viewParentId,
- isMenuAllowedLevel,
- levelPath,
- parentAttachedToMenu,
- structureId,
- });
- }
- }, [changeNavigationItemPopupState]);
-
- const usedContentTypesData = useMemo(
- () => changedActiveNavigation ? usedContentTypes(changedActiveNavigation.items) : [],
- [changedActiveNavigation],
- );
-
- const pullUsedContentTypeItem = (items = []) =>
- items.reduce((prev, curr) =>
- [...prev, curr.relatedRef ? {
- __collectionUid: curr.relatedRef.__collectionUid,
- id: curr.relatedRef.id
- } : undefined, ...pullUsedContentTypeItem(curr.items)].filter(item => item)
- , []);
- const usedContentTypeItems = pullUsedContentTypeItem(changedActiveNavigation?.items);
- const handleSubmitNavigationItem = (payload) => {
- const changedStructure = {
- ...changedActiveNavigation,
- items: transformItemToViewPayload(payload, changedActiveNavigation.items, config),
- };
- handleChangeNavigationData(changedStructure, true);
- setStructureChanged(true);
- };
-
- const filteredListFactory = (items, doUse, activeIndex) => {
- const filteredItems = items.reduce((acc, item) => {
- const subItems = !isEmpty(item.items) ? filteredListFactory(item.items, doUse) : [];
- if (doUse(item))
- return [item, ...subItems, ...acc];
- else
- return [...subItems, ...acc];
- }, []);
-
- if (activeIndex !== undefined) {
- const index = activeIndex % filteredItems.length;
-
- return filteredItems.map((item, currentIndex) => {
- return index === currentIndex ? ({ ...item, isSearchActive: true }) : item;
- });
- }
-
- return filteredItems;
- };
- const filteredList = !isSearchEmpty ? filteredListFactory(changedActiveNavigation.items.map(_ => ({..._})), (item) => (item?.title || '').toLowerCase().includes(normalisedSearchValue), normalisedSearchValue ? searchIndex : undefined) : [];
-
- const changeCollapseItemDeep = (item, isCollapsed) => {
- if (item.collapsed !== isCollapsed) {
- return {
- ...item,
- collapsed: isCollapsed,
- updated: true,
- items: item.items?.map(el => changeCollapseItemDeep(el, isCollapsed))
- }
- }
- return {
- ...item,
- items: item.items?.map(el => changeCollapseItemDeep(el, isCollapsed))
- }
- }
-
- const handleCollapseAll = () => {
- handleChangeNavigationData({
- ...changedActiveNavigation,
- items: changedActiveNavigation.items.map(item => changeCollapseItemDeep(item, true))
- }, true);
- setStructureChanged(true);
- }
-
- const handleExpandAll = () => {
- handleChangeNavigationData({
- ...changedActiveNavigation,
- items: changedActiveNavigation.items.map(item => changeCollapseItemDeep(item, false))
- }, true);
- setStructureChanged(true);
- }
-
- const handleItemReOrder = (item, newOrder) => {
- handleSubmitNavigationItem({
- ...item,
- order: newOrder,
- })
- }
-
- const handleItemRemove = (item) => {
- handleSubmitNavigationItem({
- ...item,
- removed: true,
- });
- }
-
- const handleItemRestore = (item) => {
- handleSubmitNavigationItem({
- ...item,
- removed: false,
- });
- };
-
- const handleItemToggleCollapse = (item) => {
- handleSubmitNavigationItem({
- ...item,
- collapsed: !item.collapsed,
- updated: true,
- isSearchActive: false,
- });
- }
-
- const handleItemEdit = (
- item,
- levelPath = '',
- parentAttachedToMenu = true,
- ) => {
- changeNavigationItemPopupState(true, {
- ...item,
- levelPath,
- parentAttachedToMenu,
- });
- };
-
- const onPopUpClose = (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Using Strapi design system select components inside a modal causes
- // extraneous close events to be fired. It is most likely related to how
- // the select component handles onOutsideClick events. In those situations
- // the event target element is the root HTML element.
- // This is a workaround to prevent the modal from closing in those cases.
- if (e.target.tagName !== 'HTML') {
- changeNavigationItemPopupState(false);
- }
- };
-
- const handleChangeNavigationSelection = (...args) => {
- handleChangeSelection(...args);
- setSearchValue({ value: '', index: 0 });
- }
-
- const endActions = [
- {
- onClick: handleExpandAll,
- disabled: isLoadingForSubmit,
- type: "submit",
- variant: 'tertiary',
- tradId: 'header.action.expandAll',
- margin: '8px',
- },
- {
- onClick: handleCollapseAll,
- disabled: isLoadingForSubmit,
- type: "submit",
- variant: 'tertiary',
- tradId: 'header.action.collapseAll',
- margin: '8px',
- },
- ];
- if (canUpdate) {
- endActions.push({
- onClick: addNewNavigationItem,
- startIcon: ,
- disabled: isLoadingForSubmit,
- type: "submit",
- variant: "default",
- tradId: 'header.action.newItem',
- margin: '16px',
- });
- }
-
- return (
-
-
-
- {isLoading && }
- {changedActiveNavigation && (
- <>
- }
- endActions={endActions.map(({ tradId, margin, ...item }, i) =>
-
-
-
- )}
- />
- {isEmpty(changedActiveNavigation.items || []) && (
-
-
-
- {formatMessage(getTrad('empty'))}
-
- {canUpdate && (}
- label={formatMessage(getTrad('empty.cta'))}
- onClick={addNewNavigationItem}
- >
- {formatMessage(getTrad('empty.cta'))}
- )}
- {
- canUpdate && config.i18nEnabled && availableLocale.length ? (
-
-
- {formatMessage(getTrad('view.i18n.fill.cta'))}
-
-
-
-
-
-
-
-
-
-
- ) : null
- }
-
- )}
- {
- !isEmpty(changedActiveNavigation.items || [])
- &&
- }
- >
- )}
-
- {navigationItemPopupOpened && }
- {canUpdate && i18nCopyItemsModal}
-
- );
-};
-
-export default memo(View);
diff --git a/admin/src/pages/View/utils/form.js b/admin/src/pages/View/utils/form.js
deleted file mode 100644
index 45e9a64e..00000000
--- a/admin/src/pages/View/utils/form.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { getYupInnerErrors } from '@strapi/helper-plugin';
-
-export const checkFormValidity = async (data, schema) => {
- let errors = null;
-
- try {
- await schema.validate(data, { abortEarly: false });
- } catch (err) {
- errors = getYupInnerErrors(err);
- }
-
- return errors;
-};
diff --git a/admin/src/pages/View/utils/index.js b/admin/src/pages/View/utils/index.js
deleted file mode 100644
index c3d60a45..00000000
--- a/admin/src/pages/View/utils/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-/* eslint-disable import/prefer-default-export */
-export { default as forms } from "./forms";
-export { default as parsers } from "./parsers";
diff --git a/admin/src/pages/View/utils/parsers.js b/admin/src/pages/View/utils/parsers.js
deleted file mode 100644
index 8dbe5545..00000000
--- a/admin/src/pages/View/utils/parsers.js
+++ /dev/null
@@ -1,327 +0,0 @@
-import { v4 as uuid, validate as isUuid } from 'uuid'
-import { find, get, isArray, isEmpty, isNil, isNumber, isObject, isString, last, omit, orderBy } from 'lodash';
-import { navigationItemType } from '../../../utils';
-
-export const transformItemToRESTPayload = (
- item,
- parent = undefined,
- master = undefined,
- config = {},
- parentAttachedToMenu = true,
-) => {
- const {
- id,
- title,
- type = navigationItemType.INTERNAL,
- updated = false,
- removed = false,
- uiRouterKey,
- menuAttached,
- path,
- externalPath,
- related,
- relatedType,
- order,
- audience = [],
- items = [],
- collapsed,
- isSingle,
- additionalFields = {},
- autoSync,
- } = item;
- const isExternal = type === navigationItemType.EXTERNAL;
- const isWrapper = type === navigationItemType.WRAPPER;
- const { contentTypes = [] } = config;
-
- const parsedRelated = Number(related);
- const relatedId = isExternal || isWrapper || isNaN(parsedRelated) ? related?.value || related : parsedRelated;
-
- const relatedContentType = relatedType ?
- find(contentTypes,
- ct => ct.uid === relatedType) :
- undefined;
- const itemAttachedToMenu = config.cascadeMenuAttached ? menuAttached && parentAttachedToMenu : menuAttached;
- return {
- id,
- parent,
- master,
- title,
- type,
- updated,
- removed,
- order,
- uiRouterKey,
- collapsed,
- additionalFields,
- autoSync,
- menuAttached: itemAttachedToMenu,
- audience: audience.map((audienceItem) =>
- isObject(audienceItem)
- ? audienceItem.id || audienceItem.value
- : audienceItem,
- ),
- path: isExternal ? undefined : path,
- externalPath: isExternal ? externalPath : undefined,
- related: isExternal || isWrapper
- ? undefined
- : [
- {
- refId: isSingle && !relatedId ? 1 : relatedId,
- ref: relatedContentType ? relatedContentType.uid : relatedType,
- field: relatedContentType && relatedContentType.relatedField ? relatedContentType.relatedField : 'navigation',
- },
- ],
- items: items.map((iItem) => transformItemToRESTPayload(iItem, id, master, config, itemAttachedToMenu)),
- };
-};
-
-export const transformToRESTPayload = (payload, config = {}) => {
- const { id, name, visible, items } = payload;
- return {
- id,
- name,
- visible,
- items: items.map((item) => transformItemToRESTPayload(item, null, id, config)),
- };
-};
-
-const linkRelations = (item, config) => {
- const { contentTypeItems = [], contentTypes = [] } = config;
- const { type, related, relatedType, relatedRef, isSingle } = item;
- let relation = {
- related: undefined,
- relatedRef: undefined,
- relatedType: undefined,
- };
-
- if (isSingle && relatedType) {
- const relatedContentType = contentTypes.find(_ => relatedType === _.uid) || {};
- const { singleRelatedItem = {} } = item;
- return {
- ...item,
- relatedType,
- relatedRef: {
- ...singleRelatedItem,
- ...omit(relatedContentType, 'collectionName'),
- isSingle,
- __collectionUid: relatedContentType.uid,
- },
- };
- }
-
- // we got empty array after remove object in relation
- // from API we got related as array but on edit it is primitive type
- if ((type !== navigationItemType.INTERNAL) || !related || (isObject(related) && isEmpty(related))) {
- return {
- ...item,
- ...relation,
- };
- }
-
- const relatedItem = isArray(related) ? last(related) : related;
-
- const parsedRelated = Number(related);
- const relatedId = isNaN(parsedRelated) ? related : parsedRelated;
-
- const relationNotChanged = relatedRef && relatedItem ? relatedRef.id === relatedItem : false;
-
- if (relationNotChanged) {
- return item;
- }
-
- const shouldFindRelated = (isNumber(related) || isUuid(related) || isString(related)) && !relatedRef;
- const shouldBuildRelated = !relatedRef || (relatedRef && (relatedRef.id !== relatedId));
-
- if (shouldBuildRelated && !shouldFindRelated) {
- const relatedContentType = find(contentTypes,
- ct => ct.uid === relatedItem.__contentType, {});
- const { uid, labelSingular, isSingle } = relatedContentType;
- relation = {
- related: relatedItem.id,
- relatedRef: {
- ...relatedItem,
- __collectionUid: uid,
- isSingle,
- labelSingular,
- },
- relatedType: uid,
- };
- } else if (shouldFindRelated) {
- const relatedRef = find(contentTypeItems, cti => cti.id === relatedId);
- const relatedContentType = find(contentTypes, ct => ct.uid === relatedType);
- const { uid, contentTypeName, labelSingular, isSingle } = relatedContentType;
-
- relation = {
- relatedRef: {
- ...relatedRef,
- __collectionUid: uid,
- __contentType: contentTypeName,
- isSingle,
- labelSingular,
- },
- };
- } else {
- return {
- ...item,
- };
- }
-
- return {
- ...item,
- ...relation,
- };
-};
-
-const reOrderItems = (items = []) =>
- orderBy(items, ['order'], ['asc'])
- .map((item, n) => {
- const order = n + 1;
- return {
- ...item,
- order,
- updated: item.updated || order !== item.order,
- };
- });
-
-export const transformItemToViewPayload = (payload, items = [], config) => {
- if (!payload.viewParentId) {
- if (payload.viewId) {
- const updatedRootLevel = items
- .map((item) => {
- if (item.viewId === payload.viewId) {
- return linkRelations({
- ...payload,
- }, config);
- }
- return {
- ...item,
- items: transformItemToViewPayload(payload, item.items, config),
- };
- });
- return reOrderItems(updatedRootLevel);
- }
- return [
- ...reOrderItems(items),
- linkRelations({
- ...payload,
- order: items.length + 1,
- viewId: uuid(),
- }, config),
- ];
- }
-
- const updatedLevel = items
- .map((item) => {
- const branchItems = item.items || [];
- if (payload.viewParentId === item.viewId) {
- if (!payload.viewId) {
- return {
- ...item,
- items: [
- ...reOrderItems(branchItems),
- linkRelations({
- ...payload,
- order: branchItems.length + 1,
- viewId: uuid(),
- }, config),
- ],
- };
- }
- const updatedBranchItems = branchItems
- .map((iItem) => {
- if (iItem.viewId === payload.viewId) {
- return linkRelations(payload, config);
- }
- return {
- ...iItem,
- };
- });
- return {
- ...item,
- items: reOrderItems(updatedBranchItems),
- };
- }
- return {
- ...item,
- items: transformItemToViewPayload(payload, item.items, config),
- };
- });
- return reOrderItems(updatedLevel);
-};
-
-export const prepareItemToViewPayload = ({
- items = [],
- viewParentId = null,
- config = {},
- structureIdPrefix = ''
-}) =>
- reOrderItems(items.map((item, n) => {
- const viewId = uuid();
- const structureId = structureIdPrefix ? `${structureIdPrefix}.${n}` : n.toString();
-
- return {
- ...linkRelations({
- viewId,
- viewParentId,
- ...item,
- order: item.order || (n + 1),
- structureId,
- updated: item.updated || isNil(item.order),
- }, config),
- items: prepareItemToViewPayload({
- config,
- items: item.items,
- structureIdPrefix: structureId,
- viewId,
- }),
- };
- }));
-
-export const extractRelatedItemLabel = (item = {}, fields = {}, config = {}) => {
- if (get(item, 'isSingle', false)) {
- return get(item, 'labelSingular', '');
- }
- const { contentTypes = [] } = config;
- const { __collectionUid } = item;
- const contentType = contentTypes.find(_ => _.uid === __collectionUid)
- const { default: defaultFields = [] } = fields;
- return get(fields, `${contentType ? contentType.uid : __collectionUid}`, defaultFields).map((_) => item[_]).filter((_) => _)[0] || '';
-};
-
-export const usedContentTypes = (items = []) => items.flatMap(
- (item) => {
- const used = (item.items ? usedContentTypes(item.items) : []);
- if (item.relatedRef) {
- return [item.relatedRef, ...used];
- }
- return used;
- },
-);
-
-export const isRelationCorrect = ({ related, type }) => {
- const isRelationDefined = !isNil(related);
- return type !== navigationItemType.INTERNAL || (type === navigationItemType.INTERNAL && isRelationDefined);
-};
-
-export const isRelationPublished = ({ relatedRef, relatedType = {}, type, isCollection }) => {
- if (isCollection) {
- return relatedType.available || relatedRef.available;
- }
- if ((type === navigationItemType.INTERNAL)) {
- const isHandledByPublshFlow = relatedRef ? 'published_at' in relatedRef : false;
- if (isHandledByPublshFlow) {
- return get(relatedRef, 'published_at', true);
- }
- }
- return true;
-};
-
-export const validateNavigationStructure = (items = []) =>
- items.map(item =>
- (
- item.removed ||
- isRelationCorrect({ related: item.related, type: item.type }) ||
- (item.isSingle && isRelationCorrect({ related: item.relatedType, type: item.type }))
- ) &&
- validateNavigationStructure(item.items)
- ).filter(item => !item).length === 0;
diff --git a/admin/src/pages/View/utils/types.ts b/admin/src/pages/View/utils/types.ts
deleted file mode 100644
index 0e612016..00000000
--- a/admin/src/pages/View/utils/types.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { GenericInputProps } from "@strapi/helper-plugin"
-
-export type GenericInputOnChangeInput = Parameters[0];
diff --git a/admin/src/permissions.js b/admin/src/permissions.js
deleted file mode 100644
index 978f6de7..00000000
--- a/admin/src/permissions.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import permissions from '../../permissions';
-
-const pluginPermissions = {
- access: [{ action: permissions.render(permissions.navigation.read), subject: null }],
- update: [{ action: permissions.render(permissions.navigation.update), subject: null }],
- settings: [{ action: permissions.render(permissions.navigation.settings), subject: null }],
- };
-
-export default pluginPermissions;
\ No newline at end of file
diff --git a/admin/src/pluginId.js b/admin/src/pluginId.js
deleted file mode 100644
index 3c0d7003..00000000
--- a/admin/src/pluginId.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const pluginPkg = require('../../package.json');
-
-const pluginId = pluginPkg.name.replace(/^strapi-plugin-/i, '');
-
-module.exports = pluginId;
diff --git a/admin/src/pluginId.ts b/admin/src/pluginId.ts
new file mode 100644
index 00000000..eac9601d
--- /dev/null
+++ b/admin/src/pluginId.ts
@@ -0,0 +1 @@
+export const PLUGIN_ID = 'navigation';
diff --git a/admin/src/schemas/config.ts b/admin/src/schemas/config.ts
new file mode 100644
index 00000000..65b655a2
--- /dev/null
+++ b/admin/src/schemas/config.ts
@@ -0,0 +1,69 @@
+import { z } from 'zod';
+
+export type NavigationItemCustomFieldBase = z.infer;
+const navigationCustomFieldBase = z.object({
+ // TODO: Proper message translation
+ name: z.string().refine((current) => !current.includes(' '), { message: 'No space allowed' }),
+ label: z.string(),
+ required: z.boolean().optional(),
+ enabled: z.boolean().optional(),
+});
+
+export type NavigationItemCustomFieldSelect = z.infer;
+const navigationItemCustomFieldSelect = navigationCustomFieldBase.extend({
+ type: z.literal('select'),
+ multi: z.boolean(),
+ options: z.array(z.string()),
+});
+
+export type NavigationItemCustomFieldPrimitive = z.infer;
+const navigationItemCustomFieldPrimitive = navigationCustomFieldBase.extend({
+ type: z.enum(['boolean', 'string']),
+ multi: z.literal(false).optional(),
+ options: z.array(z.string()).max(0).optional(),
+});
+
+export type NavigationItemCustomFieldMedia = z.infer;
+const navigationItemCustomFieldMedia = navigationCustomFieldBase.extend({
+ type: z.literal('media'),
+ multi: z.literal(false).optional(),
+ options: z.array(z.string()).max(0).optional(),
+});
+
+export type NavigationItemCustomField = z.infer;
+export const navigationItemCustomField = z.union([
+ navigationItemCustomFieldPrimitive,
+ navigationItemCustomFieldMedia,
+ navigationItemCustomFieldSelect,
+]);
+
+export type NavigationItemAdditionalField = z.infer;
+export const navigationItemAdditionalField = z.union([
+ z.literal('audience'),
+ navigationItemCustomField,
+]);
+
+export type ConfigSchema = z.infer;
+export const configSchema = z.object({
+ additionalFields: z.array(navigationItemAdditionalField),
+ allowedLevels: z.number(),
+ availableAudience: z
+ .object({
+ id: z.number(),
+ documentId: z.string(),
+ name: z.string(),
+ key: z.string(),
+ })
+ .array(),
+ contentTypes: z.array(z.string()),
+ contentTypesNameFields: z.record(z.string(), z.array(z.string())),
+ contentTypesPopulate: z.record(z.string(), z.array(z.string())),
+ gql: z.object({
+ navigationItemRelated: z.array(z.string()),
+ }),
+ pathDefaultFields: z.record(z.string(), z.any()),
+ cascadeMenuAttached: z.boolean(),
+ preferCustomContentTypes: z.boolean(),
+ isCacheEnabled: z.boolean().optional(),
+ isCachePluginEnabled: z.boolean().optional(),
+});
diff --git a/admin/src/schemas/content-type.ts b/admin/src/schemas/content-type.ts
new file mode 100644
index 00000000..2bb4e9c5
--- /dev/null
+++ b/admin/src/schemas/content-type.ts
@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+export type StrapiContentTypeSchema = z.infer;
+export const strapiContentTypeSchema = z.object({
+ uid: z.string(),
+ isDisplayed: z.boolean(),
+ apiID: z.string(),
+ kind: z.enum(['collectionType', 'singleType']),
+ info: z.object({
+ singularName: z.string(),
+ pluralName: z.string(),
+ displayName: z.string(),
+ description: z.string().optional(),
+ }),
+ attributes: z.record(z.string(), z.unknown()),
+});
diff --git a/admin/src/schemas/index.ts b/admin/src/schemas/index.ts
new file mode 100644
index 00000000..eb9eddde
--- /dev/null
+++ b/admin/src/schemas/index.ts
@@ -0,0 +1,2 @@
+export * from './config';
+export * from './content-type';
diff --git a/admin/src/translations/ca.json b/admin/src/translations/ca.json
deleted file mode 100644
index abf6575f..00000000
--- a/admin/src/translations/ca.json
+++ /dev/null
@@ -1,199 +0,0 @@
-{
- "components.confirmation.dialog.button.cancel": "Cancel·lar",
- "components.confirmation.dialog.button.confirm": "Confirmeu",
- "components.confirmation.dialog.description": "Vols continuar?",
- "components.confirmation.dialog.header": "Confirmació",
- "components.navigationItem.action.newItem": "Afegeix un element fill",
- "components.navigationItem.badge.draft": "Esborrany",
- "components.navigationItem.badge.published": "Publicat",
- "components.navigationItem.badge.removed": "Eliminat",
- "empty": "El teu menú està buit",
- "empty.cta": "Crea el primer element",
- "header.action.collapseAll": "Col·lapsar tot",
- "header.action.expandAll": "Amplia-ho tot",
- "header.action.manage": "Gestionar",
- "header.action.newItem": "Element nou",
- "header.description": "Definiu la vostra navegació del lloc web",
- "header.meta": "ID: { id }, slug: { key }",
- "header.title": "Navegació",
- "notification.error": "S'ha produït un error en processar la sol·licitud.",
- "notification.error.customField.type": "Tipus de camp personalitzat no compatible",
- "notification.error.item.relation": "Les relacions proporcionades en alguns elements són incorrectes",
- "notification.error.item.slug": "No es pot crear la clau (slug) vàlida de l'encaminador de la IU des de \"{ query }\". \"{ result }\" rebut",
- "notification.navigation.error": "Camí duplicat: { path } al pare: { parentTitle } per a { errorTitles } elements",
- "notification.navigation.item.relation": "La relació d'entitat no existeix!",
- "notification.navigation.item.relation.status.draft": "esborrany",
- "notification.navigation.item.relation.status.published": "publicat",
- "notification.navigation.submit": "S'han desat els canvis de navegació",
- "pages.main.header.localization.select.placeholder": "Seleccioneu la configuració regional",
- "pages.main.search.placeholder": "Escriviu per començar a cercar...",
- "pages.main.search.subLabel": "appuyez sur ENTER pour mettre en surbrillance l'élément suivant",
- "pages.settings.actions.disableI18n.confirmation.confirm": "entenc",
- "pages.settings.actions.disableI18n.confirmation.description.line1": "Esteu desactivant la internacionalització per a la navegació. Les navegacions per a configuracions regionals diferents de les predeterminades no estan disponibles per a la visualització i modificacions mitjançant aquesta extensió.",
- "pages.settings.actions.disableI18n.confirmation.description.line2": "Podeu optar per eliminar les navegacions per a altres configuracions regionals.",
- "pages.settings.actions.disableI18n.confirmation.description.line3": "Recordeu! L'eliminació és irreversible",
- "pages.settings.actions.disableI18n.confirmation.header": "Desactivació de la internacionalització",
- "pages.settings.actions.disableI18n.prune.label": "Navegacions obsoletes",
- "pages.settings.actions.disableI18n.prune.off": "Mantenir",
- "pages.settings.actions.disableI18n.prune.on": "Eliminar",
- "pages.settings.actions.restart": "Reinicieu Strapi",
- "pages.settings.actions.restart.alert.cancel": "Cancel·lar",
- "pages.settings.actions.restart.alert.close": "Descartar",
- "pages.settings.actions.restart.alert.description": "Heu fet un canvi de configuració que requereix que la vostra aplicació Strapi es reiniciï perquè tingui efecte. Fes-ho manualment o utilitzant el botó següent.",
- "pages.settings.actions.restart.alert.reason.GRAPH_QL": "S'aplicaran els canvis de GraphQL.",
- "pages.settings.actions.restart.alert.reason.I18N": "S'aplicaran els canvis d'internacionalització (i18n).",
- "pages.settings.actions.restart.alert.reason.I18N_NAVIGATIONS_PRUNE": "Les navegacions locals obsoletes s'eliminaran.",
- "pages.settings.actions.restart.alert.title": "Strapi requereix reinici",
- "pages.settings.actions.restore": "Restaura la configuració",
- "pages.settings.actions.restore.confirmation.confirm": "Restaurar",
- "pages.settings.actions.restore.confirmation.description": "La configuració de l'extensió es restaurarà des del fitxer plugins.js.",
- "pages.settings.actions.restore.confirmation.header": "Vols continuar?",
- "pages.settings.actions.restore.description": "La restauració de la configuració de l'extensió farà que es substitueixi per la configuració desada dins del fitxer \"plugins.js\".",
- "pages.settings.actions.submit": "Desa la configuració",
- "pages.settings.additional.title": "Configuracions addicionals",
- "pages.settings.customFields.title": "Configuració de camps personalitzats",
- "pages.settings.form.allowedLevels.hint": "Nivell màxim per al qual podeu marcar l'element com a \"Menú adjunt\"",
- "pages.settings.form.allowedLevels.label": "Nivells permesos",
- "pages.settings.form.allowedLevels.placeholder": "per exemple. 2",
- "pages.settings.form.audience.hint": "Activa el camp de públic",
- "pages.settings.form.audience.label": "Públic",
- "pages.settings.form.contentTypes.hint": "Si no se selecciona cap, tampoc no s'habilitarà cap dels tipus de contingut",
- "pages.settings.form.contentTypes.label": "Habilita la navegació per",
- "pages.settings.form.contentTypes.placeholder": "per exemple. Pàgines, publicacions",
- "pages.settings.form.contentTypesSettings.initializationWarning.content": "- El tipus de contingut encara no s'ha inicialitzat. Inicialitzeu-lo primer per poder utilitzar-lo en un editor visual.",
- "pages.settings.form.contentTypesSettings.initializationWarning.title": "Avís",
- "pages.settings.form.contentTypesSettings.label": "Tipus de contingut",
- "pages.settings.form.contentTypesSettings.tooltip": "Configuració personalitzada per tipus de contingut",
- "pages.settings.form.customFields.popup.header.edit": "Edita el camp personalitzat",
- "pages.settings.form.customFields.popup.header.new": "Afegeix un camp personalitzat nou",
- "pages.settings.form.customFields.popup.label.description": "Aquesta etiqueta es mostrarà al formulari d'elements de navegació",
- "pages.settings.form.customFields.popup.label.label": "Etiqueta de camp personalitzada",
- "pages.settings.form.customFields.popup.label.placeholder": "Exemple d'etiqueta",
- "pages.settings.form.customFields.popup.multi.label": "Activa l'entrada d'opcions múltiples",
- "pages.settings.form.customFields.popup.name.description": "El nom del camp personalitzat ha de ser únic",
- "pages.settings.form.customFields.popup.name.label": "Nom del camp personalitzat",
- "pages.settings.form.customFields.popup.name.placeholder": "nom_exemple",
- "pages.settings.form.customFields.popup.options.description": "Activar aquest camp no canviarà els elements de navegació que ja estan sortint",
- "pages.settings.form.customFields.popup.options.label": "Opcions per seleccionar l'entrada",
- "pages.settings.form.customFields.popup.required.description": "Activar aquest camp no canviarà els elements de navegació que ja estan sortint",
- "pages.settings.form.customFields.popup.required.label": "Camp obligatori",
- "pages.settings.form.customFields.popup.type.label": "Tipus de camp personalitzat",
- "pages.settings.form.customFields.table.confirmation.confirm": "Continua",
- "pages.settings.form.customFields.table.confirmation.error": "S'ha produït un error en eliminar el camp personalitzat",
- "pages.settings.form.customFields.table.confirmation.header": "S'està eliminant el camp personalitzat",
- "pages.settings.form.customFields.table.confirmation.message": "Aquesta acció farà que s'eliminin tots els valors dels camps personalitzats dels elements de navegació.",
- "pages.settings.form.customFields.table.disable": "Desactiva el camp personalitzat",
- "pages.settings.form.customFields.table.edit": "Edita el camp personalitzat",
- "pages.settings.form.customFields.table.enable": "Activa el camp personalitzat",
- "pages.settings.form.customFields.table.footer": "Crea un camp personalitzat nou",
- "pages.settings.form.customFields.table.header.label": "Etiqueta",
- "pages.settings.form.customFields.table.header.name": "Nom",
- "pages.settings.form.customFields.table.header.required": "Obligatori",
- "pages.settings.form.customFields.table.header.type": "Tipus",
- "pages.settings.form.customFields.table.notRequired": "no obligatori",
- "pages.settings.form.customFields.table.remove": "Elimina el camp personalitzat",
- "pages.settings.form.customFields.table.required": "obligatori",
- "pages.settings.form.i18n.hint": "Habilitar la internacionalització",
- "pages.settings.form.i18n.hint.missingDefaultLocale": "Falta la configuració regional predeterminada!",
- "pages.settings.form.i18n.label": "i18n",
- "pages.settings.form.nameField.empty": "Aquest tipus de contingut no té cap atribut de text",
- "pages.settings.form.nameField.hint": "Si es deixa el camp de nom buit, prendrà els següents camps ordenats: \"títol\", \"assumpte\" i \"nom\"",
- "pages.settings.form.nameField.label": "Camps de nom",
- "pages.settings.form.nameField.placeholder": "Seleccioneu almenys un o deixeu-lo buit per aplicar els valors predeterminats",
- "pages.settings.form.pathDefaultFields.empty": "Aquest tipus de contingut no té cap atribut adequat",
- "pages.settings.form.pathDefaultFields.hint": "El valor de l'atribut seleccionat serà un valor predeterminat per al camí intern",
- "pages.settings.form.pathDefaultFields.label": "Camps predeterminats del camí",
- "pages.settings.form.pathDefaultFields.placeholder": "Seleccioneu-ne com a mínim un o deixeu-lo buit per desactivar l'emplenat del camp del camí amb el valor dels atributs",
- "pages.settings.form.populate.empty": "Aquest tipus de contingut no té cap camp de relació",
- "pages.settings.form.populate.hint": "Els camps de relació seleccionats s'emplenaran dins de les respostes de l'API",
- "pages.settings.form.populate.label": "Camps a omplir",
- "pages.settings.form.populate.placeholder": "Seleccioneu-ne com a mínim un o deixeu-lo buit per desactivar l'ompliment dels camps de relació",
- "pages.settings.general.title": "Configuració general",
- "pages.settings.header.description": "Configura el connector de navegació",
- "pages.settings.header.title": "Navegació",
- "pages.settings.nameField.title": "Configuració dels tipus de contingut",
- "pages.settings.notification.fetch.error": "No s'ha pogut obtenir la configuració. S'està tornant a provar...",
- "pages.settings.notification.restart.error": "No s'ha pogut reiniciar l'aplicació. Intenta fer-ho manualment.",
- "pages.settings.notification.restart.success": "L'aplicació s'ha reiniciat correctament",
- "pages.settings.notification.restore.error": "La restauració de la configuració ha fallat",
- "pages.settings.notification.restore.success": "La configuració s'ha restaurat correctament",
- "pages.settings.notification.submit.error": "L'actualització de la configuració ha fallat",
- "pages.settings.notification.submit.success": "La configuració s'ha actualitzat correctament",
- "pages.settings.restoring.title": "Restauració",
- "pages.settings.section.subtitle": "Configuració",
- "pages.settings.section.title": "Extensió de navegació",
- "pages.view.actions.i18nCopyItems.confirmation.confirm": "Còpia",
- "pages.view.actions.i18nCopyItems.confirmation.content": "Voleu copiar els elements de navegació?",
- "pages.view.actions.i18nCopyItems.confirmation.header": "Confirmació",
- "plugin.name": "Navegació",
- "popup.item.form.audience.empty": "No hi ha més públic",
- "popup.item.form.audience.label": "Públic",
- "popup.item.form.audience.placeholder": "Selecciona el públic...",
- "popup.item.form.button.cancel": "Cancel·lar",
- "popup.item.form.button.create": "Crea un element",
- "popup.item.form.button.remove": "Eliminar",
- "popup.item.form.button.restore": "Restaura l'element",
- "popup.item.form.button.save": "Desa",
- "popup.item.form.button.update": "Actualitza l'element",
- "popup.item.form.externalPath.label": "URL externa",
- "popup.item.form.externalPath.placeholder": "Enllaç a la pàgina externa",
- "popup.item.form.externalPath.validation.type": "Aquest valor no és una URL adequada.",
- "popup.item.form.i18n.locale.button": "Còpia",
- "popup.item.form.i18n.locale.error.generic": "No es pot copiar l'element",
- "popup.item.form.i18n.locale.error.unavailable": "La versió local no està disponible",
- "popup.item.form.i18n.locale.label": "Copia els detalls de",
- "popup.item.form.i18n.locale.placeholder": "local",
- "popup.item.form.menuAttached.label": "Adjuntar al menú",
- "popup.item.form.path.label": "URL",
- "popup.item.form.path.placeholder": "La part d'URL única identifica aquest element",
- "popup.item.form.path.preview": "Vista prèvia:",
- "popup.item.form.related.empty": "No hi ha més entitats de \"{ contentTypeName }\" per seleccionar",
- "popup.item.form.related.label": "Entitat",
- "popup.item.form.relatedSection.label": "Relació amb",
- "popup.item.form.relatedType.empty": "No hi ha tipus de contingut per seleccionar",
- "popup.item.form.relatedType.label": "Tipus de contingut",
- "popup.item.form.relatedType.placeholder": "Selecciona el tipus de contingut...",
- "popup.item.form.title.label": "Títol",
- "popup.item.form.title.placeholder": "Introduïu el títol de l'element o deixeu-lo en blanc per extreure'l de l'entitat relacionada",
- "popup.item.form.type.external.description": "Camí de sortida: {valor}",
- "popup.item.form.type.external.label": "Pàgina externa",
- "popup.item.form.type.internal.label": "Contingut intern",
- "popup.item.form.type.label": "Tipus d'element de navegació",
- "popup.item.form.type.wrapper.label": "Contenidor",
- "popup.item.form.uiRouterKey.label": "Clau de l'encaminador de la interfície d'usuari",
- "popup.item.form.uiRouterKey.placeholder": "Si està buit, generat automàticament per \"Títol\"",
- "popup.item.header.edit": "Edita l'element de navegació",
- "popup.item.header.new": "Nou element de navegació",
- "popup.navigation.form.name.label": "Nom",
- "popup.navigation.form.name.placeholder": "Nom del menú",
- "popup.navigation.form.validation.name.alreadyUsed": "El nom ja s'utilitza",
- "popup.navigation.form.validation.name.required": "El nom és obligatori",
- "popup.navigation.form.validation.name.tooShort": "El nom és massa curt",
- "popup.navigation.form.validation.visible.required": "Es requereix visibilitat",
- "popup.navigation.form.visible.label": "Visibilitat",
- "popup.navigation.manage.button.cancel": "Cancel·lar",
- "popup.navigation.manage.button.create": "Crear",
- "popup.navigation.manage.button.delete": "Suprimeix",
- "popup.navigation.manage.button.edit": "Edita",
- "popup.navigation.manage.button.goBack": "Torna",
- "popup.navigation.manage.button.save": "Desa",
- "popup.navigation.manage.delete.header": "S'eliminaran els menús següents:",
- "popup.navigation.manage.error.message": "Ha passat un error :(",
- "popup.navigation.manage.header.CREATE": "Nou menú",
- "popup.navigation.manage.header.DELETE": "S'està suprimint",
- "popup.navigation.manage.header.EDIT": "S'està editant \"{name}\"",
- "popup.navigation.manage.header.ERROR": "Error",
- "popup.navigation.manage.header.LIST": "Tots els menús",
- "popup.navigation.manage.navigation.hidden": "amagat",
- "popup.navigation.manage.navigation.visible": "visible",
- "popup.navigation.manage.table.hasSelected": "{count} entrades seleccionades",
- "popup.navigation.manage.table.id": "Id",
- "popup.navigation.manage.table.locale": "Versions locals",
- "popup.navigation.manage.table.name": "Nom",
- "popup.navigation.manage.table.visibility": "Visibilitat",
- "submit.cta.cancel": "Cancel·lar",
- "submit.cta.save": "Desa",
- "view.i18n.fill.cta": "o copia de",
- "view.i18n.fill.cta.button": "còpia",
- "view.i18n.fill.option": "{locale} local"
-}
\ No newline at end of file
diff --git a/admin/src/translations/ca.ts b/admin/src/translations/ca.ts
new file mode 100644
index 00000000..ee94686e
--- /dev/null
+++ b/admin/src/translations/ca.ts
@@ -0,0 +1,483 @@
+const ca = {
+ plugin: {
+ name: "Navigation UI",
+ section: {
+ name: "Plugin de navigation",
+ item: "Configuration",
+ },
+ },
+ header: {
+ title: "Navigation",
+ description: "Définissez la navigation de votre portail",
+ meta: "ID: { id }, slug: { key }",
+ action: {
+ newItem: "Nouvel élément",
+ manage: "Gérer",
+ collapseAll: "Tout réduire",
+ expandAll: "Tout développer",
+ },
+ },
+ submit: {
+ cta: {
+ cancel: "Annuler",
+ save: "Enregistrer",
+ },
+ },
+ empty: {
+ description: "Votre navigation est vide",
+ cta: "Créer le premier élément",
+ },
+ popup: {
+ navigation: {
+ manage: {
+ header: {
+ LIST: "Toutes les navigations",
+ CREATE: "Nouvelle navigation",
+ DELETE: "Suppression",
+ ERROR: "Erreur",
+ EDIT: "Édition de \"{name}\"",
+ },
+ button: {
+ cancel: "Annuler",
+ delete: "Supprimer",
+ save: "Enregistrer",
+ edit: "Modifier",
+ create: "Créer",
+ goBack: "Retourner",
+ purge: "Effacer le cache de lecture",
+ },
+ table: {
+ id: "Id",
+ name: "Nom",
+ locale: "Versions locales",
+ visibility: "Visibilité",
+ hasSelected: "{count} entrées sélectionnées",
+ },
+ footer: {
+ button: {
+ purge: "Effacer",
+ },
+ },
+ purge: {
+ header: "Cette action effacera le cache de lecture de l'API. Cela entraînera un ralentissement temporaire des lectures pour les navigations ci-dessous.",
+ },
+ delete: {
+ header: "Les navigations suivantes seront supprimées :",
+ },
+ error: {
+ header: "Une erreur est survenue :(",
+ message: "Une erreur est survenue lors du traitement de la demande.",
+ },
+ navigation: {
+ visible: "visible",
+ hidden: "caché",
+ },
+ },
+ form: {
+ name: {
+ label: "Nom",
+ placeholder: "Nom de la navigation",
+ validation: {
+ name: {
+ required: "Le nom est requis",
+ tooShort: "Le nom est trop court",
+ alreadyUsed: "Le nom est déjà utilisé",
+ },
+ visible: {
+ required: "La visibilité est requise",
+ },
+ },
+ },
+ visible: {
+ label: "Visibilité",
+ },
+ },
+ },
+ item: {
+ header: {
+ view: "Voir l'élément de navigation",
+ edit: "Modifier l'élément de navigation",
+ new: "Nouvel élément de navigation",
+ },
+ form: {
+ title: {
+ label: "Titre",
+ autoSync: {
+ label: "Lire les champs de la relation",
+ },
+ placeholder: "Entrez le titre de l'élément ou laissez vide pour tirer de l'entité liée",
+ },
+ uiRouterKey: {
+ label: "Clé du routeur UI",
+ placeholder: "Si vide, généré automatiquement par \"Titre\"",
+ },
+ path: {
+ label: "URL",
+ placeholder: "Partie unique de l'URL identifiant cet élément",
+ preview: "Aperçu :",
+ },
+ externalPath: {
+ label: "URL externe",
+ placeholder: "Lien vers la source externe",
+ validation: {
+ type: "Cette valeur n'est pas une URL valide.",
+ },
+ },
+ menuAttached: {
+ label: "Attacher au menu",
+ },
+ type: {
+ label: "Type d'élément de navigation",
+ internal: {
+ label: "Source interne",
+ },
+ external: {
+ label: "Source externe",
+ description: "Chemin de sortie : {value}",
+ },
+ wrapper: {
+ label: "Élément wrapper",
+ },
+ },
+ audience: {
+ label: "Audience",
+ placeholder: "Sélectionner l'audience...",
+ empty: "Il n'y a plus d'audiences",
+ },
+ relatedSection: {
+ label: "Relation à",
+ },
+ relatedType: {
+ label: "Type de contenu",
+ placeholder: "Sélectionner le type de contenu...",
+ empty: "Il n'y a pas de types de contenu à sélectionner",
+ },
+ related: {
+ label: "Entité",
+ placeholder: "Sélectionner l'entité...",
+ empty: "Il n'y a plus d'entités de \"{ contentTypeName }\" à sélectionner",
+ },
+ i18n: {
+ locale: {
+ label: "Copier les détails de",
+ placeholder: "locale",
+ button: "Copier",
+ error: {
+ generic: "Impossible de copier l'élément",
+ unavailable: "Version locale indisponible",
+ },
+ },
+ },
+ button: {
+ create: "Créer l'élément",
+ update: "Mettre à jour l'élément",
+ restore: "Restaurer l'élément",
+ remove: "Supprimer",
+ save: "Enregistrer",
+ cancel: "Annuler",
+ },
+ },
+ },
+ },
+ notification: {
+ navigation: {
+ submit: "Les modifications de navigation ont été enregistrées",
+ error: "Chemin en double : \"{ path }\" dans le parent : \"{ parentTitle }\" pour { errorTitles } éléments",
+ item: {
+ relation: "La relation de l'entité n'existe pas !",
+ status: {
+ draft: "brouillon",
+ published: "publié",
+ },
+ },
+ },
+ error: {
+ common: "Erreur lors du traitement de la demande.",
+ customField: {
+ type: "Type de champ personnalisé non pris en charge",
+ media: {
+ missing: "Le composant d'entrée média est manquant",
+ },
+ },
+ item: {
+ relation: "Les relations fournies dans certains éléments sont incorrectes",
+ slug: "Impossible de créer une clé de routeur UI valide (slug) à partir de \"{ query }\". \"{ result }\" reçu",
+ },
+ }
+ },
+ pages: {
+ auth: {
+ noAccess: "Pas d'accès",
+ not: {
+ allowed: "Oups ! Il semble que vous n'ayez pas accès à cette page...",
+ },
+ },
+ main: {
+ search: {
+ placeholder: "Tapez pour commencer à rechercher...",
+ subLabel: "appuyez sur ENTRÉE pour mettre en surbrillance l'élément suivant",
+ },
+ header: {
+ localization: {
+ select: {
+ placeholder: "Sélectionner la locale",
+ },
+ },
+ },
+ },
+ settings: {
+ title: "Paramètres de navigation",
+ general: {
+ title: "Paramètres généraux",
+ },
+ additional: {
+ title: "Paramètres supplémentaires",
+ },
+ customFields: {
+ title: "Paramètres des champs personnalisés",
+ },
+ nameField: {
+ title: "Paramètres des types de contenu",
+ },
+ restoring: {
+ title: "Restauration",
+ },
+ section: {
+ title: "Plugin de navigation",
+ subtitle: "Configuration",
+ },
+ header: {
+ title: "Navigation",
+ description: "Configurer le plugin de navigation",
+ },
+ form: {
+ cascadeMenuAttached: {
+ label: "Cascade du menu attaché",
+ hint: "Désactiver si vous ne voulez pas que \"Menu attaché\" se propage aux éléments enfants",
+ },
+ preferCustomContentTypes: {
+ label: "Préférer les types de contenu personnalisés",
+ hint: "Préférer utiliser uniquement les types de contenu préfixés par api::",
+ },
+ contentTypes: {
+ label: "Activer la navigation pour",
+ placeholder: "ex. Pages, Articles",
+ hint: "Si aucun n'est sélectionné, aucun des types de contenu n'est activé",
+ },
+ i18n: {
+ label: "i18n",
+ hint: "Activer l'internationalisation",
+ hintMissingDefaultLocale: "Locale par défaut manquante !",
+ },
+ allowedLevels: {
+ label: "Niveaux autorisés",
+ placeholder: "ex. 2",
+ hint: "Niveau maximum pour lequel vous pouvez marquer un élément comme \"Menu attaché\"",
+ },
+ audience: {
+ label: "Audience",
+ hint: "Activer le champ audience",
+ },
+ nameField: {
+ default: "Par défaut",
+ label: "Champs de nom",
+ placeholder: "Sélectionner au moins un ou laisser vide pour appliquer les valeurs par défaut",
+ hint: "Si laissé vide, le champ de nom prendra les champs suivants par ordre : \"titre\", \"sujet\" et \"nom\"",
+ empty: "Ce type de contenu n'a pas d'attributs de chaîne",
+ },
+ populate: {
+ label: "Champs à remplir",
+ placeholder: "Sélectionner au moins un ou laisser vide pour désactiver le remplissage des champs de relation",
+ hint: "Les champs de relation sélectionnés seront remplis dans les réponses de l'API",
+ empty: "Ce type de contenu n'a pas de champs de relation",
+ },
+ pathDefaultFields: {
+ label: "Champs par défaut du chemin",
+ placeholder: "Sélectionner au moins un ou laisser vide pour désactiver le remplissage du champ de chemin avec la valeur des attributs",
+ hint: "La valeur de l'attribut sélectionné sera la valeur par défaut pour le chemin interne",
+ empty: "Ce type de contenu n'a pas d'attributs appropriés",
+ },
+ contentTypesSettings: {
+ label: "Types de contenu",
+ tooltip: "Configuration personnalisée par type de contenu",
+ initializationWarning: {
+ title: "Avertissement",
+ content: "- Le type de contenu n'a pas encore été initialisé. Initialisez-le d'abord pour pouvoir l'utiliser dans un éditeur visuel.",
+ },
+ },
+ customFields: {
+ table: {
+ confirmation: {
+ header: "Suppression du champ personnalisé",
+ message: "Cette action entraînera la suppression de toutes les valeurs des champs personnalisés des éléments de navigation.",
+ confirm: "Continuer",
+ error: "Une erreur est survenue lors de la suppression du champ personnalisé",
+ },
+ header: {
+ name: "Nom",
+ label: "Étiquette",
+ type: "Type",
+ required: "Requis",
+ },
+ footer: "Créer un nouveau champ personnalisé",
+ edit: "Modifier le champ personnalisé",
+ enable: "Activer le champ personnalisé",
+ disable: "Désactiver le champ personnalisé",
+ remove: "Supprimer le champ personnalisé",
+ required: "requis",
+ notRequired: "non requis",
+ },
+ popup: {
+ header: {
+ edit: "Modifier le champ personnalisé",
+ new: "Ajouter un nouveau champ personnalisé",
+ },
+ name: {
+ label: "Nom du champ personnalisé",
+ placeholder: "exemple_nom",
+ description: "Le nom du champ personnalisé doit être unique",
+ },
+ label: {
+ label: "Étiquette du champ personnalisé",
+ placeholder: "Exemple d'étiquette",
+ description: "Cette étiquette sera affichée sur le formulaire de l'élément de navigation",
+ },
+ type: {
+ label: "Type de champ personnalisé",
+ },
+ required: {
+ label: "Champ requis",
+ description: "Activer ce champ ne changera pas les éléments de navigation déjà existants",
+ },
+ options: {
+ label: "Options pour l'entrée de sélection",
+ description: "Activer ce champ ne changera pas les éléments de navigation déjà existants",
+ },
+ multi: {
+ label: "Activer l'entrée de plusieurs options",
+ },
+ },
+ },
+ },
+ actions: {
+ submit: "Enregistrer la configuration",
+ restore: {
+ label: "Restaurer la configuration",
+ confirmation: {
+ header: "Voulez-vous continuer ?",
+ confirm: "Restaurer",
+ description: "La configuration du plugin sera restaurée à partir du fichier plugins.js.",
+ },
+ description: "La restauration de la configuration du plugin entraînera son remplacement par la configuration enregistrée dans le fichier 'plugins.js'.",
+ },
+ restart: {
+ label: "Redémarrer Strapi",
+ alert: {
+ title: "Strapi nécessite un redémarrage",
+ description: "Vous avez apporté des modifications à la configuration qui nécessitent le redémarrage de votre application Strapi pour prendre effet. Faites-le manuellement ou en utilisant le déclencheur ci-dessous.",
+ close: "Ignorer",
+ cancel: "Annuler",
+ reason: {
+ I18N: "Les modifications de l'internationalisation (i18n) seront appliquées.",
+ GRAPH_QL: "Les modifications de GraphQL seront appliquées.",
+ I18N_NAVIGATIONS_PRUNE: "Les navigations de locale obsolètes seront supprimées.",
+ },
+ },
+ },
+ disableI18n: {
+ confirmation: {
+ header: "Désactivation de l'internationalisation",
+ confirm: "Je comprends",
+ description: {
+ line1: "Vous désactivez l'internationalisation pour la navigation. Les navigations pour les locales différentes de la locale par défaut ne sont pas disponibles pour la visualisation et les modifications via ce plugin.",
+ line2: "Vous pouvez choisir de supprimer les navigations pour d'autres locales.",
+ line3: "Rappelez-vous ! La suppression est irréversible",
+ },
+ },
+ prune: {
+ label: "Navigations obsolètes",
+ on: "Supprimer",
+ off: "Garder",
+ },
+ },
+ },
+ notification: {
+ fetch: {
+ error: "Échec de la récupération de la configuration. Nouvelle tentative...",
+ },
+ submit: {
+ success: "La configuration a été mise à jour avec succès",
+ error: "La mise à jour de la configuration a échoué",
+ },
+ restore: {
+ success: "La configuration a été restaurée avec succès",
+ error: "La restauration de la configuration a échoué",
+ },
+ restart: {
+ success: "L'application a été redémarrée avec succès",
+ error: "Échec du redémarrage de votre application. Essayez de le faire manuellement.",
+ },
+ },
+ },
+ view: {
+ actions: {
+ i18nCopyItems: {
+ confirmation: {
+ header: "Confirmation",
+ confirm: "Copier",
+ content: "Voulez-vous copier les éléments de navigation ?",
+ },
+ },
+ },
+ },
+ },
+ components: {
+ toggle: {
+ enabled: "Activé",
+ disabled: "Désactivé",
+ },
+ navigationItem: {
+ action: {
+ newItem: "Ajouter un élément imbriqué",
+ edit: "Modifier",
+ view: "Voir",
+ restore: "Restaurer",
+ remove: "Supprimer",
+ },
+ badge: {
+ removed: "Supprimé",
+ draft: "Brouillon",
+ published: "Publié",
+ },
+ related: {
+ localeMissing: "(Falta la versió local)"
+ },
+ },
+ confirmation: {
+ dialog: {
+ button: {
+ cancel: "Annuler",
+ confirm: "Confirmer",
+ },
+ description: "Voulez-vous continuer ?",
+ header: "Confirmation",
+ },
+ },
+ notAccessPage: {
+ back: "Retour à la page d'accueil",
+ },
+ },
+ view: {
+ i18n: {
+ fill: {
+ option: "locale {locale}",
+ cta: {
+ header: "ou démarrer",
+ button: "copier",
+ },
+ },
+ },
+ },
+};
+
+export default ca;
diff --git a/admin/src/translations/en.json b/admin/src/translations/en.json
deleted file mode 100644
index 95ab4ecc..00000000
--- a/admin/src/translations/en.json
+++ /dev/null
@@ -1,220 +0,0 @@
-{
- "plugin.name": "UI Navigation",
- "header.title": "Navigation",
- "header.description": "Define your portal navigation",
- "header.meta": "ID: { id }, slug: { key }",
- "header.action.newItem": "New Item",
- "header.action.manage": "Manage",
- "header.action.collapseAll": "Collapse All",
- "header.action.expandAll": "Expand All",
- "submit.cta.cancel": "Cancel",
- "submit.cta.save": "Save",
- "submit.cta.cache.purge": "Clear cache",
- "empty": "Your navigation is empty",
- "empty.cta": "Create first item",
- "popup.item.header.view": "View navigation item",
- "popup.item.header.edit": "Edit navigation item",
- "popup.item.header.new": "New navigation item",
- "popup.item.form.title.label": "Title",
- "popup.item.form.autoSync.label": "Read fields from related",
- "popup.item.form.title.placeholder": "Enter the item title or leave blank to pull from related entity",
- "popup.item.form.uiRouterKey.label": "UI router key",
- "popup.item.form.uiRouterKey.placeholder": "If empty, auto generated by \"Title\"",
- "popup.item.form.path.label": "URL",
- "popup.item.form.path.placeholder": "Unique url part identifies this item",
- "popup.item.form.path.preview": "Preview:",
- "popup.item.form.externalPath.label": "External URL",
- "popup.item.form.externalPath.placeholder": "Link to the external source",
- "popup.item.form.externalPath.validation.type": "This value is not a proper url.",
- "popup.item.form.menuAttached.label": "Attach to menu",
- "popup.item.form.type.label": "Navigation item type",
- "popup.item.form.type.internal.label": "Internal source",
- "popup.item.form.type.external.label": "External source",
- "popup.item.form.type.wrapper.label": "Wrapper element",
- "popup.item.form.type.external.description": "Output path: {value}",
- "popup.item.form.audience.label": "Audience",
- "popup.item.form.audience.placeholder": "Select audience...",
- "popup.item.form.audience.empty": "There are no more audiences",
- "popup.item.form.relatedSection.label": "Relation to",
- "popup.item.form.relatedType.label": "Content Type",
- "popup.item.form.relatedType.placeholder": "Select content type...",
- "popup.item.form.relatedType.empty": "There are no content types to select",
- "popup.item.form.i18n.locale.label": "Copy details from",
- "popup.item.form.i18n.locale.placeholder": "locale",
- "popup.item.form.i18n.locale.button": "Copy",
- "popup.item.form.i18n.locale.error.generic": "Unable to copy item",
- "popup.item.form.i18n.locale.error.unavailable": "Locale version unavailable",
- "popup.item.form.related.label": "Entity",
- "popup.item.form.related.empty": "There are no more entities of \"{ contentTypeName }\" to select",
- "popup.item.form.button.create": "Create item",
- "popup.item.form.button.update": "Update item",
- "popup.item.form.button.restore": "Restore item",
- "popup.item.form.button.remove": "Remove",
- "popup.item.form.button.save": "Save",
- "popup.item.form.button.cancel": "Cancel",
- "popup.navigation.form.name.label": "Name",
- "popup.navigation.form.name.placeholder": "Navigations's name",
- "popup.navigation.form.visible.label": "Visibility",
- "popup.navigation.form.validation.name.alreadyUsed": "Name is already used",
- "popup.navigation.form.validation.name.required": "Name is required",
- "popup.navigation.form.validation.name.tooShort": "Name is too short",
- "popup.navigation.form.validation.visible.required": "Visibility is required",
- "popup.navigation.manage.table.hasSelected": "{count} entries selected",
- "popup.navigation.manage.table.id": "Id",
- "popup.navigation.manage.table.name": "Name",
- "popup.navigation.manage.table.locale": "Locale versions",
- "popup.navigation.manage.table.visibility": "Visibility",
- "popup.navigation.manage.button.goBack": "Go back",
- "popup.navigation.manage.button.cancel": "Cancel",
- "popup.navigation.manage.button.delete": "Delete",
- "popup.navigation.manage.button.purge": "Clear read cache",
- "popup.navigation.manage.footer.button.purge": "Clear",
- "popup.navigation.manage.purge.header": "This action will clear API read cache. This will result in brief slowdown of reads for navigations below.",
- "popup.navigation.manage.button.save": "Save",
- "popup.navigation.manage.button.edit": "Edit",
- "popup.navigation.manage.button.create": "Create",
- "popup.navigation.manage.delete.header": "Following navigations will be removed:",
- "popup.navigation.manage.header.CACHE_PURGE": "Read cache clear",
- "popup.navigation.manage.header.LIST": "All navigations",
- "popup.navigation.manage.header.CREATE": "New navigation",
- "popup.navigation.manage.header.DELETE": "Deleting",
- "popup.navigation.manage.header.ERROR": "Error",
- "popup.navigation.manage.header.EDIT": "Editing \"{name}\"",
- "popup.navigation.manage.error.message": "An error happened :(",
- "popup.navigation.manage.navigation.visible": "visible",
- "popup.navigation.manage.navigation.hidden": "hidden",
- "notification.navigation.submit": "Navigation changes has been saved",
- "notification.navigation.error": "Duplicate path: \"{ path }\" in parent: \"{ parentTitle }\" for { errorTitles } items",
- "notification.navigation.item.relation": "Entity relation does not exist!",
- "notification.navigation.item.relation.status.draft": "draft",
- "notification.navigation.item.relation.status.published": "published",
- "notification.error": "Error while processing request.",
- "notification.error.customField.type": "Unsupported type of custom field",
- "notification.error.customField.media.missing": "Media input component is missing",
- "notification.error.item.relation": "Relations provided in some items are incorrect",
- "notification.error.item.slug": "Unable to create valid UI Router Key(slug) from \"{ query }\". \"{ result }\" received",
- "page.auth.noAccess": "No access",
- "page.auth.not.allowed": "Oops! It seems like You do not have access to this page...",
- "pages.main.search.placeholder": "Type to start searching...",
- "pages.main.search.subLabel": "press ENTER to highlight next item",
- "pages.main.header.localization.select.placeholder": "Select locale",
- "pages.settings.general.title": "General settings",
- "pages.settings.additional.title": "Additional settings",
- "pages.settings.customFields.title": "Custom fields settings",
- "pages.settings.nameField.title": "Content types settings",
- "pages.settings.restoring.title": "Restoring",
- "pages.settings.section.title": "Navigation Plugin",
- "pages.settings.section.subtitle": "Configuration",
- "pages.settings.header.title": "Navigation",
- "pages.settings.header.description": "Configure the navigation plugin",
- "pages.settings.actions.restart": "Restart Strapi",
- "pages.settings.actions.submit": "Save configuration",
- "pages.settings.actions.restore": "Restore configuration",
- "pages.settings.actions.restore.confirmation.header": "Do you want to continue?",
- "pages.settings.actions.restore.confirmation.confirm": "Restore",
- "pages.settings.actions.restore.confirmation.description": "Plugin config will be restored from plugins.js file.",
- "pages.settings.actions.restore.description": "Restoring the plugin configuration will cause it to be replaced with configuration saved inside 'plugins.js' file.",
- "pages.settings.actions.restart.alert.title": "Strapi requires restart",
- "pages.settings.actions.restart.alert.description": "You've made a configuration changes which requires your Strapi application to be restarted to take an effect. Do it manually or by using below trigger.",
- "pages.settings.actions.restart.alert.reason.I18N": "Internationalization(i18n) changes will be applied.",
- "pages.settings.actions.restart.alert.reason.GRAPH_QL": "GraphQL changes will be applied.",
- "pages.settings.actions.restart.alert.reason.I18N_NAVIGATIONS_PRUNE": "Obsolete locale navigations will be removed.",
- "pages.settings.actions.disableI18n.confirmation.header": "Disabling Internationalization",
- "pages.settings.actions.disableI18n.confirmation.confirm": "I understand",
- "pages.settings.actions.disableI18n.confirmation.description.line1": "You are disabling Internationalization for Navigation. Navigations for locales different than default are not available for viewing and modifications via this plugin.",
- "pages.settings.actions.disableI18n.confirmation.description.line2": "You can choose to remove navigations for other locales.",
- "pages.settings.actions.disableI18n.confirmation.description.line3": "Remember! Removal is irreversible",
- "pages.settings.actions.disableI18n.prune.label": "Obsolete navigations",
- "pages.settings.actions.disableI18n.prune.on": "Remove",
- "pages.settings.actions.disableI18n.prune.off": "Keep",
- "pages.settings.actions.restart.alert.close": "Discard",
- "pages.settings.actions.restart.alert.cancel": "Cancel",
- "pages.settings.notification.fetch.error": "Failed to fetch configuration. Retrying...",
- "pages.settings.notification.submit.success": "Config has been updated successfully",
- "pages.settings.notification.restore.success": "Config has been restored successfully",
- "pages.settings.notification.restart.success": "Application has been restarted successfully",
- "pages.settings.notification.submit.error": "Config update has failed",
- "pages.settings.notification.restore.error": "Config restore has failed",
- "pages.settings.notification.restart.error": "Failed to restart your application. Try to do it manually.",
- "pages.settings.form.preferCustomContentTypes.label": "Prefer custom content types",
- "pages.settings.form.preferCustomContentTypes.hint": "Prefer if to use only api:: prefixed content types",
- "pages.settings.form.cascadeMenuAttached.label": "Cascade menu attached",
- "pages.settings.form.cascadeMenuAttached.hint": "Disable if you don't want \"Menu attached\" to cascade on child items",
- "pages.settings.form.contentTypes.label": "Enable navigation for",
- "pages.settings.form.cache.label": "REST cache",
- "pages.settings.form.cache.hint": "Enable caching of client read requests",
- "pages.settings.form.i18n.label": "i18n",
- "pages.settings.form.i18n.hint": "Enable internationalisation",
- "pages.settings.form.i18n.hint.missingDefaultLocale": "Default locale missing!",
- "pages.settings.form.contentTypes.placeholder": "eg. Pages, Posts",
- "pages.settings.form.contentTypes.hint": "If none is selected, also none of the content types are enabled",
- "pages.settings.form.allowedLevels.label": "Allowed levels",
- "pages.settings.form.allowedLevels.placeholder": "eg. 2",
- "pages.settings.form.allowedLevels.hint": "Maximum level for which you're able to mark item as \"Menu attached\"",
- "pages.settings.form.audience.label": "Audience",
- "pages.settings.form.audience.hint": "Enable audience field",
- "pages.settings.form.nameField.label": "Name fields",
- "pages.settings.form.nameField.placeholder": "Select at least one or leave empty to apply defaults",
- "pages.settings.form.nameField.hint": "If left empty name field is going to take following ordered fields: \"title\", \"subject\" and \"name\"",
- "pages.settings.form.nameField.empty": "This content type doesn't have any string attributes",
- "pages.settings.form.populate.label": "Fields to populate",
- "pages.settings.form.populate.placeholder": "Select at least one or leave empty to disable populating relation fields",
- "pages.settings.form.populate.hint": "Selected relation fields will be populated inside API responses",
- "pages.settings.form.populate.empty": "This content type doesn't have any relation fields",
- "pages.settings.form.pathDefaultFields.label": "Path default fields",
- "pages.settings.form.pathDefaultFields.placeholder": "Select at least one or leave it empty to disable populating path field with attributes value",
- "pages.settings.form.pathDefaultFields.hint": "Value of selected attribute will be a default value for internal path",
- "pages.settings.form.pathDefaultFields.empty": "This content type doesn't have any suitable attributes",
- "pages.settings.form.contentTypesSettings.label": "Content types",
- "pages.settings.form.contentTypesSettings.tooltip": "Custom configuration per content type",
- "pages.settings.form.contentTypesSettings.initializationWarning.title": "Warning",
- "pages.settings.form.contentTypesSettings.initializationWarning.content": "- Content Type hasn't yet been initialized. Initialize it first to be able to use in a Visual Editor.",
- "pages.view.actions.i18nCopyItems.confirmation.header": "Confirmation",
- "pages.view.actions.i18nCopyItems.confirmation.confirm": "Copy",
- "pages.view.actions.i18nCopyItems.confirmation.content": "Do you want to copy navigations items?",
- "pages.settings.form.customFields.table.confirmation.header": "Removing custom field",
- "pages.settings.form.customFields.table.confirmation.message": "This action will cause removing all custom fields values from navigation items.",
- "pages.settings.form.customFields.table.confirmation.confirm": "Continue",
- "pages.settings.form.customFields.table.confirmation.error": "An error ocurred while removing custom field",
- "pages.settings.form.customFields.table.header.name": "Name",
- "pages.settings.form.customFields.table.header.label": "Label",
- "pages.settings.form.customFields.table.header.type": "Type",
- "pages.settings.form.customFields.table.header.required": "Required",
- "pages.settings.form.customFields.table.footer": "Create new custom field",
- "pages.settings.form.customFields.table.edit": "Edit custom field",
- "pages.settings.form.customFields.table.enable": "Enable custom field",
- "pages.settings.form.customFields.table.disable": "Disable custom field",
- "pages.settings.form.customFields.table.remove": "Remove custom field",
- "pages.settings.form.customFields.table.required": "required",
- "pages.settings.form.customFields.table.notRequired": "not required",
- "pages.settings.form.customFields.popup.header.edit": "Edit custom field",
- "pages.settings.form.customFields.popup.header.new": "Add new custom field",
- "pages.settings.form.customFields.popup.name.label": "Custom field name",
- "pages.settings.form.customFields.popup.name.placeholder": "example_name",
- "pages.settings.form.customFields.popup.name.description": "Name of the custom field must be unique",
- "pages.settings.form.customFields.popup.label.label": "Custom field label",
- "pages.settings.form.customFields.popup.label.placeholder": "Example label",
- "pages.settings.form.customFields.popup.label.description": "This label will be displayed on navigation item form",
- "pages.settings.form.customFields.popup.type.label": "Custom field type",
- "pages.settings.form.customFields.popup.required.label": "Require field",
- "pages.settings.form.customFields.popup.required.description": "Enabling this field will not change already exiting navigation items",
- "pages.settings.form.customFields.popup.options.label": "Options for select input",
- "pages.settings.form.customFields.popup.options.description": "Enabling this field will not change already exiting navigation items",
- "pages.settings.form.customFields.popup.multi.label": "Enable multiple options input",
- "components.navigationItem.action.newItem": "Add nested item",
- "components.navigationItem.action.edit": "Edit",
- "components.navigationItem.action.view": "View",
- "components.navigationItem.action.restore": "Restore",
- "components.navigationItem.action.remove": "Remove",
- "components.navigationItem.badge.removed": "Removed",
- "components.navigationItem.badge.draft": "Draft",
- "components.navigationItem.badge.published": "Published",
- "components.confirmation.dialog.button.cancel": "Cancel",
- "components.confirmation.dialog.button.confirm": "Confirm",
- "components.confirmation.dialog.description": "Do you want to continue?",
- "components.confirmation.dialog.header": "Confirmation",
- "components.notAccessPage.back": "Back to homepage",
- "view.i18n.fill.cta": "or bootstrap",
- "view.i18n.fill.option": "{locale} locale",
- "view.i18n.fill.cta.button": "copy"
-}
diff --git a/admin/src/translations/en.ts b/admin/src/translations/en.ts
new file mode 100644
index 00000000..b91d3f48
--- /dev/null
+++ b/admin/src/translations/en.ts
@@ -0,0 +1,491 @@
+const en = {
+ plugin: {
+ name: "UI Navigation",
+ section: {
+ name: "Navigation plugin",
+ item: "Configuration",
+ },
+ },
+ header: {
+ title: "Navigation",
+ description: "Define your portal navigation",
+ meta: "ID: { id }, slug: { key }",
+ action: {
+ newItem: "New Item",
+ manage: "Manage",
+ collapseAll: "Collapse All",
+ expandAll: "Expand All",
+ },
+ },
+ submit: {
+ cta: {
+ cancel: "Cancel",
+ save: "Save",
+ },
+ },
+ empty: {
+ description: "Your navigation is empty",
+ cta: "Create first item",
+ },
+ popup: {
+ navigation: {
+ manage: {
+ header: {
+ LIST: "All navigations",
+ CREATE: "New navigation",
+ DELETE: "Deleting",
+ ERROR: "Error",
+ EDIT: "Editing \"{name}\"",
+ },
+ button: {
+ cancel: "Cancel",
+ delete: "Delete",
+ save: "Save",
+ edit: "Edit",
+ create: "Create",
+ goBack: "Go back",
+ purge: "Clear read cache",
+ },
+ table: {
+ id: "Id",
+ name: "Name",
+ locale: "Locale versions",
+ visibility: "Visibility",
+ hasSelected: "{count} entries selected",
+ },
+ footer: {
+ button: {
+ purge: "Clear",
+ },
+ },
+ purge: {
+ header: "This action will clear API read cache. This will result in brief slowdown of reads for navigations below.",
+ },
+ delete: {
+ header: "Following navigations will be removed:",
+ },
+ error: {
+ header: "An error happened :(",
+ message: "An error happened while processing request.",
+ },
+ navigation: {
+ visible: "visible",
+ hidden: "hidden",
+ },
+ },
+ form: {
+ name: {
+ label: "Name",
+ placeholder: "Navigations's name",
+ validation: {
+ name: {
+ required: "Name is required",
+ tooShort: "Name is too short",
+ alreadyUsed: "Name is already used",
+ },
+ visible: {
+ required: "Visibility is required",
+ },
+ },
+ },
+ visible: {
+ label: "Visibility",
+ toggle: {
+ visible: "Visible",
+ hidden: "Hidden",
+ }
+ },
+ },
+ },
+ item: {
+ header: {
+ view: "View navigation item",
+ edit: "Edit navigation item",
+ new: "New navigation item",
+ },
+ form: {
+ title: {
+ label: "Title",
+ autoSync: {
+ label: "Read fields from related",
+ },
+ placeholder: "Enter the item title or leave blank to pull from related entity",
+ },
+ uiRouterKey: {
+ label: "UI router key",
+ placeholder: "If empty, auto generated by \"Title\"",
+ },
+ path: {
+ label: "URL",
+ placeholder: "Unique url part identifies this item",
+ preview: "Preview:",
+ },
+ externalPath: {
+ label: "External URL",
+ placeholder: "Link to the external source",
+ validation: {
+ type: "This value is not a proper url.",
+ },
+ },
+ menuAttached: {
+ label: "Attach to menu",
+ },
+ type: {
+ label: "Navigation item type",
+ internal: {
+ label: "Internal source",
+ },
+ external: {
+ label: "External source",
+ description: "Output path: {value}",
+ },
+ wrapper: {
+ label: "Wrapper element",
+ },
+ },
+ audience: {
+ label: "Audience",
+ placeholder: "Select audience...",
+ empty: "There are no more audiences",
+ },
+ relatedSection: {
+ label: "Relation to",
+ },
+ relatedType: {
+ label: "Content Type",
+ placeholder: "Select content type...",
+ empty: "There are no content types to select",
+ },
+ related: {
+ label: "Entity",
+ placeholder: "Select entity...",
+ empty: "There are no more entities of \"{ contentTypeName }\" to select",
+ },
+ i18n: {
+ locale: {
+ label: "Copy details from",
+ placeholder: "locale",
+ button: "Copy",
+ error: {
+ generic: "Unable to copy item",
+ unavailable: "Locale version unavailable",
+ },
+ },
+ },
+ button: {
+ create: "Create item",
+ update: "Update item",
+ restore: "Restore item",
+ remove: "Remove",
+ save: "Save",
+ cancel: "Cancel",
+ },
+ },
+ },
+ },
+ notification: {
+ navigation: {
+ submit: "Navigation changes has been saved",
+ error: "Duplicate path: \"{ path }\" in parent: \"{ parentTitle }\" for { errorTitles } items",
+ item: {
+ relation: "Entity relation does not exist!",
+ status: {
+ draft: "draft",
+ published: "published",
+ },
+ },
+ },
+ error: {
+ common: "Error while processing request.",
+ customField: {
+ type: "Unsupported type of custom field",
+ media: {
+ missing: "Media input component is missing",
+ },
+ },
+ item: {
+ relation: "Relations provided in some items are incorrect",
+ slug: "Unable to create valid UI Router Key(slug) from \"{ query }\". \"{ result }\" received",
+ },
+ }
+ },
+ pages: {
+ auth: {
+ noAccess: "No access",
+ not: {
+ allowed: "Oops! It seems like You do not have access to this page...",
+ },
+ },
+ main: {
+ search: {
+ placeholder: "Type to start searching...",
+ subLabel: "press ENTER to highlight next item",
+ },
+ header: {
+ localization: {
+ select: {
+ placeholder: "Select locale",
+ },
+ },
+ },
+ },
+ settings: {
+ title: "Navigation settings",
+ general: {
+ title: "General settings",
+ },
+ additional: {
+ title: "Additional settings",
+ },
+ customFields: {
+ title: "Custom fields settings",
+ },
+ nameField: {
+ title: "Content types settings",
+ },
+ restoring: {
+ title: "Restoring",
+ },
+ section: {
+ title: "Navigation Plugin",
+ subtitle: "Configuration",
+ },
+ header: {
+ title: "Navigation",
+ description: "Configure the navigation plugin",
+ },
+ form: {
+ cascadeMenuAttached: {
+ label: "Cascade menu attached",
+ hint: "Disable if you don't want \"Menu attached\" to cascade on child items",
+ },
+ preferCustomContentTypes: {
+ label: "Prefer API Content Types",
+ hint: "Prefer if to use only api:: prefixed content types",
+ },
+ contentTypes: {
+ label: "Enable navigation for",
+ placeholder: "eg. Pages, Posts",
+ hint: "If none is selected, also none of the content types are enabled",
+ },
+ i18n: {
+ label: "i18n",
+ hint: "Enable internationalisation",
+ hintMissingDefaultLocale: "Default locale missing!",
+ },
+ allowedLevels: {
+ label: "Allowed levels",
+ placeholder: "eg. 2",
+ hint: "Maximum level for which you're able to mark item as \"Menu attached\"",
+ },
+ audience: {
+ label: "Audience",
+ hint: "Enable audience field",
+ },
+ nameField: {
+ default: "Default",
+ label: "Name fields",
+ placeholder: "Select at least one or leave empty to apply defaults",
+ hint: "If left empty name field is going to take following ordered fields: \"title\", \"subject\" and \"name\"",
+ empty: "This content type doesn't have any string attributes",
+ },
+ populate: {
+ label: "Fields to populate",
+ placeholder: "Select at least one or leave empty to disable populating relation fields",
+ hint: "Selected relation fields will be populated inside API responses",
+ empty: "This content type doesn't have any relation fields",
+ },
+ pathDefaultFields: {
+ label: "Path default fields",
+ placeholder: "Select at least one or leave it empty to disable populating path field with attributes value",
+ hint: "Value of selected attribute will be a default value for internal path",
+ empty: "This content type doesn't have any suitable attributes",
+ },
+ contentTypesSettings: {
+ label: "Content types",
+ tooltip: "Custom configuration per content type",
+ initializationWarning: {
+ title: "Warning",
+ content: "- Content Type hasn't yet been initialized. Initialize it first to be able to use in a Visual Editor.",
+ },
+ },
+ customFields: {
+ table: {
+ confirmation: {
+ header: "Removing custom field",
+ message: "This action will cause removing all custom fields values from navigation items.",
+ confirm: "Continue",
+ error: "An error ocurred while removing custom field",
+ },
+ header: {
+ name: "Name",
+ label: "Label",
+ type: "Type",
+ required: "Required",
+ },
+ footer: "Create new custom field",
+ edit: "Edit custom field",
+ enable: "Enable custom field",
+ disable: "Disable custom field",
+ remove: "Remove custom field",
+ required: "required",
+ notRequired: "not required",
+ },
+ popup: {
+ header: {
+ edit: "Edit custom field",
+ new: "Add new custom field",
+ },
+ name: {
+ label: "Custom field name",
+ placeholder: "example_name",
+ description: "Name of the custom field must be unique",
+ },
+ label: {
+ label: "Custom field label",
+ placeholder: "Example label",
+ description: "This label will be displayed on navigation item form",
+ },
+ type: {
+ label: "Custom field type",
+ description: "Type of the custom field, define how it will be displayed",
+ },
+ required: {
+ label: "Require field",
+ description: "Enabling this field will not change already exiting navigation items",
+ },
+ options: {
+ label: "Options for select input",
+ description: "Provide options separated by \";\"",
+ },
+ multi: {
+ label: "Enable multiple options input",
+ description: "Allow single or multiple options selection",
+ },
+ },
+ },
+ },
+ actions: {
+ submit: "Save configuration",
+ restore: {
+ label: "Restore configuration",
+ confirmation: {
+ header: "Do you want to continue?",
+ confirm: "Restore",
+ description: "Plugin config will be restored from plugins.js file.",
+ },
+ description: "Restoring the plugin configuration will cause it to be replaced with configuration saved inside 'plugins.js' file.",
+ },
+ restart: {
+ label: "Restart Strapi",
+ alert: {
+ title: "Strapi requires restart",
+ description: "You've made a configuration changes which requires your Strapi application to be restarted to take an effect. Do it manually or by using below trigger.",
+ close: "Discard",
+ cancel: "Cancel",
+ reason: {
+ I18N: "Internationalization(i18n) changes will be applied.",
+ GRAPH_QL: "GraphQL changes will be applied.",
+ I18N_NAVIGATIONS_PRUNE: "Obsolete locale navigations will be removed.",
+ },
+ },
+ },
+ disableI18n: {
+ confirmation: {
+ header: "Disabling Internationalization",
+ confirm: "I understand",
+ description: {
+ line1: "You are disabling Internationalization for Navigation. Navigations for locales different than default are not available for viewing and modifications via this plugin.",
+ line2: "You can choose to remove navigations for other locales.",
+ line3: "Remember! Removal is irreversible",
+ },
+ },
+ prune: {
+ label: "Obsolete navigations",
+ on: "Remove",
+ off: "Keep",
+ },
+ },
+ },
+ notification: {
+ fetch: {
+ error: "Failed to fetch configuration. Retrying...",
+ },
+ submit: {
+ success: "Config has been updated successfully",
+ error: "Config update has failed",
+ },
+ restore: {
+ success: "Config has been restored successfully",
+ error: "Config restore has failed",
+ },
+ restart: {
+ success: "Application has been restarted successfully",
+ error: "Failed to restart your application. Try to do it manually.",
+ },
+ },
+ },
+ view: {
+ actions: {
+ i18nCopyItems: {
+ confirmation: {
+ header: "Confirmation",
+ confirm: "Copy",
+ content: "Do you want to copy navigations items?",
+ },
+ },
+ },
+ },
+ },
+ components: {
+ toggle: {
+ enabled: "Enabled",
+ disabled: "Disabled",
+ },
+ navigationItem: {
+ action: {
+ newItem: "Add nested item",
+ edit: "Edit",
+ view: "View",
+ restore: "Restore",
+ remove: "Remove",
+ },
+ badge: {
+ removed: "Removed",
+ draft: "Draft",
+ published: "Published",
+ },
+ related: {
+ localeMissing: "(Locale version missing)"
+ },
+ },
+ confirmation: {
+ dialog: {
+ button: {
+ cancel: "Cancel",
+ confirm: "Confirm",
+ },
+ description: "Do you want to continue?",
+ header: "Confirmation",
+ },
+ },
+ notAccessPage: {
+ back: "Back to homepage",
+ },
+ },
+ view: {
+ i18n: {
+ fill: {
+ option: "{locale} locale",
+ cta: {
+ header: "or bootstrap",
+ button: "copy",
+ },
+ },
+ },
+ },
+};
+
+export default en;
+
+export type EN = typeof en;
diff --git a/admin/src/translations/fr.json b/admin/src/translations/fr.json
deleted file mode 100644
index 2e824c8f..00000000
--- a/admin/src/translations/fr.json
+++ /dev/null
@@ -1,48 +0,0 @@
-{
- "plugin.name": "UI Navigation",
- "header.title": "Navigation",
- "header.description": "Définisser votre menu de navigation",
- "header.meta": "ID: { id }, slug: { key }",
- "submit.cta.cancel": "Annuler",
- "submit.cta.save": "Sauvegarder",
- "empty": "Le menu de navigation est vide",
- "empty.cta": "Créer un premier élément",
- "popup.item.header": "Modifiez les éléments de navigation",
- "popup.item.form.title.label": "Titre",
- "popup.item.form.autoSync.label": "Lire les champs associés",
- "popup.item.form.title.placeholder": "Saisissez le titre de l'élément ou laissez le champ vide pour utiliser celui de l'entité associée",
- "popup.item.form.uiRouterKey.label": "Clé de routeur pour l'interface utilisateur",
- "popup.item.form.uiRouterKey.placeholder": "Si vide, généré automatiquement par \"Title\"",
- "popup.item.form.path.label": "URL",
- "popup.item.form.path.placeholder": "Une partie d'URL unique identifie cet élément",
- "popup.item.form.path.preview": "Aperçu:",
- "popup.item.form.externalPath.label": "URL externe",
- "popup.item.form.externalPath.placeholder": "Lien vers la source externe",
- "popup.item.form.externalPath.validation.type": "Cette valeur d'URL n'est pas correcte.",
- "popup.item.form.menuAttached.label": "Ajouter au menu",
- "popup.item.form.type.label": "Type",
- "popup.item.form.type.internal.label": "Interne",
- "popup.item.form.type.external.label": "Externe",
- "popup.item.form.audience.label": "Audience",
- "popup.item.form.audience.placeholder": "Écrivez pour lancer la recherche...",
- "popup.item.form.relatedSection.label": "Relation avec",
- "popup.item.form.relatedType.label": "Type de contenu",
- "popup.item.form.related.label": "Entitée",
- "popup.item.form.related.empty": "Il n'y a plus d'entitée \"{ contentTypeName }\", que vous pouvez sélectionner",
- "popup.item.form.button.create": "Créer un élément",
- "popup.item.form.button.update": "Modifier l'élément",
- "popup.item.form.button.restore": "Restaurer l'élément",
- "popup.item.form.button.remove": "Supprimer",
- "popup.item.form.button.save": "Sauvegarder",
- "popup.item.form.button.cancel": "Annuler",
- "notification.navigation.submit": "Les modifications de navigation ont été enregistrées",
- "notification.navigation.error": "Chemin indisponible: { path } dans le parent: { parentTitle } pour l'élément { errorTitles }",
- "notification.navigation.item.relation": "L'entitée n'a pas de relations!",
- "notification.navigation.item.relation.status.draft": "brouillon",
- "notification.navigation.item.relation.status.published": "publié",
- "components.navigationItem.action.newItem": "Nouvel élément imbriqué",
- "components.navigationItem.badge.removed": "Supprimé",
- "components.navigationItem.badge.draft": "Brouillon",
- "components.navigationItem.badge.published": "Publié",
- "notification.error.item.slug": "Impossible de créer une clé de routeur d'interface utilisateur valide (slug) à partir de \"{ query }\". \"{ result }\" reçu"
-}
\ No newline at end of file
diff --git a/admin/src/translations/fr.ts b/admin/src/translations/fr.ts
new file mode 100644
index 00000000..086ed2ed
--- /dev/null
+++ b/admin/src/translations/fr.ts
@@ -0,0 +1,489 @@
+const fr = {
+ plugin: {
+ name: "Navigation UI",
+ section: {
+ name: "Plugin de navigation",
+ item: "Configuration",
+ },
+ },
+ header: {
+ title: "Navigation",
+ description: "Définissez la navigation de votre portail",
+ meta: "ID: { id }, slug: { key }",
+ action: {
+ newItem: "Nouvel élément",
+ manage: "Gérer",
+ collapseAll: "Tout réduire",
+ expandAll: "Tout développer",
+ },
+ },
+ submit: {
+ cta: {
+ cancel: "Annuler",
+ save: "Enregistrer",
+ },
+ },
+ empty: {
+ description: "Votre navigation est vide",
+ cta: "Créer le premier élément",
+ },
+ popup: {
+ navigation: {
+ manage: {
+ header: {
+ LIST: "Toutes les navigations",
+ CREATE: "Nouvelle navigation",
+ DELETE: "Suppression",
+ ERROR: "Erreur",
+ EDIT: "Édition de \"{name}\"",
+ },
+ button: {
+ cancel: "Annuler",
+ delete: "Supprimer",
+ save: "Enregistrer",
+ edit: "Modifier",
+ create: "Créer",
+ goBack: "Retourner",
+ purge: "Effacer le cache de lecture",
+ },
+ table: {
+ id: "Id",
+ name: "Nom",
+ locale: "Versions locales",
+ visibility: "Visibilité",
+ hasSelected: "{count} entrées sélectionnées",
+ },
+ footer: {
+ button: {
+ purge: "Effacer",
+ },
+ },
+ purge: {
+ header: "Cette action effacera le cache de lecture de l'API. Cela entraînera un ralentissement temporaire des lectures pour les navigations ci-dessous.",
+ },
+ delete: {
+ header: "Les navigations suivantes seront supprimées :",
+ },
+ error: {
+ header: "Une erreur est survenue :(",
+ message: "Une erreur est survenue lors du traitement de la demande.",
+ },
+ navigation: {
+ visible: "visible",
+ hidden: "caché",
+ },
+ },
+ form: {
+ name: {
+ label: "Nom",
+ placeholder: "Nom de la navigation",
+ validation: {
+ name: {
+ required: "Le nom est requis",
+ tooShort: "Le nom est trop court",
+ alreadyUsed: "Le nom est déjà utilisé",
+ },
+ visible: {
+ required: "La visibilité est requise",
+ },
+ },
+ },
+ visible: {
+ label: "Visibilité",
+ toggle: {
+ visible: "Visible",
+ hidden: "Caché",
+ }
+ },
+ },
+ },
+ item: {
+ header: {
+ view: "Voir l'élément de navigation",
+ edit: "Modifier l'élément de navigation",
+ new: "Nouvel élément de navigation",
+ },
+ form: {
+ title: {
+ label: "Titre",
+ autoSync: {
+ label: "Lire les champs de la relation",
+ },
+ placeholder: "Entrez le titre de l'élément ou laissez vide pour tirer de l'entité liée",
+ },
+ uiRouterKey: {
+ label: "Clé du routeur UI",
+ placeholder: "Si vide, généré automatiquement par \"Titre\"",
+ },
+ path: {
+ label: "URL",
+ placeholder: "Partie unique de l'URL identifiant cet élément",
+ preview: "Aperçu :",
+ },
+ externalPath: {
+ label: "URL externe",
+ placeholder: "Lien vers la source externe",
+ validation: {
+ type: "Cette valeur n'est pas une URL valide.",
+ },
+ },
+ menuAttached: {
+ label: "Attacher au menu",
+ },
+ type: {
+ label: "Type d'élément de navigation",
+ internal: {
+ label: "Source interne",
+ },
+ external: {
+ label: "Source externe",
+ description: "Chemin de sortie : {value}",
+ },
+ wrapper: {
+ label: "Élément wrapper",
+ },
+ },
+ audience: {
+ label: "Audience",
+ placeholder: "Sélectionner l'audience...",
+ empty: "Il n'y a plus d'audiences",
+ },
+ relatedSection: {
+ label: "Relation à",
+ },
+ relatedType: {
+ label: "Type de contenu",
+ placeholder: "Sélectionner le type de contenu...",
+ empty: "Il n'y a pas de types de contenu à sélectionner",
+ },
+ related: {
+ label: "Entité",
+ placeholder: "Sélectionner l'entité...",
+ empty: "Il n'y a plus d'entités de \"{ contentTypeName }\" à sélectionner",
+ },
+ i18n: {
+ locale: {
+ label: "Copier les détails de",
+ placeholder: "locale",
+ button: "Copier",
+ error: {
+ generic: "Impossible de copier l'élément",
+ unavailable: "Version locale indisponible",
+ },
+ },
+ },
+ button: {
+ create: "Créer l'élément",
+ update: "Mettre à jour l'élément",
+ restore: "Restaurer l'élément",
+ remove: "Supprimer",
+ save: "Enregistrer",
+ cancel: "Annuler",
+ },
+ },
+ },
+ },
+ notification: {
+ navigation: {
+ submit: "Les modifications de navigation ont été enregistrées",
+ error: "Chemin en double : \"{ path }\" dans le parent : \"{ parentTitle }\" pour { errorTitles } éléments",
+ item: {
+ relation: "La relation de l'entité n'existe pas !",
+ status: {
+ draft: "brouillon",
+ published: "publié",
+ },
+ },
+ },
+ error: {
+ common: "Erreur lors du traitement de la demande.",
+ customField: {
+ type: "Type de champ personnalisé non pris en charge",
+ media: {
+ missing: "Le composant d'entrée média est manquant",
+ },
+ },
+ item: {
+ relation: "Les relations fournies dans certains éléments sont incorrectes",
+ slug: "Impossible de créer une clé de routeur UI valide (slug) à partir de \"{ query }\". \"{ result }\" reçu",
+ },
+ }
+ },
+ pages: {
+ auth: {
+ noAccess: "Pas d'accès",
+ not: {
+ allowed: "Oups ! Il semble que vous n'ayez pas accès à cette page...",
+ },
+ },
+ main: {
+ search: {
+ placeholder: "Tapez pour commencer à rechercher...",
+ subLabel: "appuyez sur ENTRÉE pour mettre en surbrillance l'élément suivant",
+ },
+ header: {
+ localization: {
+ select: {
+ placeholder: "Sélectionner la locale",
+ },
+ },
+ },
+ },
+ settings: {
+ title: "Paramètres de navigation",
+ general: {
+ title: "Paramètres généraux",
+ },
+ additional: {
+ title: "Paramètres supplémentaires",
+ },
+ customFields: {
+ title: "Paramètres des champs personnalisés",
+ },
+ nameField: {
+ title: "Paramètres des types de contenu",
+ },
+ restoring: {
+ title: "Restauration",
+ },
+ section: {
+ title: "Plugin de navigation",
+ subtitle: "Configuration",
+ },
+ header: {
+ title: "Navigation",
+ description: "Configurer le plugin de navigation",
+ },
+ form: {
+ cascadeMenuAttached: {
+ label: "Cascade du menu attaché",
+ hint: "Désactiver si vous ne voulez pas que \"Menu attaché\" se propage aux éléments enfants",
+ },
+ preferCustomContentTypes: {
+ label: "Préférer les types de contenu API",
+ hint: "Préférer utiliser uniquement les types de contenu préfixés par api::",
+ },
+ contentTypes: {
+ label: "Activer la navigation pour",
+ placeholder: "ex. Pages, Articles",
+ hint: "Si aucun n'est sélectionné, aucun des types de contenu n'est activé",
+ },
+ i18n: {
+ label: "i18n",
+ hint: "Activer l'internationalisation",
+ hintMissingDefaultLocale: "Locale par défaut manquante !",
+ },
+ allowedLevels: {
+ label: "Niveaux autorisés",
+ placeholder: "ex. 2",
+ hint: "Niveau maximum pour lequel vous pouvez marquer un élément comme \"Menu attaché\"",
+ },
+ audience: {
+ label: "Audience",
+ hint: "Activer le champ audience",
+ },
+ nameField: {
+ default: "Par défaut",
+ label: "Champs de nom",
+ placeholder: "Sélectionner au moins un ou laisser vide pour appliquer les valeurs par défaut",
+ hint: "Si laissé vide, le champ de nom prendra les champs suivants par ordre : \"titre\", \"sujet\" et \"nom\"",
+ empty: "Ce type de contenu n'a pas d'attributs de chaîne",
+ },
+ populate: {
+ label: "Champs à remplir",
+ placeholder: "Sélectionner au moins un ou laisser vide pour désactiver le remplissage des champs de relation",
+ hint: "Les champs de relation sélectionnés seront remplis dans les réponses de l'API",
+ empty: "Ce type de contenu n'a pas de champs de relation",
+ },
+ pathDefaultFields: {
+ label: "Champs par défaut du chemin",
+ placeholder: "Sélectionner au moins un ou laisser vide pour désactiver le remplissage du champ de chemin avec la valeur des attributs",
+ hint: "La valeur de l'attribut sélectionné sera la valeur par défaut pour le chemin interne",
+ empty: "Ce type de contenu n'a pas d'attributs appropriés",
+ },
+ contentTypesSettings: {
+ label: "Types de contenu",
+ tooltip: "Configuration personnalisée par type de contenu",
+ initializationWarning: {
+ title: "Avertissement",
+ content: "- Le type de contenu n'a pas encore été initialisé. Initialisez-le d'abord pour pouvoir l'utiliser dans un éditeur visuel.",
+ },
+ },
+ customFields: {
+ table: {
+ confirmation: {
+ header: "Suppression du champ personnalisé",
+ message: "Cette action entraînera la suppression de toutes les valeurs des champs personnalisés des éléments de navigation.",
+ confirm: "Continuer",
+ error: "Une erreur est survenue lors de la suppression du champ personnalisé",
+ },
+ header: {
+ name: "Nom",
+ label: "Étiquette",
+ type: "Type",
+ required: "Requis",
+ },
+ footer: "Créer un nouveau champ personnalisé",
+ edit: "Modifier le champ personnalisé",
+ enable: "Activer le champ personnalisé",
+ disable: "Désactiver le champ personnalisé",
+ remove: "Supprimer le champ personnalisé",
+ required: "requis",
+ notRequired: "non requis",
+ },
+ popup: {
+ header: {
+ edit: "Modifier le champ personnalisé",
+ new: "Ajouter un nouveau champ personnalisé",
+ },
+ name: {
+ label: "Nom du champ personnalisé",
+ placeholder: "exemple_nom",
+ description: "Le nom du champ personnalisé doit être unique",
+ },
+ label: {
+ label: "Étiquette du champ personnalisé",
+ placeholder: "Exemple d'étiquette",
+ description: "Cette étiquette sera affichée sur le formulaire de l'élément de navigation",
+ },
+ type: {
+ label: "Type de champ personnalisé",
+ description: "Le type de champ personnalisé détermine le type de données que le champ peut contenir",
+ },
+ required: {
+ label: "Champ requis",
+ description: "Activer ce champ ne changera pas les éléments de navigation déjà existants",
+ },
+ options: {
+ label: "Options pour l'entrée de sélection",
+ description: "Fournir des options séparées par \";\"",
+ },
+ multi: {
+ label: "Activer l'entrée de plusieurs options",
+ description: "Autoriser la sélection simple ou multiple",
+ },
+ },
+ },
+ },
+ actions: {
+ submit: "Enregistrer la configuration",
+ restore: {
+ label: "Restaurer la configuration",
+ confirmation: {
+ header: "Voulez-vous continuer ?",
+ confirm: "Restaurer",
+ description: "La configuration du plugin sera restaurée à partir du fichier plugins.js.",
+ },
+ description: "La restauration de la configuration du plugin entraînera son remplacement par la configuration enregistrée dans le fichier 'plugins.js'.",
+ },
+ restart: {
+ label: "Redémarrer Strapi",
+ alert: {
+ title: "Strapi nécessite un redémarrage",
+ description: "Vous avez apporté des modifications à la configuration qui nécessitent le redémarrage de votre application Strapi pour prendre effet. Faites-le manuellement ou en utilisant le déclencheur ci-dessous.",
+ close: "Ignorer",
+ cancel: "Annuler",
+ reason: {
+ I18N: "Les modifications de l'internationalisation (i18n) seront appliquées.",
+ GRAPH_QL: "Les modifications de GraphQL seront appliquées.",
+ I18N_NAVIGATIONS_PRUNE: "Les navigations de locale obsolètes seront supprimées.",
+ },
+ },
+ },
+ disableI18n: {
+ confirmation: {
+ header: "Désactivation de l'internationalisation",
+ confirm: "Je comprends",
+ description: {
+ line1: "Vous désactivez l'internationalisation pour la navigation. Les navigations pour les locales différentes de la locale par défaut ne sont pas disponibles pour la visualisation et les modifications via ce plugin.",
+ line2: "Vous pouvez choisir de supprimer les navigations pour d'autres locales.",
+ line3: "Rappelez-vous ! La suppression est irréversible",
+ },
+ },
+ prune: {
+ label: "Navigations obsolètes",
+ on: "Supprimer",
+ off: "Garder",
+ },
+ },
+ },
+ notification: {
+ fetch: {
+ error: "Échec de la récupération de la configuration. Nouvelle tentative...",
+ },
+ submit: {
+ success: "La configuration a été mise à jour avec succès",
+ error: "La mise à jour de la configuration a échoué",
+ },
+ restore: {
+ success: "La configuration a été restaurée avec succès",
+ error: "La restauration de la configuration a échoué",
+ },
+ restart: {
+ success: "L'application a été redémarrée avec succès",
+ error: "Échec du redémarrage de votre application. Essayez de le faire manuellement.",
+ },
+ },
+ },
+ view: {
+ actions: {
+ i18nCopyItems: {
+ confirmation: {
+ header: "Confirmation",
+ confirm: "Copier",
+ content: "Voulez-vous copier les éléments de navigation ?",
+ },
+ },
+ },
+ },
+ },
+ components: {
+ toggle: {
+ enabled: "Activé",
+ disabled: "Désactivé",
+ },
+ navigationItem: {
+ action: {
+ newItem: "Ajouter un élément imbriqué",
+ edit: "Modifier",
+ view: "Voir",
+ restore: "Restaurer",
+ remove: "Supprimer",
+ },
+ badge: {
+ removed: "Supprimé",
+ draft: "Brouillon",
+ published: "Publié",
+ },
+ related: {
+ localeMissing: "(Version locale manquante)"
+ },
+ },
+ confirmation: {
+ dialog: {
+ button: {
+ cancel: "Annuler",
+ confirm: "Confirmer",
+ },
+ description: "Voulez-vous continuer ?",
+ header: "Confirmation",
+ },
+ },
+ notAccessPage: {
+ back: "Retour à la page d'accueil",
+ },
+ },
+ view: {
+ i18n: {
+ fill: {
+ option: "locale {locale}",
+ cta: {
+ header: "ou démarrer",
+ button: "copier",
+ },
+ },
+ },
+ },
+};
+
+export default fr;
diff --git a/admin/src/translations/index.js b/admin/src/translations/index.js
deleted file mode 100644
index f7c1b3f8..00000000
--- a/admin/src/translations/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import pluginId from "../pluginId";
-import en from "./en.json";
-import fr from "./fr.json";
-import ca from "./ca.json";
-
-const trads = {
- en,
- fr,
- ca,
-};
-
-export const getTradId = (msg) => `${pluginId}.${msg}`;
-export const getTrad = (msg, defaultMessage) => ({id: getTradId(msg), defaultMessage: defaultMessage || getTradId(msg)});
-
-export default trads;
diff --git a/admin/src/translations/index.ts b/admin/src/translations/index.ts
new file mode 100644
index 00000000..85bf8a3c
--- /dev/null
+++ b/admin/src/translations/index.ts
@@ -0,0 +1,19 @@
+import { Path } from '@sensinum/strapi-utils';
+import type { EN } from './en';
+import { PLUGIN_ID } from '../pluginId';
+
+export type TranslationPath = Path;
+
+const trads = {
+ en: () => import('./en'),
+ fr: () => import('./fr'),
+ ca: () => import('./ca'),
+};
+
+export const getTradId = (msg: string) => `${PLUGIN_ID}.${msg}`;
+export const getTrad = (msg: string, defaultMessage?: string) => ({
+ id: getTradId(msg),
+ defaultMessage: defaultMessage ?? getTradId(msg),
+});
+
+export default trads;
diff --git a/admin/src/types.ts b/admin/src/types.ts
new file mode 100644
index 00000000..7c253cd4
--- /dev/null
+++ b/admin/src/types.ts
@@ -0,0 +1,6 @@
+export type Effect = (value: T) => void;
+export type VoidEffect = Effect;
+export type ToBeFixed = any;
+
+export type FormChangeEvent = React.ChangeEvent | string;
+export type FormItemErrorSchema = Record;
\ No newline at end of file
diff --git a/admin/src/utils/api.ts b/admin/src/utils/api.ts
deleted file mode 100644
index e6f2f124..00000000
--- a/admin/src/utils/api.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { request } from '@strapi/helper-plugin';
-import { prop } from 'lodash/fp';
-import { NavigationPluginConfig } from '../../../types';
-import pluginId from '../pluginId';
-
-export const fetchNavigationConfig = () =>
- request(`/${pluginId}/settings/config`, { method: 'GET' });
-
-export const updateNavigationConfig = ({ body }: { body: NavigationPluginConfig }) =>
- request(`/${pluginId}/config`, { method: 'PUT', body: (body as unknown as XMLHttpRequestBodyInit) }, true);
-
-export const restoreNavigationConfig = () =>
- request(`/${pluginId}/config`, { method: 'DELETE' }, true);
-
-export const fetchAllContentTypes = async () =>
- request('/content-manager/content-types', { method: 'GET' }).then(prop("data"));
-
-export const restartStrapi = () =>
- request(`/${pluginId}/settings/restart`);
diff --git a/admin/src/utils/constants.ts b/admin/src/utils/constants.ts
new file mode 100644
index 00000000..59d1ea54
--- /dev/null
+++ b/admin/src/utils/constants.ts
@@ -0,0 +1 @@
+export const RELATED_ITEM_SEPARATOR = '$';
diff --git a/admin/src/utils/enums.ts b/admin/src/utils/enums.ts
deleted file mode 100644
index 73971ff6..00000000
--- a/admin/src/utils/enums.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export const navigationItemType = {
- INTERNAL: "INTERNAL",
- EXTERNAL: "EXTERNAL",
- WRAPPER: "WRAPPER",
-};
-
-export const navigationItemAdditionalFields = {
- AUDIENCE: 'audience',
-};
-
-export const ItemTypes = {
- NAVIGATION_ITEM: 'navigationItem'
-};
-
-export const ResourceState = {
- RESOLVED: 'RESOLVED',
- LOADING: 'LOADING',
- ERROR: 'ERROR',
-};
-
-export const resolvedResourceFor = (value: T) => ({
- type: ResourceState.RESOLVED,
- value,
-});
-
-export const errorStatusResourceFor = (errors: Array) => ({
- type: ResourceState.ERROR,
- errors,
-});
\ No newline at end of file
diff --git a/admin/src/utils/functions.ts b/admin/src/utils/functions.ts
deleted file mode 100644
index 58976d15..00000000
--- a/admin/src/utils/functions.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { get, isEmpty } from 'lodash';
-import { useIntl } from 'react-intl';
-import { NavigationItemAdditionalField, NavigationItemAdditionalFieldValues, ToBeFixed } from '../../../types';
-import { defaultValues as navigationItemFormDefaults } from '../pages/View/components/NavigationItemForm/utils/form';
-import pluginId from '../pluginId';
-
-type MessageInput = {
- id: string;
- defaultMessage?: string;
- props?: Record
-} | string;
-
-type PrepareNewValueForRecord = (
- uid: string,
- current: Record,
- value: string[]
-) => Record;
-
-export const getMessage = (input: MessageInput, defaultMessage: string = '', inPluginScope: boolean = true): string => {
- const { formatMessage } = useIntl();
- let formattedId = '';
- if (typeof input === 'string') {
- formattedId = input;
- } else {
- formattedId = input.id.toString() || formattedId;
- }
- return formatMessage({
- id: `${inPluginScope ? pluginId : 'app.components'}.${formattedId}`,
- defaultMessage,
- }, typeof input === 'string' ? undefined : input?.props)
-};
-
-export const getDefaultCustomFields = (args: {
- additionalFields: NavigationItemAdditionalField[],
- customFieldsValues: NavigationItemAdditionalFieldValues,
- defaultCustomFieldsValues: NavigationItemAdditionalFieldValues
-}): NavigationItemAdditionalFieldValues => {
- return args.additionalFields.reduce((acc, additionalField) => {
- if (typeof additionalField === 'string') {
- return acc;
- } else {
- const value = navigationItemFormDefaults.additionalFields[additionalField.type];
- return {
- ...acc,
- [additionalField.name]: get(args.customFieldsValues, additionalField.name, value),
- };
- }
- }, {});
-}
-
-export const prepareNewValueForRecord: PrepareNewValueForRecord = (uid, current, value) => ({
- ...current,
- [uid]: value && !isEmpty(value) ? [...value] : undefined,
-});
diff --git a/admin/src/utils/getTranslation.ts b/admin/src/utils/getTranslation.ts
new file mode 100644
index 00000000..69296100
--- /dev/null
+++ b/admin/src/utils/getTranslation.ts
@@ -0,0 +1,7 @@
+import { PLUGIN_ID } from '../pluginId';
+
+export { getTrad, getTradId } from '../translations';
+
+const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`;
+
+export { getTranslation };
diff --git a/admin/src/utils/index.ts b/admin/src/utils/index.ts
deleted file mode 100644
index e53cba3e..00000000
--- a/admin/src/utils/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './functions';
-export * from './api';
-export * from './enums';
\ No newline at end of file
diff --git a/admin/src/utils/permissions.ts b/admin/src/utils/permissions.ts
new file mode 100644
index 00000000..d2de5e13
--- /dev/null
+++ b/admin/src/utils/permissions.ts
@@ -0,0 +1,20 @@
+'use strict';
+
+const render = (uid: string) => {
+ return `plugin::navigation.${uid}`;
+};
+
+const navigation = {
+ read: 'read',
+ update: 'update',
+ settings: 'settings',
+};
+
+// This should be equal to admin side. Strapi push to make admin and server independent chunks.
+const pluginPermissions = {
+ access: [{ action: render(navigation.read), subject: null }],
+ update: [{ action: render(navigation.update), subject: null }],
+ settings: [{ action: render(navigation.settings), subject: null }],
+};
+
+export default pluginPermissions;
diff --git a/admin/src/utils/prefixPluginTranslations.ts b/admin/src/utils/prefixPluginTranslations.ts
new file mode 100644
index 00000000..835fb029
--- /dev/null
+++ b/admin/src/utils/prefixPluginTranslations.ts
@@ -0,0 +1,5 @@
+export const prefixPluginTranslations = (input: Record, pluginId: string) => {
+ return Object.fromEntries(
+ Object.entries(input).map(([key, value]) => [[pluginId, key].join('.'), value] as const)
+ );
+};
diff --git a/admin/tsconfig.build.json b/admin/tsconfig.build.json
new file mode 100644
index 00000000..d033e0ca
--- /dev/null
+++ b/admin/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig",
+ "include": ["./src", "./custom.d.ts"],
+ "exclude": ["**/*.test.ts", "**/*.test.tsx"],
+ "compilerOptions": {
+ "rootDir": "../",
+ "baseUrl": ".",
+ "outDir": "./dist"
+ }
+}
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 00000000..5692e7ac
--- /dev/null
+++ b/admin/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@strapi/typescript-utils/tsconfigs/admin",
+ "include": ["./src", "./custom.d.ts", "../@types"],
+ "compilerOptions": {
+ "rootDir": "../",
+ "baseUrl": "."
+ }
+}
diff --git a/jest.config.ts b/jest.config.ts
index ea1d8503..966d4b55 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,22 +1,25 @@
-import type { Config } from '@jest/types';
-import { defaults as tsjPreset } from 'ts-jest/presets'
+import type { JestConfigWithTsJest } from 'ts-jest';
+import type { Core } from '@strapi/strapi';
+import { defaults as tsjPreset } from 'ts-jest/presets';
-const config: Config.InitialOptions = {
- testMatch: ['**/__tests__/?(*.)+(spec|test).ts'],
+const config: JestConfigWithTsJest = {
+ testMatch: ['**/tests/**/?(*.)+(spec|test).(t|j)s'],
transform: {
- // TODO: Resolve packages versions
- ...tsjPreset.transform as any,
+ ...tsjPreset.transform,
},
- preset: "ts-jest",
- coverageDirectory: "./coverage/",
+ preset: 'ts-jest',
+ coverageDirectory: './coverage/',
collectCoverage: true,
+ reporters: ['default', 'jest-junit'],
globals: {
'ts-jest': {
diagnostics: {
- warnOnly: true
- }
- }
- }
+ warnOnly: true,
+ },
+ },
+ strapi: {} as Core.Strapi,
+ },
+ prettierPath: null,
};
-export default config;
\ No newline at end of file
+export default config;
diff --git a/migrations/strapi-plugin-navigation-3.0.0-no-1-related-id-to-documentid.js b/migrations/strapi-plugin-navigation-3.0.0-no-1-related-id-to-documentid.js
new file mode 100644
index 00000000..d4e9365a
--- /dev/null
+++ b/migrations/strapi-plugin-navigation-3.0.0-no-1-related-id-to-documentid.js
@@ -0,0 +1,40 @@
+const SOURCE_TABLE_NAME = 'navigations_items_related';
+const SOURCE_LINK_TABLE_NAME = 'navigations_items_related_links';
+const TARGET_TABLE_NAME = 'navigations_items';
+const RELATED_ITEM_SEPARATOR = '$';
+
+
+module.exports = {
+ async up(knex) {
+
+ console.log("Navigation plugin :: Migrations :: Backup navigation item related table - START");
+
+ // Get all entries and rewrite directly to the navigation_items table
+ const all = await knex.from(SOURCE_TABLE_NAME).columns('id', 'related_id', 'related_type').select();
+
+ await Promise.all(
+ all.map(async (item) => {
+ const { related_id, related_type, id: row_id} = item;
+
+ if (related_id && related_type && !isNaN(parseInt(related_id, 10))) {
+ const newRelatedId = `${related_type}${RELATED_ITEM_SEPARATOR}${related_id}`;
+ const link = await knex.from(SOURCE_LINK_TABLE_NAME).columns('navigation_item_id', 'navigations_items_related_id').select().where({ navigations_items_related_id: row_id });
+ const nav_id = link[0].navigation_item_id;
+
+ await knex.from(TARGET_TABLE_NAME).update({ related: newRelatedId }).where({ id: nav_id });
+ }
+ })
+ );
+
+ // Drop the old tables
+ // await knex.schema.dropTable(SOURCE_TABLE_NAME);
+ // await knex.schema.dropTable(SOURCE_LINK_TABLE_NAME);
+
+ console.log("Navigation plugin :: Migrations :: Backup navigation item related table - DONE");
+
+ await strapi.db.transaction(async () => {
+ // Run related id to document id migration
+ await strapi.service('plugin::navigation.navigation').migrateRelatedIdToDocumentId();
+ });
+ },
+};
\ No newline at end of file
diff --git a/migrations/strapi-plugin-navigation-3.0.0-no-2-locale-slug-regular-slug.js b/migrations/strapi-plugin-navigation-3.0.0-no-2-locale-slug-regular-slug.js
new file mode 100644
index 00000000..728329aa
--- /dev/null
+++ b/migrations/strapi-plugin-navigation-3.0.0-no-2-locale-slug-regular-slug.js
@@ -0,0 +1,31 @@
+const SOURCE_TABLE_NAME = 'navigations';
+
+
+module.exports = {
+ async up(knex) {
+ // Get all entries and rewrite directly to the navigation_items table
+ const all = await knex.from(SOURCE_TABLE_NAME).columns('id', 'slug', 'locale').select();
+
+ const run = async () => {
+ await Promise.all(
+ all.map(async (item) => {
+ const { id, slug, locale } = item;
+
+ if (slug && locale && id) {
+ const regex = new RegExp(`-${locale}$`);
+
+ await knex
+ .from(SOURCE_TABLE_NAME)
+ .update({ slug: slug.replace(regex, '') })
+ .where({ id });
+ }
+ })
+ );
+ };
+
+ await strapi.db.transaction(async () => {
+ // Run related id to document id migration
+ await run();
+ });
+ },
+};
diff --git a/migrations/strapi-plugin-navigation-3.0.0-no-3-morph-relation.js b/migrations/strapi-plugin-navigation-3.0.0-no-3-morph-relation.js
new file mode 100644
index 00000000..a26ecd0c
--- /dev/null
+++ b/migrations/strapi-plugin-navigation-3.0.0-no-3-morph-relation.js
@@ -0,0 +1,85 @@
+const SOURCE_TABLE_NAME = "navigations";
+const SOURCE_TABLE_NAME_NAVIGATION_ITEMS = "navigations_items";
+const TARGET_TABLE_NAME = "navigations_items_related_mph";
+const JOIN_TABLE = "navigations_items_master_lnk";
+
+const RELATED_ITEM_SEPARATOR = "$";
+
+module.exports = {
+ async up(knex) {
+ const run = async () => {
+ let hasMorphTable = false;
+
+ await knex.schema.hasTable(TARGET_TABLE_NAME).then((exists) => {
+ hasMorphTable = exists;
+ });
+
+ if (hasMorphTable) {
+ return;
+ }
+
+ await knex.schema.createTable(TARGET_TABLE_NAME, (table) => {
+ table.increments("id");
+ table.integer("navigation_item_id");
+ table.integer("related_id");
+ table.string("related_type");
+ table.string("field");
+ table.float("order");
+ });
+
+ const navigations = await knex
+ .from(SOURCE_TABLE_NAME)
+ .columns("id", "locale")
+ .select();
+
+ for (const navigation of navigations) {
+ const items = await knex(SOURCE_TABLE_NAME_NAVIGATION_ITEMS)
+ .join(
+ JOIN_TABLE,
+ `${JOIN_TABLE}.navigation_item_id`,
+ "=",
+ `${SOURCE_TABLE_NAME_NAVIGATION_ITEMS}.id`
+ )
+ .where(`${JOIN_TABLE}.navigation_id`, navigation.id)
+ .select(
+ `${SOURCE_TABLE_NAME_NAVIGATION_ITEMS}.id`,
+ `${SOURCE_TABLE_NAME_NAVIGATION_ITEMS}.related`
+ );
+
+ for (const item of items) {
+ if (!item.related) {
+ continue;
+ }
+
+ const [uid, documentId] = item.related.split(RELATED_ITEM_SEPARATOR);
+ const repository = uid ? strapi.documents(uid) : undefined;
+ const related =
+ uid && repository
+ ? documentId
+ ? await repository.findOne({
+ documentId,
+ locale: navigation.locale,
+ })
+ : await repository.findFirst({ locale: navigation.locale })
+ : undefined;
+
+ await knex(TARGET_TABLE_NAME).insert({
+ navigation_item_id: item.id,
+ related_id: related.id,
+ related_type: uid,
+ order: 1,
+ });
+ }
+ }
+
+ knex.schema.alterTable(
+ SOURCE_TABLE_NAME_NAVIGATION_ITEMS,
+ function (table) {
+ table.dropColumn("related");
+ }
+ );
+ };
+
+ await strapi.db.transaction(run);
+ },
+};
diff --git a/package.json b/package.json
index 454cf183..c2a434db 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "strapi-plugin-navigation",
- "version": "2.5.4",
+ "version": "3.0.0",
"description": "Strapi - Navigation plugin",
"strapi": {
"name": "navigation",
@@ -12,59 +12,6 @@
"type": "git",
"url": "https://github.com/VirtusLab/strapi-plugin-navigation"
},
- "scripts": {
- "publish:latest": "cd build && npm publish --tag latest",
- "prepublish:latest": "npm run clean && npm run build && node build/setup-package.js",
- "test:unit": "jest --verbose --coverage",
- "test:unit:watch": "jest --watch",
- "build": "tsc",
- "build:dev": "npm run build && cp ./package.json ./build && cd ./build && yarn",
- "clean": "rm -rf build",
- "develop": "nodemon --exec \"npm run build:dev\""
- },
- "types": "./types/index.d.ts",
- "dependencies": {
- "@sindresorhus/slugify": "1.1.0",
- "@strapi/utils": "^4.25.11",
- "lodash": "^4.17.21",
- "pluralize": "^8.0.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "react-router": "^5.3.4",
- "react-router-dom": "5.3.4",
- "react-intl": "6.4.1",
- "uuid": "^8.3.0"
- },
- "devDependencies": {
- "@jest/types": "29.5.x",
- "@strapi/admin": "4.25.11",
- "@strapi/helper-plugin": "4.25.11",
- "@types/jest": "29.5.1",
- "@types/koa__router": "^12.0.4",
- "@types/lodash": "^4.14.181",
- "@types/pluralize": "^0.0.29",
- "@types/uuid": "^8.3.4",
- "codecov": "^3.7.2",
- "formik": "^2.2.9",
- "jest": "29.6.0",
- "jest-cli": "^27.5.1",
- "jest-styled-components": "^7.0.2",
- "koa": "^2.8.0",
- "nodemon": "^2.0.15",
- "strapi-plugin-rest-cache": "^4.2.9",
- "strapi-typed": "1.0.20",
- "ts-jest": "29.1.1",
- "ts-node": "^10.7.0",
- "typescript": "5.2.2"
- },
- "peerDependencies": {
- "@strapi/data-transfer": "4.25.11",
- "@strapi/strapi": "4.x",
- "react": "^17.0.0 || ^18.0.0",
- "react-dom": "^17.0.0 || ^18.0.0",
- "react-router-dom": "^5.2.0",
- "styled-components": "^5.2.1"
- },
"author": {
"name": "VirtusLab",
"email": "strapi@virtuslab.com",
@@ -92,20 +39,124 @@
"url": "https://virtuslab.com"
}
],
- "engines": {
- "node": ">=18.0.0 <=20.x.x",
- "npm": ">=6.0.0"
+ "dependencies": {
+ "@faker-js/faker": "^9.0.3",
+ "@sindresorhus/slugify": "1.1.0",
+ "@tanstack/react-query": "^5.40.0",
+ "lodash": "^4.17.21",
+ "pluralize": "8.0.0",
+ "react": "^18.2.0",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
+ "react-dom": "^18.2.0",
+ "react-intl": "6.6.8",
+ "react-router-dom": "^6.22.3",
+ "uuid": "^10.0.0",
+ "zod": "^3.22.5"
+ },
+ "devDependencies": {
+ "@jest/types": "29.5.x",
+ "@koa/router": "^12.0.1",
+ "@sensinum/strapi-utils": "^1.0.4",
+ "@strapi/design-system": "2.0.0-rc.14",
+ "@strapi/icons": "2.0.0-rc.14",
+ "@strapi/plugin-graphql": "^5.5.1",
+ "@strapi/sdk-plugin": "^5.2.8",
+ "@strapi/strapi": "^5.5.1",
+ "@strapi/types": "^5.5.1",
+ "@strapi/typescript-utils": "^5.5.1",
+ "@types/jest": "^29.5.12",
+ "@types/koa": "^2.15.0",
+ "@types/koa-bodyparser": "^4.3.12",
+ "@types/koa__router": "^12.0.4",
+ "@types/lodash": "^4.17.4",
+ "@types/node": "^20.12.0",
+ "@types/pluralize": "0.0.33",
+ "@types/react": "^18.3.8",
+ "@types/react-dom": "^18.3.0",
+ "@types/react-router-dom": "5.3.3",
+ "@types/styled-components": "5.1.34",
+ "@types/uuid": "^10.0.0",
+ "codecov": "^3.7.2",
+ "formik": "^2.2.9",
+ "husky": "7.0.4",
+ "jest": "^29.7.0",
+ "jest-cli": "^29.7.0",
+ "jest-junit": "^16.0.0",
+ "jest-styled-components": "^7.1.1",
+ "koa": "^2.15.3",
+ "lodash": "^4.17.21",
+ "nodemon": "^2.0.15",
+ "prettier": "^3.3.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-intl": "^6.6.8",
+ "react-query": "3.39.3",
+ "react-router-dom": "^6.26.2",
+ "strapi-plugin-rest-cache": "^4.2.9",
+ "styled-components": "^6.1.13",
+ "ts-jest": "^29.1.4",
+ "ts-node": "^10.9.1",
+ "typescript": "^5.6.2"
+ },
+ "peerDependencies": {
+ "@strapi/sdk-plugin": "^5.2.8",
+ "@strapi/strapi": "^5.5.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.26.2",
+ "styled-components": "^6.1.13"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "yarn format && yarn test:unit"
+ }
+ },
+ "exports": {
+ "./package.json": "./package.json",
+ "./strapi-admin": {
+ "types": "./dist/admin/src/index.d.ts",
+ "source": "./admin/src/index.ts",
+ "import": "./dist/admin/index.mjs",
+ "require": "./dist/admin/index.js",
+ "default": "./dist/admin/index.js"
+ },
+ "./strapi-server": {
+ "types": "./dist/server/src/index.d.ts",
+ "source": "./server/src/index.ts",
+ "import": "./dist/server/index.mjs",
+ "require": "./dist/server/index.js",
+ "default": "./dist/server/index.js"
+ }
},
- "nodemonConfig": {
- "ignore": [
- "./build/**/*"
- ],
- "ext": "js,json,ts"
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "prepare": "husky install",
+ "publish:latest": "npm publish --tag latest",
+ "publish:beta": "npm publish --tag beta",
+ "build": "yarn clean && strapi-plugin build",
+ "clean": "rm -rf dist",
+ "lint": "prettier --check .",
+ "format": "prettier --write .",
+ "test:ts:back": "run -T tsc -p server/tsconfig.json",
+ "test:ts:front": "run -T tsc -p admin/tsconfig.json",
+ "test:unit": "jest --coverage",
+ "test:unit:watch": "jest --watch",
+ "test:unit:ci": "CI=true jest --ci --runInBand --verbose --coverage",
+ "verify": "strapi-plugin verify",
+ "watch": "strapi-plugin watch",
+ "watch:link": "strapi-plugin watch:link"
},
"keywords": [
"strapi",
"plugin",
"navigation"
],
+ "engines": {
+ "node": ">=18.0.0 <=22.x.x",
+ "npm": ">=6.0.0"
+ },
"license": "MIT"
}
diff --git a/server/bootstrap/index.ts b/server/bootstrap/index.ts
deleted file mode 100755
index cdd427bf..00000000
--- a/server/bootstrap/index.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import permissions from "../../permissions";
-import {
- IConfigSetupStrategy,
- IGraphQLSetupStrategy,
- INavigationSetupStrategy,
- IRestCacheSetupStrategy,
-} from "../../types";
-import { graphQLSetupStrategy } from "../graphql";
-import { navigationSetupStrategy } from "../navigation";
-import { configSetupStrategy } from "../config";
-import { setupCacheStrategy } from "../cache";
-
-export = async ({ strapi }: StrapiContext) => {
- assertUserPermissionsAvailability({ strapi });
-
- await setupPermissions({ strapi });
- await setupConfig({ strapi });
- await setupGraphQL({ strapi });
- await setupNavigation({ strapi });
- await setupCache({ strapi });
-};
-
-const assertUserPermissionsAvailability = ({ strapi }: StrapiContext) => {
- if (!strapi.plugin("users-permissions")) {
- throw new Error(
- "In order to make the navigation plugin work the users-permissions plugin is required"
- );
- }
-};
-const setupGraphQL: IGraphQLSetupStrategy = graphQLSetupStrategy;
-const setupNavigation: INavigationSetupStrategy = navigationSetupStrategy;
-const setupConfig: IConfigSetupStrategy = configSetupStrategy;
-const setupCache: IRestCacheSetupStrategy = setupCacheStrategy;
-const setupPermissions = async ({ strapi }: StrapiContext) => {
- // Add permissions
- const actions = [
- {
- section: "plugins",
- displayName: "Read",
- uid: permissions.navigation.read,
- pluginName: "navigation",
- },
- {
- section: "plugins",
- displayName: "Update",
- uid: permissions.navigation.update,
- pluginName: "navigation",
- },
- {
- section: "plugins",
- displayName: "Settings",
- uid: permissions.navigation.settings,
- pluginName: "navigation",
- },
- ];
- await strapi.admin.services.permission.actionProvider.registerMany(actions);
-};
diff --git a/server/cache/__tests__/serviceEnhancers.test.ts b/server/cache/__tests__/serviceEnhancers.test.ts
deleted file mode 100644
index 91a4e29c..00000000
--- a/server/cache/__tests__/serviceEnhancers.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { IStrapi } from "strapi-typed";
-import { addCacheConfigFields } from "../serviceEnhancers";
-
-describe("Cache", () => {
- describe("Enhancers", () => {
- describe("addCacheConfigFields()", () => {
- it("should return config fields", async () => {
- // Given
- const cachePlugin = {};
- const pluginStore = { get: jest.fn() };
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- store() {
- return pluginStore;
- },
- } as unknown as IStrapi;
- const previousConfig = {
- foo: "bar",
- };
-
- pluginStore.get.mockReturnValue({ isCacheEnabled: true });
-
- // When
- const result = await addCacheConfigFields({ strapi, previousConfig });
-
- // Then
- expect(result).toMatchInlineSnapshot(`
- Object {
- "foo": "bar",
- "isCacheEnabled": true,
- "isCachePluginEnabled": true,
- }
- `);
- });
- });
- });
-});
diff --git a/server/cache/__tests__/utils.test.ts b/server/cache/__tests__/utils.test.ts
deleted file mode 100644
index b6685bce..00000000
--- a/server/cache/__tests__/utils.test.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { IStrapi } from "strapi-typed";
-import { getCacheStatus } from "../utils";
-
-describe("Cache", () => {
- describe("Utils", () => {
- describe("getCacheStatus()", () => {
- it("should mark cache as disabled if plugin is not installed", async () => {
- // Given
- const cachePlugin = null;
- const pluginStore = { get: jest.fn() };
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- store() {
- return pluginStore;
- },
- } as unknown as IStrapi;
-
- // When
- const result = await getCacheStatus({ strapi });
-
- // Then
- expect(result).toMatchInlineSnapshot(`
- Object {
- "enabled": false,
- "hasCachePlugin": false,
- }
- `);
- });
-
- it("should mark cache as enabled if it is enabled in config", async () => {
- // Given
- const cachePlugin = {};
- const pluginStore = { get: jest.fn() };
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- store() {
- return pluginStore;
- },
- } as unknown as IStrapi;
-
- pluginStore.get.mockReturnValue({ isCacheEnabled: true });
-
- // When
- const result = await getCacheStatus({ strapi });
-
- // Then
- expect(result).toMatchInlineSnapshot(`
- Object {
- "enabled": true,
- "hasCachePlugin": true,
- }
- `);
- });
-
- it("should mark cache as enabled if it is enabled in config", async () => {
- // Given
- const cachePlugin = {};
- const pluginStore = { get: jest.fn() };
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- store() {
- return pluginStore;
- },
- } as unknown as IStrapi;
-
- pluginStore.get.mockReturnValue({ isCacheEnabled: false });
-
- // When
- const result = await getCacheStatus({ strapi });
-
- // Then
- expect(result).toMatchInlineSnapshot(`
- Object {
- "enabled": false,
- "hasCachePlugin": true,
- }
- `);
- });
- });
- });
-});
diff --git a/server/cache/index.ts b/server/cache/index.ts
deleted file mode 100644
index 871478ea..00000000
--- a/server/cache/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./setupStrategy";
diff --git a/server/cache/serviceEnhancers.ts b/server/cache/serviceEnhancers.ts
deleted file mode 100644
index 0767e924..00000000
--- a/server/cache/serviceEnhancers.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { AddCacheConfigFieldsInput, CacheConfigFields } from "./types";
-import { getCacheStatus } from "./utils";
-
-export const addCacheConfigFields = async ({
- previousConfig,
- strapi,
-}: AddCacheConfigFieldsInput): Promise => {
- const { enabled, hasCachePlugin } = await getCacheStatus({
- strapi,
- });
-
- return {
- ...previousConfig,
- isCacheEnabled: enabled,
- isCachePluginEnabled: hasCachePlugin,
- };
-};
diff --git a/server/cache/setupStrategy.ts b/server/cache/setupStrategy.ts
deleted file mode 100644
index 3360f7b8..00000000
--- a/server/cache/setupStrategy.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import Router from "@koa/router";
-import { IRestCacheSetupStrategy, ToBeFixed } from "../../types";
-import clientRoutes from "../routes/client";
-import { getCacheStatus } from "./utils";
-
-export const setupCacheStrategy: IRestCacheSetupStrategy = async ({
- strapi,
-}) => {
- const { enabled, hasCachePlugin } = await getCacheStatus({ strapi });
- if (hasCachePlugin && enabled) {
- const cachePlugin: ToBeFixed = strapi.plugin("rest-cache");
- const createCacheMiddleware = cachePlugin?.middleware("recv");
-
- if (!createCacheMiddleware) {
- console.warn("Cache middleware not present in cache plugin. Stopping");
- console.warn("Notify strapi-navigation-plugin-team");
- return;
- }
-
- const pluginOption: ToBeFixed = strapi.config.get("plugin.rest-cache");
- const router = new Router();
-
- const buildPathFrom = (route: ToBeFixed) =>
- `/api/${route.info.pluginName}${route.path}`;
- const buildFrom = (route: ToBeFixed) => ({
- maxAge: pluginOption.strategy?.maxAge ?? 6 * 60 * 1000,
- path: buildPathFrom(route),
- method: "GET",
- paramNames: ["idOrSlug", "childUIKey"],
- keys: { useHeaders: [], useQueryParams: true },
- hitpass: false,
- });
-
- clientRoutes.routes.forEach((route) => {
- router.get(
- buildPathFrom(route),
- createCacheMiddleware(
- { cacheRouteConfig: buildFrom(route) },
- { strapi }
- )
- );
- });
-
- (strapi as ToBeFixed).server.router.use(router.routes());
- }
-};
diff --git a/server/cache/types.ts b/server/cache/types.ts
deleted file mode 100644
index f19a6f20..00000000
--- a/server/cache/types.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { IStrapi } from "strapi-typed";
-
-export type AddCacheConfigFieldsInput = {
- previousConfig: T;
- strapi: IStrapi;
- viaSettingsPage?: boolean;
-};
-
-export type CacheConfigFields = {
- isCacheEnabled: boolean;
- isCachePluginEnabled: boolean;
-};
diff --git a/server/cache/utils.ts b/server/cache/utils.ts
deleted file mode 100644
index 00d20538..00000000
--- a/server/cache/utils.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { IStrapi } from "strapi-typed";
-import { NavigationPluginConfig } from "../../types/config";
-import { ToBeFixed } from "../../types";
-
-type GetCacheStatusInput = {
- strapi: IStrapi;
-};
-
-type CacheStatus = {
- hasCachePlugin: boolean;
- enabled: boolean;
-};
-
-export const getCacheStatus = async ({
- strapi,
-}: GetCacheStatusInput): Promise => {
- const cachePlugin: null | ToBeFixed = strapi.plugin("rest-cache");
- const hasCachePlugin = !!cachePlugin;
- const pluginStore = strapi.store({
- type: "plugin",
- name: "navigation",
- });
-
- const config: NavigationPluginConfig = await pluginStore.get({
- key: "config",
- });
-
- return hasCachePlugin
- ? { hasCachePlugin, enabled: config.isCacheEnabled }
- : { hasCachePlugin, enabled: false };
-};
diff --git a/server/config/setupStrategy.ts b/server/config/setupStrategy.ts
deleted file mode 100644
index 14582504..00000000
--- a/server/config/setupStrategy.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import {
- assertNotEmpty,
- IConfigSetupStrategy,
- NavigationItemAdditionalField,
- NavigationPluginConfig,
- PluginConfigGraphQL,
- PluginConfigKeys,
- PluginConfigNameFields,
- PluginConfigPathDefaultFields,
- PluginConfigPopulate,
- PluginDefaultConfigGetter,
-} from "../../types";
-import { resolveGlobalLikeId, validateAdditionalFields } from "../utils";
-
-export const configSetupStrategy: IConfigSetupStrategy = async ({ strapi }) => {
- const pluginStore = strapi.store({
- type: "plugin",
- name: "navigation",
- });
- const getFromPluginDefaults: PluginDefaultConfigGetter = await strapi.plugin(
- "navigation"
- ).config;
- const hasI18nPlugin: boolean = !!strapi.plugin("i18n");
- // TODO: Mark config from store as Partial
- let config: NavigationPluginConfig = await pluginStore.get({
- key: "config",
- });
- const getWithFallback = getWithFallbackFactory(config, getFromPluginDefaults);
-
- config = {
- additionalFields:
- getWithFallback("additionalFields"),
- contentTypes: getWithFallback("contentTypes"),
- contentTypesNameFields: getWithFallback(
- "contentTypesNameFields"
- ),
- contentTypesPopulate: getWithFallback(
- "contentTypesPopulate"
- ),
- allowedLevels: getWithFallback("allowedLevels"),
- gql: getWithFallback("gql"),
- i18nEnabled: hasI18nPlugin && getWithFallback("i18nEnabled"),
- pruneObsoleteI18nNavigations: false,
- pathDefaultFields:
- getWithFallback("pathDefaultFields"),
- cascadeMenuAttached: getWithFallback("cascadeMenuAttached"),
- isCacheEnabled: getWithFallback("isCacheEnabled"),
- preferCustomContentTypes: getWithFallback("isCacheEnabled"),
- };
-
- handleDeletedContentTypes(config, { strapi });
-
- validateAdditionalFields(config.additionalFields);
-
- await pluginStore.set({
- key: "config",
- value: config,
- });
-
- return config;
-};
-
-const getWithFallbackFactory =
- (config: NavigationPluginConfig, fallback: PluginDefaultConfigGetter) =>
- >(key: PluginConfigKeys) => {
- const value = config?.[key] ?? fallback(key);
-
- assertNotEmpty(
- value,
- new Error(`[Navigation] Config "${key}" is undefined`)
- );
-
- return value as T;
- };
-
-const handleDeletedContentTypes = (
- config: NavigationPluginConfig,
- { strapi }: StrapiContext
-): void => {
- const notAvailableContentTypes = config.contentTypes.filter(
- (contentType) => !strapi.contentTypes[contentType]
- );
-
- if (notAvailableContentTypes.length === 0) {
- return;
- }
-
- const notAvailableContentTypesGraphQL =
- notAvailableContentTypes.map(resolveGlobalLikeId);
-
- config.contentTypes = config.contentTypes.filter(
- (contentType) => !notAvailableContentTypes.includes(contentType)
- );
-
- config.contentTypesNameFields = Object.fromEntries(
- Object.entries(config.contentTypesNameFields).filter(
- ([contentType]) => !notAvailableContentTypes.includes(contentType)
- )
- );
-
- config.gql.navigationItemRelated = config.gql.navigationItemRelated.filter(
- (contentType) => !notAvailableContentTypesGraphQL.includes(contentType)
- );
-};
diff --git a/server/content-types/index.ts b/server/content-types/index.ts
deleted file mode 100644
index 37677297..00000000
--- a/server/content-types/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-"use strict"
-
-import navigationsItemsRelated from "./navigations-items-related";
-import navigationItem from "./navigation-item";
-import navigation from "./navigation";
-import audience from "./audience";
-
-export default {
- audience,
- navigation,
- "navigation-item": navigationItem,
- "navigations-items-related": navigationsItemsRelated
-};
diff --git a/server/content-types/navigation-item/lifecycles.ts b/server/content-types/navigation-item/lifecycles.ts
deleted file mode 100644
index 21197517..00000000
--- a/server/content-types/navigation-item/lifecycles.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import { buildAllHookListeners } from "../../utils";
-
-export default buildAllHookListeners("navigation-item", {
- strapi,
-} as unknown as StrapiContext);
diff --git a/server/content-types/navigation-item/schema.ts b/server/content-types/navigation-item/schema.ts
deleted file mode 100644
index abb44e38..00000000
--- a/server/content-types/navigation-item/schema.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-export default {
- collectionName: "navigations_items",
- info: {
- singularName: "navigation-item",
- pluralName: "navigation-items",
- displayName: "Navigation Item",
- name: "navigation-item"
- },
- options: {
- increments: true,
- timestamps: true,
- comment: "Navigation Item"
- },
- pluginOptions: {
- "content-manager": {
- visible: false
- },
- "content-type-builder": {
- visible: false
- },
- i18n: {
- localized: false
- }
- },
- attributes: {
- title: {
- type: "text",
- configurable: false,
- required: true,
- pluginOptions: {
- i18n: {
- localized: false
- }
- }
- },
- type: {
- type: "enumeration",
- enum: [
- "INTERNAL",
- "EXTERNAL",
- "WRAPPER"
- ],
- default: "INTERNAL",
- configurable: false
- },
- path: {
- type: "text",
- targetField: "title",
- configurable: false
- },
- externalPath: {
- type: "text",
- configurable: false
- },
- uiRouterKey: {
- type: "string",
- configurable: false
- },
- menuAttached: {
- type: "boolean",
- default: false,
- configurable: false
- },
- order: {
- type: "integer",
- default: 0,
- configurable: false
- },
- collapsed: {
- type: "boolean",
- default: false,
- configurable: false
- },
- autoSync: {
- type: "boolean",
- default: true,
- configurable: false
- },
- related: {
- type: "relation",
- relation: "oneToOne",
- target: "plugin::navigation.navigations-items-related",
- configurable: false
- },
- parent: {
- type: "relation",
- relation: "oneToOne",
- target: "plugin::navigation.navigation-item",
- configurable: false,
- default: null
- },
- master: {
- type: "relation",
- relation: "manyToOne",
- target: "plugin::navigation.navigation",
- configurable: false,
- inversedBy: "items",
- },
- audience: {
- type: "relation",
- relation: "oneToMany",
- target: "plugin::navigation.audience"
- },
- additionalFields: {
- type: "json",
- require: false,
- default: {},
- }
- }
-}
diff --git a/server/content-types/navigation/lifecycles.ts b/server/content-types/navigation/lifecycles.ts
deleted file mode 100644
index ef8d9fdf..00000000
--- a/server/content-types/navigation/lifecycles.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import { buildAllHookListeners } from "../../utils";
-
-export default buildAllHookListeners("navigation", {
- strapi,
-} as unknown as StrapiContext);
diff --git a/server/content-types/navigation/schema.ts b/server/content-types/navigation/schema.ts
deleted file mode 100644
index b96b38bc..00000000
--- a/server/content-types/navigation/schema.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-export default {
- collectionName: "navigations",
- info: {
- singularName: "navigation",
- pluralName: "navigations",
- displayName: "Navigation",
- name: "navigation"
- },
- options: {
- increments: true,
- comment: ""
- },
- pluginOptions: {
- "content-manager": {
- visible: false
- },
- "content-type-builder": {
- visible: false
- }
- },
- attributes: {
- name: {
- type: "text",
- configurable: false,
- required: true
- },
- slug: {
- type: "uid",
- target: "name",
- configurable: false,
- required: true
- },
- visible: {
- type: "boolean",
- default: false,
- configurable: false
- },
- items: {
- type: "relation",
- relation: "oneToMany",
- target: "plugin::navigation.navigation-item",
- configurable: false,
- mappedBy: "master"
- },
- localizations: {
- type: "relation",
- relation: "oneToMany",
- target: "plugin::navigation.navigation",
- },
- localeCode: {
- type: "string"
- }
- }
-}
diff --git a/server/content-types/navigations-items-related/index.ts b/server/content-types/navigations-items-related/index.ts
deleted file mode 100644
index a59ffbe4..00000000
--- a/server/content-types/navigations-items-related/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-"use strict"
-
-import schema from "./schema";
-
-export default {
- schema
-};
diff --git a/server/content-types/navigations-items-related/schema.ts b/server/content-types/navigations-items-related/schema.ts
deleted file mode 100644
index 44929155..00000000
--- a/server/content-types/navigations-items-related/schema.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-export default {
- collectionName: "navigations_items_related",
- info: {
- singularName: "navigations-items-related",
- pluralName: "navigations-items-relateds",
- displayName: "Navigations Items Related",
- name: "navigations_items_related"
- },
- options: {
- increments: true,
- timestamps: false,
- populateCreatorFields: false
- },
- pluginOptions: {
- "content-manager": {
- visible: false
- },
- "content-type-builder": {
- visible: false
- },
- i18n: {
- localized: false
- }
- },
- attributes: {
- related_id: {
- type: "string",
- required: true
- },
- related_type: {
- type: "string",
- required: true
- },
- field: {
- type: "string",
- required: true
- },
- order: {
- type: "integer",
- required: true
- },
- master: {
- type: "string",
- required: true
- }
- }
-}
diff --git a/server/controllers/admin.ts b/server/controllers/admin.ts
deleted file mode 100644
index 8c693039..00000000
--- a/server/controllers/admin.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-// @ts-ignore
-import { errors } from "@strapi/utils"
-import {
- assertIsNumber,
- assertNotEmpty,
- ToBeFixed,
-} from "../../types";
-import { getPluginService, parseParams } from "../utils";
-import { errorHandler } from "../utils";
-import { IAdminController } from "../../types";
-import { Id, IStrapi, StringMap } from "strapi-typed";
-import { InvalidParamNavigationError } from "../../utils/InvalidParamNavigationError";
-import { NavigationError } from "../../utils/NavigationError";
-import { getCacheStatus } from "../cache/utils";
-
-const adminControllers: IAdminController = {
- getAdminService() {
- return getPluginService("admin");
- },
- getCommonService() {
- return getPluginService("common");
- },
- async get() {
- return await this.getAdminService().get();
- },
- post(ctx) {
- const { auditLog } = ctx;
- const { body = {} } = ctx.request;
- return this.getAdminService().post(body, auditLog);
- },
- put(ctx) {
- const { params, auditLog } = ctx;
- const { id } = parseParams, { id: Id }>(params);
- const { body = {} } = ctx.request;
- return this.getAdminService().put(id, body, auditLog).catch(errorHandler(ctx));
- },
- async delete(ctx) {
- const { params, auditLog } = ctx;
- const { id } = parseParams, { id: Id }>(params);
-
- try {
- assertNotEmpty(id, new InvalidParamNavigationError("Navigation's id is not a id"));
-
- await this.getAdminService().delete(id, auditLog);
-
- return {};
- } catch (error) {
- console.error(error);
-
- if (error instanceof NavigationError) {
- return errorHandler(ctx)(error)
- }
-
- throw error;
- }
- },
- async config() {
- return this.getAdminService().config();
- },
-
- async updateConfig(ctx) {
- try {
- await this.getAdminService().updateConfig(ctx.request.body);
- } catch (e: ToBeFixed) {
- errorHandler(ctx)(e);
- }
- return ctx.send({ status: 200 });
- },
-
- async restoreConfig(ctx) {
- try {
- await this.getAdminService().restoreConfig();
- } catch (e: ToBeFixed) {
- errorHandler(ctx)(e);
- }
- return ctx.send({ status: 200 });
- },
-
- async settingsConfig() {
- return this.getAdminService().config(true);
- },
-
- async settingsRestart(ctx) {
- try {
- await this.getAdminService().restart();
- return ctx.send({ status: 200 });
- } catch (e: ToBeFixed) {
- errorHandler(ctx)(e);
- }
- },
- async getById(ctx) {
- const { params } = ctx;
- const { id } = parseParams, { id: Id }>(params);
- return this.getAdminService().getById(id);
- },
- async getContentTypeItems(ctx) {
- const { params, query = {} } = ctx;
- const { model } = parseParams, { model: string }>(params);
- return this.getCommonService().getContentTypeItems(
- model,
- query
- );
- },
-
- fillFromOtherLocale(ctx) {
- const { params, auditLog } = ctx;
- const { source, target } = parseParams<
- StringMap,
- { source: number; target: number }
- >(params);
-
- try {
- assertCopyParams(source, target);
-
- return this.getAdminService().fillFromOtherLocale({ source, target, auditLog });
- } catch (error) {
- if (error instanceof Error) {
- return ctx.badRequest(error.message)
- }
-
- throw error
- }
- },
-
- async readNavigationItemFromLocale(ctx) {
- const { params, query: { path } } = ctx;
- const { source, target } = parseParams, { source: number, target: number }>(
- params
- );
-
- try {
- assertCopyParams(source, target);
- assertNotEmpty(
- path,
- new InvalidParamNavigationError("Path is missing")
- )
-
- return await this.getAdminService().readNavigationItemFromLocale({
- path,
- source,
- target,
- });
- } catch (error: any) {
- if (error instanceof errors.NotFoundError) {
- return ctx.notFound((error as Error).message, {
- messageKey: "popup.item.form.i18n.locale.error.unavailable"
- });
- }
-
- if (error instanceof Error) {
- return ctx.badRequest(error.message)
- }
-
- throw error
- }
- },
-
- getSlug(ctx) {
- const { query: { q } } = ctx;
-
- try {
- assertNotEmpty(q);
-
- return this.getCommonService().getSlug(q).then((slug) => ({ slug }));
- } catch (error) {
- if (error instanceof Error) {
- return ctx.badRequest(error.message)
- }
-
- throw error
- }
- },
-
- async purgeNavigationsCache() {
- const mappedStrapi = strapi as unknown as IStrapi;
- const { enabled } = await getCacheStatus({ strapi: mappedStrapi });
-
- if (!enabled) {
- return { success: false };
- }
-
- return await this.getAdminService().purgeNavigationsCache();
- },
-
- async purgeNavigationCache(ctx) {
- const { params: { id }, query: { clearLocalisations = 'false' } } = ctx;
- const mappedStrapi = strapi as unknown as IStrapi;
- const { enabled } = await getCacheStatus({ strapi: mappedStrapi });
-
- if (!enabled) {
- return { success: false };
- }
-
- return await this.getAdminService().purgeNavigationCache(id, JSON.parse(clearLocalisations));
- }
-};
-
-const assertCopyParams = (source: unknown, target: unknown) => {
- assertIsNumber(
- source,
- new InvalidParamNavigationError("Source's id is not a number")
- );
- assertIsNumber(
- target,
- new InvalidParamNavigationError("Target's id is not a number")
- );
-}
-
-export default adminControllers;
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
deleted file mode 100644
index 0e712279..00000000
--- a/server/controllers/client.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-//@ts-ignore
-import { errors, sanitize } from "@strapi/utils";
-import { Id, StringMap } from "strapi-typed";
-import { IClientController, ToBeFixed } from "../../types";
-import { getPluginService, parseParams, sanitizePopulateField } from "../utils";
-
-const clientControllers: IClientController = {
- getService() {
- return getPluginService("client");
- },
-
- async readAll(ctx) {
- const { query = {} } = ctx;
- const { locale, orderBy, orderDirection } = query;
-
- try {
- return await this.getService().readAll({
- locale,
- orderBy,
- orderDirection,
- });
- } catch (error: unknown) {
- if (error instanceof Error) {
- return ctx.badRequest(error.message);
- }
-
- throw error;
- }
- },
-
- async render(ctx) {
- const { params, query = {} } = ctx;
- const { type, menu: menuOnly, path: rootPath, locale, populate } = query;
- const { idOrSlug } = parseParams, { idOrSlug: Id }>(
- params
- );
- try {
- return await this.getService().render({
- idOrSlug,
- type,
- menuOnly,
- rootPath,
- locale,
- populate: sanitizePopulateField(populate) as ToBeFixed,
- });
- } catch (error: unknown) {
- if (error instanceof errors.NotFoundError) {
- return ctx.notFound((error as ToBeFixed).message);
- }
-
- if (error instanceof Error) {
- return ctx.badRequest(error.message);
- }
-
- throw error;
- }
- },
- async renderChild(ctx) {
- const { params, query = {} } = ctx;
- const { type, menu: menuOnly, locale } = query;
- const { idOrSlug, childUIKey } = parseParams<
- StringMap,
- { idOrSlug: Id; childUIKey: string }
- >(params);
- try {
- return await this.getService().renderChildren({
- idOrSlug,
- childUIKey,
- type,
- menuOnly,
- locale,
- });
- } catch (error: unknown) {
- if (error instanceof errors.NotFoundError) {
- return ctx.notFound((error as ToBeFixed).message);
- }
-
- if (error instanceof Error) {
- return ctx.badRequest(error.message);
- }
-
- throw error;
- }
- },
-};
-
-export default clientControllers;
diff --git a/server/controllers/index.ts b/server/controllers/index.ts
deleted file mode 100644
index efdc803f..00000000
--- a/server/controllers/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { NavigationController } from '../../types';
-
-import admin from './admin';
-import client from './client';
-
-const controllers: NavigationController = {
- admin,
- client,
-}
-
-export default controllers;
diff --git a/server/destroy.ts b/server/destroy.ts
deleted file mode 100644
index 2a6ddd91..00000000
--- a/server/destroy.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export default () => {
- // Destroy function for strapi-plugin-navigation
-};
diff --git a/server/graphql/config.ts b/server/graphql/config.ts
deleted file mode 100644
index 0c1e7c4c..00000000
--- a/server/graphql/config.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import { getPluginService } from "../utils";
-
-const getTypes = require("./types");
-const getQueries = require("./queries");
-const getResolversConfig = require("./resolvers-config");
-
-export default async ({ strapi }: StrapiContext) => {
- const extensionService = strapi.plugin("graphql").service("extension");
-
- extensionService.shadowCRUD("plugin::navigation.audience").disable();
- extensionService.shadowCRUD("plugin::navigation.navigation").disable();
- extensionService.shadowCRUD("plugin::navigation.navigation-item").disable();
- extensionService
- .shadowCRUD("plugin::navigation.navigations-items-related")
- .disable();
- const commonService = getPluginService('common');
- const pluginStore = await commonService.getPluginStore()
- const config = await pluginStore.get({ key: 'config' });
-
- extensionService.use(({ strapi, nexus }: any) => {
- const types = getTypes({ strapi, nexus, config });
- const queries = getQueries({ strapi, nexus });
- const resolversConfig = getResolversConfig({ strapi });
-
- return {
- types: [types, queries],
- resolversConfig,
- };
- });
-};
diff --git a/server/graphql/index.js b/server/graphql/index.js
deleted file mode 100644
index 871478ea..00000000
--- a/server/graphql/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./setupStrategy";
diff --git a/server/graphql/queries/index.js b/server/graphql/queries/index.js
deleted file mode 100644
index a678bca8..00000000
--- a/server/graphql/queries/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-module.exports = (context) => {
- const queries = {
- renderNavigationChild: require('./render-navigation-child'),
- renderNavigation: require('./render-navigation'),
- }
-
- return context.nexus.extendType({
- type: 'Query',
- definition(t) {
- for (const [name, configFactory] of Object.entries(queries)) {
- const config = configFactory(context);
-
- t.field(name, config);
- }
- },
- });
-};
diff --git a/server/graphql/queries/render-navigation-child.js b/server/graphql/queries/render-navigation-child.js
deleted file mode 100644
index 0592008c..00000000
--- a/server/graphql/queries/render-navigation-child.js
+++ /dev/null
@@ -1,24 +0,0 @@
-const { getPluginService } = require("../../utils");
-
-module.exports = ({ strapi, nexus }) => {
- const { nonNull, list, stringArg, booleanArg } = nexus;
- return {
- type: nonNull(list("NavigationItem")),
- args: {
- id: nonNull(stringArg()),
- childUiKey: nonNull(stringArg()),
- type: "NavigationRenderType",
- menuOnly: booleanArg(),
- },
- resolve(obj, args) {
- const { id, childUIKey, type, menuOnly } = args;
- return getPluginService('client').renderChildren({
- idOrSlug: id,
- childUIKey,
- type,
- menuOnly,
- wrapRelated: true,
- });
- },
- };
-}
diff --git a/server/graphql/queries/render-navigation.js b/server/graphql/queries/render-navigation.js
deleted file mode 100644
index 3e29b0ca..00000000
--- a/server/graphql/queries/render-navigation.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const { addI18NRenderNavigationArgs } = require("../../i18n");
-const { getPluginService } = require("../../utils");
-
-module.exports = ({ strapi, nexus }) => {
- const { nonNull, list, stringArg, booleanArg } = nexus;
- const defaultArgs = {
- navigationIdOrSlug: nonNull(stringArg()),
- type: "NavigationRenderType",
- menuOnly: booleanArg(),
- path: stringArg(),
- };
- const hasI18nPlugin = !!strapi.plugin("i18n");
- const args = hasI18nPlugin ? addI18NRenderNavigationArgs({
- previousArgs: defaultArgs,
- nexus,
- }) : defaultArgs;
-
- return {
- args,
- type: nonNull(list("NavigationItem")),
- resolve(
- obj,
- { navigationIdOrSlug: idOrSlug, type, menuOnly, path: rootPath, locale }
- ) {
- return getPluginService('client').render({
- idOrSlug,
- type,
- rootPath,
- locale,
- menuOnly,
- wrapRelated: true
- })
- },
- };
-};
diff --git a/server/graphql/resolvers-config.js b/server/graphql/resolvers-config.js
deleted file mode 100644
index 53522db0..00000000
--- a/server/graphql/resolvers-config.js
+++ /dev/null
@@ -1,4 +0,0 @@
-module.exports = ({ }) => ({
- 'Query.renderNavigationChild': { auth: false },
- 'Query.renderNavigation': { auth: false },
-});
diff --git a/server/graphql/setupStrategy.ts b/server/graphql/setupStrategy.ts
deleted file mode 100644
index 45f0d7af..00000000
--- a/server/graphql/setupStrategy.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { IGraphQLSetupStrategy } from "../../types";
-import handleConfig from "./config";
-
-export const graphQLSetupStrategy: IGraphQLSetupStrategy = async ({
- strapi,
-}) => {
- const hasGraphQLPlugin = !!strapi.plugin("graphql");
-
- if (hasGraphQLPlugin) {
- await handleConfig({ strapi });
- }
-};
diff --git a/server/graphql/types/content-types-name-fields.js b/server/graphql/types/content-types-name-fields.js
deleted file mode 100644
index 1e26da0e..00000000
--- a/server/graphql/types/content-types-name-fields.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const { getPluginService } = require("../../utils");
-
-module.exports = ({ nexus, strapi }) => nexus.objectType({
- name: "ContentTypesNameFields",
- async definition(t) {
- t.nonNull.list.nonNull.string("default");
- const commonService = getPluginService('common');
- const pluginStore = await commonService.getPluginStore();
- const config = await pluginStore.get({ key: 'config' });
- const contentTypesNameFields = config.contentTypesNameFields;
- Object.keys(contentTypesNameFields || {}).forEach(key => t.nonNull.list.string(key))
- }
-})
\ No newline at end of file
diff --git a/server/graphql/types/content-types.js b/server/graphql/types/content-types.js
deleted file mode 100644
index e39506d4..00000000
--- a/server/graphql/types/content-types.js
+++ /dev/null
@@ -1,16 +0,0 @@
-module.exports = ({ nexus }) => nexus.objectType({
- name: "ContentTypes",
- definition(t) {
- t.nonNull.string("uid")
- t.nonNull.string("name")
- t.nonNull.boolean("isSingle")
- t.nonNull.string("collectionName")
- t.nonNull.string("contentTypeName")
- t.nonNull.string("label")
- t.nonNull.string("relatedField")
- t.nonNull.string("labelSingular")
- t.nonNull.string("endpoint")
- t.nonNull.boolean("available")
- t.nonNull.boolean("visible")
- }
-})
\ No newline at end of file
diff --git a/server/graphql/types/create-navigation-item.js b/server/graphql/types/create-navigation-item.js
deleted file mode 100644
index 95b298cb..00000000
--- a/server/graphql/types/create-navigation-item.js
+++ /dev/null
@@ -1,17 +0,0 @@
-module.exports = ({ nexus }) => nexus.inputObjectType({
- name: "CreateNavigationItem",
- definition(t) {
- t.nonNull.string("title")
- t.nonNull.string("type")
- t.string("path")
- t.string("externalPath")
- t.nonNull.string("uiRouterKey")
- t.nonNull.boolean("menuAttached")
- t.nonNull.int("order")
- t.int("parent")
- t.int("master")
- t.list.field("items", { type: 'CreateNavigationItem' })
- t.list.string("audience")
- t.field("related", { type: 'CreateNavigationRelated' })
- }
-});
\ No newline at end of file
diff --git a/server/graphql/types/create-navigation-related.js b/server/graphql/types/create-navigation-related.js
deleted file mode 100644
index 3cf37e66..00000000
--- a/server/graphql/types/create-navigation-related.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = ({ nexus }) => nexus.inputObjectType({
- name: "CreateNavigationRelated",
- definition(t) {
- t.nonNull.string("ref")
- t.nonNull.string("field")
- t.nonNull.string("refId")
- }
-});
\ No newline at end of file
diff --git a/server/graphql/types/create-navigation.js b/server/graphql/types/create-navigation.js
deleted file mode 100644
index 4b7a2c9c..00000000
--- a/server/graphql/types/create-navigation.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = ({ nexus }) => nexus.inputObjectType({
- name: "CreateNavigation",
- definition(t) {
- t.nonNull.string("name")
- t.nonNull.list.field("items", { type: 'CreateNavigationItem' })
- }
-});
\ No newline at end of file
diff --git a/server/graphql/types/index.js b/server/graphql/types/index.js
deleted file mode 100644
index ecef3c46..00000000
--- a/server/graphql/types/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const typesFactories = [
- require('./navigation-item-additional-field-media'),
- require('./navigation-item-related'),
- require('./navigation-item-related-data'),
- require('./navigation-item'),
- require('./navigation-render-type'),
- require('./navigation'),
- require('./navigation-details'),
- require('./content-types-name-fields'),
- require('./content-types'),
- require('./navigation-config'),
- require('./create-navigation-related'),
- require('./create-navigation-item'),
- require('./create-navigation'),
-];
-
-module.exports = context => typesFactories.map(factory => factory(context));
\ No newline at end of file
diff --git a/server/graphql/types/navigation-config.js b/server/graphql/types/navigation-config.js
deleted file mode 100644
index aa8272d8..00000000
--- a/server/graphql/types/navigation-config.js
+++ /dev/null
@@ -1,9 +0,0 @@
-module.exports = ({ nexus }) => nexus.objectType({
- name: "NavigationConfig",
- definition(t) {
- t.int("allowedLevels");
- t.nonNull.list.string("additionalFields");
- t.field("contentTypesNameFields", { type: 'ContentTypesNameFields' });
- t.list.field("contentTypes", { type: 'ContentTypes' });
- }
-})
\ No newline at end of file
diff --git a/server/graphql/types/navigation-item-additional-field-media.js b/server/graphql/types/navigation-item-additional-field-media.js
deleted file mode 100644
index cdcac3a5..00000000
--- a/server/graphql/types/navigation-item-additional-field-media.js
+++ /dev/null
@@ -1,11 +0,0 @@
-module.exports = ({ nexus }) => nexus.objectType({
- name: "NavigationItemAdditionalFieldMedia",
- definition(t) {
- t.nonNull.string("name")
- t.nonNull.string("url")
- t.nonNull.string("mime")
- t.nonNull.int("width")
- t.nonNull.int("height")
- t.string("previewUrl")
- }
-});
\ No newline at end of file
diff --git a/server/graphql/types/navigation-item-related-data.js b/server/graphql/types/navigation-item-related-data.js
deleted file mode 100644
index f104cd2f..00000000
--- a/server/graphql/types/navigation-item-related-data.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = ({ nexus }) =>
- nexus.objectType({
- name: "NavigationItemRelatedData",
- definition(t) {
- t.nonNull.int("id")
- t.field("attributes", { type: 'NavigationItemRelated' })
- }
- });
\ No newline at end of file
diff --git a/server/graphql/types/navigation-item.js b/server/graphql/types/navigation-item.js
deleted file mode 100644
index 462f02a7..00000000
--- a/server/graphql/types/navigation-item.js
+++ /dev/null
@@ -1,58 +0,0 @@
-module.exports = ({ nexus, config }) =>
- nexus.objectType({
- name: "NavigationItem",
- definition(t) {
- t.nonNull.int("id");
- t.nonNull.string("title");
- t.nonNull.string("type");
- t.string("path");
- t.string("externalPath");
- t.nonNull.string("uiRouterKey");
- t.nonNull.boolean("menuAttached");
- t.nonNull.int("order");
- t.field("parent", { type: "NavigationItem" })
- t.int("master");
- t.list.field("items", { type: 'NavigationItem' });
- t.field("related", { type: 'NavigationItemRelatedData' });
-
- // SQL
- t.string("created_at");
- t.string("updated_at");
- t.string("created_by");
- t.string("updated_by");
- // MONGO
- t.string("createdAt");
- t.string("updatedAt");
- t.string("createdBy");
- t.string("updatedBy");
-
- // Additional Fields
- config.additionalFields.forEach(field => {
- if (field !== 'audience') {
- if (field.enabled) {
- switch (field.type) {
- case 'media':
- t.field(field.name, { type: "NavigationItemAdditionalFieldMedia" });
- break;
- case 'string':
- t.string(field.name);
- break;
- case 'boolean':
- t.boolean(field.name);
- break;
- case 'select':
- if (field.multi)
- t.list.string(field.name);
- else
- t.string(field.name);
- break;
- default:
- throw new Error(`Type "${JSON.stringify(field.type)}" is unsupported by custom fields`);
- }
- }
- } else {
- t.list.string("audience");
- }
- });
- }
- });
diff --git a/server/graphql/types/navigation-render-type.js b/server/graphql/types/navigation-render-type.js
deleted file mode 100644
index feb6d3aa..00000000
--- a/server/graphql/types/navigation-render-type.js
+++ /dev/null
@@ -1,6 +0,0 @@
-const { RENDER_TYPES } = require("../../utils");
-
-module.exports = ({nexus}) => nexus.enumType({
- name: "NavigationRenderType",
- members: Object.values(RENDER_TYPES),
-});
\ No newline at end of file
diff --git a/server/graphql/types/navigation.js b/server/graphql/types/navigation.js
deleted file mode 100644
index 268e71e6..00000000
--- a/server/graphql/types/navigation.js
+++ /dev/null
@@ -1,9 +0,0 @@
-module.exports = ({ nexus }) => nexus.objectType({
- name: "Navigation",
- definition(t) {
- t.nonNull.string("id")
- t.nonNull.string("name")
- t.nonNull.string("slug")
- t.nonNull.boolean("visible")
- }
-})
\ No newline at end of file
diff --git a/server/i18n/__tests__/utils.test.ts b/server/i18n/__tests__/utils.test.ts
deleted file mode 100644
index 6ce23bc5..00000000
--- a/server/i18n/__tests__/utils.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { StrapiContext } from "strapi-typed";
-import { ToBeFixed } from "../../../types";
-import { getI18nStatus } from "../utils";
-
-describe("i18n", () => {
- describe("Utils", () => {
- describe("getI18nStatus()", () => {
- it("should check if i18n plugin is installed", async () => {
- // Given
- const store: ToBeFixed = () => ({
- get() {
- return {};
- },
- });
- const strapi: Partial = {
- store,
- plugin() {
- return null as ToBeFixed;
- },
- };
-
- // When
- const result = await getI18nStatus({
- strapi: strapi as StrapiContext["strapi"],
- });
-
- // Given
- expect(result).toMatchInlineSnapshot(`
- Object {
- "defaultLocale": undefined,
- "enabled": false,
- "hasI18NPlugin": false,
- "locales": undefined,
- }
- `);
- });
-
- it("should check if i18n is enabled in navigation plugin config", async () => {
- // Given
- const enabled = Math.floor(Math.random() * 10) % 2 ? true : false;
- const store: ToBeFixed = () => ({
- get() {
- return { i18nEnabled: enabled };
- },
- });
- const strapi: Partial = {
- store,
- plugin() {
- return {
- service() {
- return {
- async getDefaultLocale() {
- return "en";
- },
- async find() {
- return [{ code: "en" }];
- },
- };
- },
- } as ToBeFixed;
- },
- };
-
- // When
- const result = await getI18nStatus({
- strapi: strapi as StrapiContext["strapi"],
- });
-
- // Given
- expect(result).toHaveProperty("enabled", enabled);
- });
-
- it("should read default locale", async () => {
- // Given
- const enabled = Math.floor(Math.random() * 10) % 2 ? true : false;
- const store: ToBeFixed = () => ({
- get() {
- return { i18nEnabled: enabled };
- },
- });
- const locale = "fr";
- const strapi: Partial = {
- store,
- plugin() {
- return {
- service() {
- return {
- async getDefaultLocale() {
- return locale;
- },
- async find() {
- return [{ code: locale }];
- },
- };
- },
- } as ToBeFixed;
- },
- };
-
- // When
- const result = await getI18nStatus({
- strapi: strapi as StrapiContext["strapi"],
- });
-
- // Given
- expect(result).toHaveProperty("defaultLocale", locale);
- });
-
- it("should read current locale", async () => {
- // Given
- const enabled = Math.floor(Math.random() * 10) % 2 ? true : false;
- const store: ToBeFixed = () => ({
- get() {
- return { i18nEnabled: enabled };
- },
- });
- const locale = "fr";
- const strapi: Partial = {
- store,
- plugin() {
- return {
- service() {
- return {
- async getDefaultLocale() {
- return locale;
- },
- async find() {
- return [{ code: locale }];
- },
- };
- },
- } as ToBeFixed;
- },
- };
-
- // When
- const result = await getI18nStatus({
- strapi: strapi as StrapiContext["strapi"],
- });
-
- // Given
- expect(result).toHaveProperty("locales", [locale]);
- });
- });
- });
-});
diff --git a/server/i18n/constant.ts b/server/i18n/constant.ts
deleted file mode 100644
index 0ceabf84..00000000
--- a/server/i18n/constant.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const I18N_DEFAULT_POPULATE = ["localizations"] as ["localizations"];
diff --git a/server/i18n/errors.ts b/server/i18n/errors.ts
deleted file mode 100644
index 3b897f3b..00000000
--- a/server/i18n/errors.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { NavigationError } from "../../utils/NavigationError";
-
-export class DefaultLocaleMissingError extends NavigationError {
- constructor(message = "Default locale is required.") {
- super(message);
- }
-}
-
-export class FillNavigationError extends NavigationError {}
diff --git a/server/i18n/graphQLEnhancers.ts b/server/i18n/graphQLEnhancers.ts
deleted file mode 100644
index 2bd1cb12..00000000
--- a/server/i18n/graphQLEnhancers.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Nexus } from "../../types";
-
-// TODO: Find a way to get this key from the source plugin
-const LOCALE_SCALAR_TYPENAME = 'I18NLocaleCode';
-
-type RenderNavigationArgsEnhancer = {
- previousArgs: T;
- nexus: Nexus;
-};
-
-export const addI18NRenderNavigationArgs = ({
- previousArgs,
- nexus,
-}: RenderNavigationArgsEnhancer) => ({
- ...previousArgs,
- locale: nexus.arg({ type: LOCALE_SCALAR_TYPENAME }),
-});
diff --git a/server/i18n/index.ts b/server/i18n/index.ts
deleted file mode 100644
index 7703d6d4..00000000
--- a/server/i18n/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from "./constant";
-export * from "./graphQLEnhancers";
-export * from "./navigationSetupStrategy";
-export * from "./serviceEnhancers";
-export * from "./utils";
-export * from "./errors";
-export { I18NConfigFields } from "./types"
diff --git a/server/i18n/navigationSetupStrategy.ts b/server/i18n/navigationSetupStrategy.ts
deleted file mode 100644
index c0980a92..00000000
--- a/server/i18n/navigationSetupStrategy.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { IStrapi, OnlyStrings, StrapiDBBulkActionResponse } from "strapi-typed";
-import {
- assertEntity,
- assertNotEmpty,
- I18nLocale,
- INavigationSetupStrategy,
- Navigation,
- NavigationPluginConfig,
-} from "../../types";
-import { DEFAULT_NAVIGATION_ITEM, DEFAULT_POPULATE } from "../utils";
-import { DefaultLocaleMissingError } from "./errors";
-import { prop } from "lodash/fp";
-
-export const i18nNavigationSetupStrategy: INavigationSetupStrategy = async ({
- strapi,
-}) => {
- const pluginStore = strapi.store({
- type: "plugin",
- name: "navigation",
- });
- const config: NavigationPluginConfig = await pluginStore.get({
- key: "config",
- });
- let currentNavigations = await getCurrentNavigations(strapi);
- const localeService = strapi.plugin("i18n").service("locales");
- const defaultLocale: string | null = await localeService.getDefaultLocale();
- const allLocale: string[] = (await localeService.find({})).map(
- ({ code }: I18nLocale) => code
- );
-
- assertNotEmpty(defaultLocale, new DefaultLocaleMissingError());
-
- if (config.i18nEnabled) {
- const hasNotAnyLocale = ({ localeCode }: Navigation) => !localeCode;
-
- if (currentNavigations.length === 0) {
- currentNavigations = [
- await createDefaultI18nNavigation({ strapi, defaultLocale }),
- ];
- }
-
- const noLocaleNavigations = currentNavigations.filter(hasNotAnyLocale);
-
- if (noLocaleNavigations.length) {
- await updateNavigations({
- strapi,
- ids: noLocaleNavigations.map(prop("id")).reduce((acc, id) => {
- if (id && Number(id)) {
- acc.push(Number(id));
- }
-
- return acc;
- }, [] as Array),
- payload: {
- localeCode: defaultLocale,
- },
- populate: DEFAULT_POPULATE,
- });
-
- currentNavigations = await getCurrentNavigations(strapi);
- }
-
- const navigationsToProcess = currentNavigations.filter(
- ({ localeCode }) => defaultLocale === localeCode
- );
-
- for (const rootNavigation of navigationsToProcess) {
- let localizations = [
- ...(rootNavigation.localizations ?? []).map(
- (localization) => assertEntity(localization, "Navigation")
- ),
- ];
-
- const connectOrphans = currentNavigations.filter((navigation) => {
- return (
- navigation.slug.startsWith(rootNavigation.slug) &&
- navigation.id !== rootNavigation.id &&
- !localizations.find(({ id }) => navigation.id === id)
- );
- });
-
- localizations = localizations
- .concat([rootNavigation])
- .concat(connectOrphans)
- // hiding not supported locale versions
- .filter(
- ({ localeCode }) => !!localeCode && allLocale.includes(localeCode)
- );
-
- const missingLocale = allLocale.filter(
- (locale) =>
- !localizations.some(({ localeCode }) => localeCode === locale)
- );
-
- if (missingLocale.length) {
- const { ids } = await createNavigations({
- strapi,
- payloads: missingLocale.map((locale) => ({
- localeCode: locale,
- slug: `${rootNavigation.slug}-${locale}`,
- name: rootNavigation.name,
- visible: true,
- })),
- });
-
- localizations = [...(await getCurrentNavigations(strapi, ids))].concat([
- rootNavigation,
- ]);
- }
-
- // TODO: Update to bulk operation when strapi
- // allows to update `oneToMany` relations on bulk update
- for (const current of localizations) {
- await updateNavigation({
- strapi,
- current,
- payload: {
- localizations: localizations.filter(
- (localization) => localization.id !== current.id
- ),
- },
- });
- }
- }
- } else {
- if (config.pruneObsoleteI18nNavigations) {
- await deleteNavigations({
- strapi,
- where: { localeCode: { $not: defaultLocale } as any },
- });
- await pluginStore.set({
- key: "config",
- value: {
- ...config,
- pruneObsoleteI18nNavigations: false,
- },
- });
- }
-
- const remainingNavigations = await getCurrentNavigations(strapi);
- if (!remainingNavigations.length) {
- await createDefaultI18nNavigation({ strapi, defaultLocale });
- }
- }
-
- return getCurrentNavigations(strapi);
-};
-
-const getCurrentNavigations = (
- strapi: IStrapi,
- ids?: Array
-): Promise =>
- strapi.plugin("navigation").service("admin").get(ids, true);
-
-// TODO: Move to service
-const createNavigation = ({
- strapi,
- payload,
- populate,
-}: {
- strapi: IStrapi;
- payload: Partial;
- populate?: Array;
-}): Promise =>
- strapi.query("plugin::navigation.navigation").create({
- data: {
- ...payload,
- },
- populate,
- });
-
-// TODO: update strapi-typed
-const createNavigations = ({
- strapi,
- payloads,
- populate,
-}: {
- strapi: IStrapi;
- payloads: Array>;
- populate?: Array;
-}): Promise =>
- strapi.query("plugin::navigation.navigation").createMany({
- data: payloads,
- populate,
- });
-
-const updateNavigation = ({
- strapi,
- current,
- payload,
- populate,
-}: {
- strapi: IStrapi;
- payload: Partial;
- current: Navigation;
- populate?: Array>;
-}): Promise =>
- strapi.query("plugin::navigation.navigation").update({
- data: {
- ...payload,
- },
- populate,
- where: {
- id: current.id,
- },
- });
-
-const updateNavigations = ({
- strapi,
- ids,
- payload,
- populate,
-}: {
- strapi: IStrapi;
- payload: Partial;
- ids: Array;
- populate?: Array>;
-}): Promise =>
- strapi.query("plugin::navigation.navigation").updateMany({
- data: payload,
- populate,
- where: {
- id: {
- $in: ids,
- },
- },
- });
-
-// TODO: Move to service
-const deleteNavigations = ({
- strapi,
- where,
-}: {
- strapi: IStrapi;
- where: { localeCode?: string | { $in: string[] } | { $not: string } };
-}) =>
- strapi.query("plugin::navigation.navigation").deleteMany({
- where,
- });
-
-const createDefaultI18nNavigation = ({
- strapi,
- defaultLocale,
-}: {
- strapi: IStrapi;
- defaultLocale: string;
-}): Promise =>
- createNavigation({
- strapi,
- payload: {
- ...DEFAULT_NAVIGATION_ITEM,
- localeCode: defaultLocale,
- },
- populate: DEFAULT_POPULATE,
- });
diff --git a/server/i18n/serviceEnhancers.ts b/server/i18n/serviceEnhancers.ts
deleted file mode 100644
index a56a098d..00000000
--- a/server/i18n/serviceEnhancers.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-// @ts-ignore
-import { errors } from "@strapi/utils"
-import { get, toString } from "lodash";
-import { pick } from "lodash/fp";
-import { OnlyStrings } from "strapi-typed";
-import {
- assertIsNumber,
- assertNotEmpty,
- RelatedRef,
- RelatedRefBase,
- ToBeFixed,
-} from "../../types";
-import { InvalidParamNavigationError } from "../../utils/InvalidParamNavigationError";
-import { intercalate } from "../utils";
-import { I18N_DEFAULT_POPULATE } from "./constant";
-import { DefaultLocaleMissingError, FillNavigationError } from "./errors";
-import {
- AddI18NConfigFieldsInput,
- AddI18nWhereClause,
- FillCopyContext,
- HandleLocaleParamInput,
- I18nAwareEntityReadHandlerInput,
- I18NConfigFields,
- I18nNavigationContentsCopyInput,
- MinimalEntityWithI18n,
- ResultNavigationItem,
- SourceNavigationItem,
- I18nNavigationItemReadInput,
-} from "./types";
-import { getI18nStatus } from "./utils";
-
-export const addI18NConfigFields = async ({
- previousConfig,
- strapi,
- viaSettingsPage = false,
-}: AddI18NConfigFieldsInput): Promise => {
- const { enabled, hasI18NPlugin, defaultLocale } = await getI18nStatus({
- strapi,
- });
-
- return {
- ...previousConfig,
- defaultLocale,
- i18nEnabled: enabled,
- isI18NPluginEnabled: viaSettingsPage ? hasI18NPlugin : undefined,
- pruneObsoleteI18nNavigations: false,
- };
-};
-
-export const handleLocaleQueryParam = async ({
- locale,
- strapi,
-}: HandleLocaleParamInput) => {
- const { enabled } = await getI18nStatus({ strapi });
-
- if (locale) {
- return locale;
- }
-
- const localeService = strapi.plugin("i18n").service("locales");
- const defaultLocale: string | null = await localeService.getDefaultLocale();
-
- assertNotEmpty(defaultLocale, new DefaultLocaleMissingError());
-
- return enabled ? defaultLocale : undefined;
-};
-
-export const i18nAwareEntityReadHandler = async <
- T extends { localeCode?: string | null; localizations?: T[] | null }
->({
- entity,
- entityUid,
- localeCode,
- populate = [],
- strapi,
- whereClause,
-}: I18nAwareEntityReadHandlerInput): Promise => {
- const { defaultLocale, enabled } = await getI18nStatus({ strapi });
-
- if (!enabled) {
- return entity;
- }
-
- if (entity && (!localeCode || entity.localeCode === localeCode)) {
- return entity;
- }
-
- const locale = localeCode || defaultLocale;
-
- const rerun = await strapi.query(entityUid).findOne({
- where: whereClause,
- populate: [...populate, ...I18N_DEFAULT_POPULATE] as Array<
- OnlyStrings
- >,
- });
-
- if (rerun) {
- if (rerun.localeCode === locale) {
- return rerun;
- }
-
- return rerun.localizations?.find(
- (localization) => localization.localeCode === locale
- );
- }
-};
-
-export const addI18nWhereClause = async ({
- modelUid,
- previousWhere,
- query,
- strapi,
-}: AddI18nWhereClause): Promise => {
- const { enabled } = await getI18nStatus({ strapi });
- const modelSchema = strapi.getModel(modelUid);
-
- if (enabled && query.localeCode && modelSchema.attributes.locale) {
- return {
- ...previousWhere,
- locale: query.localeCode,
- };
- }
-
- return previousWhere as T & { locale?: string };
-};
-
-export const i18nNavigationContentsCopy = async ({
- target,
- source,
- strapi,
- service,
-}: I18nNavigationContentsCopyInput): Promise => {
- const sourceItems = source.items ?? [];
-
- if (target.items?.length) {
- throw new FillNavigationError("Current navigation is non-empty");
- }
-
- if (!target.localeCode) {
- throw new FillNavigationError(
- "Current navigation does not have specified locale"
- );
- }
-
- if (!sourceItems.length) {
- throw new FillNavigationError("Source navigation is empty");
- }
-
- const newItems = await Promise.all(
- sourceItems.map(
- processItems({
- master: target,
- localeCode: target.localeCode,
- strapi,
- })
- )
- );
-
- await service.createBranch(newItems, target, null, { create: true });
-};
-
-export const i18nNavigationItemRead = async ({
- target,
- source,
- path,
- strapi
-}: I18nNavigationItemReadInput) => {
- const pickFields = pick(['path', 'related', 'type', 'uiRouterKey', 'title', 'externalPath']);
- const structurePath = path.split('.').map(p => parseInt(p, 10));
-
- if (!structurePath.some(Number.isNaN) || !structurePath.length) {
- new InvalidParamNavigationError("Path is invalid");
- }
-
- let result = get(source.items, intercalate("items", structurePath.map(toString)))
-
- if (!result) {
- throw new errors.NotFoundError("Unable to find navigation item");
- }
-
- let { related } = result;
-
- if (related) {
- const fullRelated = await strapi.query(related.__contentType).findOne({
- where: {
- id: related.id,
- },
- populate: I18N_DEFAULT_POPULATE,
- })
- if (fullRelated.localizations?.length) {
- const localeVersion = fullRelated.localizations.find(({ locale }) => locale === target.localeCode);
-
- if (localeVersion) {
- related = {
- ...localeVersion,
- __contentType: related.__contentType
- }
- }
- }
- }
-
- return pickFields({
- ...result,
- related
- });
-}
-
-const processItems =
- (context: FillCopyContext) =>
- async (item: SourceNavigationItem): Promise => ({
- title: item.title,
- path: item.path,
- audience: item.audience as ToBeFixed,
- type: item.type,
- uiRouterKey: item.uiRouterKey,
- order: item.order,
- collapsed: item.collapsed,
- menuAttached: item.menuAttached,
- removed: false,
- updated: true,
- externalPath: item.externalPath ?? undefined,
- items: item.items
- ? await Promise.all(item.items.map(processItems(context)))
- : [],
- master: parseInt(context.master.id.toString(), 10),
- parent: null,
- related: item.related ? [await processRelated(item.related, context)] : [],
- });
-
-const processRelated = async (
- related: RelatedRef,
- { localeCode, strapi }: FillCopyContext
-): Promise => {
- const { __contentType, id } = related;
-
- assertNotEmpty(
- __contentType,
- new FillNavigationError("Related item's content type is missing")
- );
- assertIsNumber(
- id,
- new FillNavigationError("Related item's id is not a number")
- );
-
- const relatedItemWithLocalizations = await strapi
- .query(__contentType)
- .findOne({ where: { id }, populate: I18N_DEFAULT_POPULATE });
- const localization = relatedItemWithLocalizations.localizations?.find(
- ({ locale }) => locale === localeCode
- );
-
- return {
- refId: localization?.id ?? id,
- ref: __contentType,
- field: related.field,
- };
-};
diff --git a/server/i18n/types.ts b/server/i18n/types.ts
deleted file mode 100644
index c488d0d9..00000000
--- a/server/i18n/types.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { IStrapi, OnlyStrings, StringMap, WhereClause } from "strapi-typed";
-import {
- ICommonService,
- Navigation,
- NavigationItemInput,
- NestedStructure,
- NotVoid,
-} from "../../types";
-
-export type AddI18NConfigFieldsInput = {
- previousConfig: T;
- strapi: IStrapi;
- viaSettingsPage?: boolean;
-};
-
-export type HandleLocaleParamInput = {
- locale?: string;
- strapi: IStrapi;
-};
-
-export type I18nAwareEntityReadHandlerInput = {
- entity: T | undefined | null;
- entityUid: string;
- whereClause: WhereClause>;
- localeCode?: string;
- strapi: IStrapi;
- populate?: string[];
-};
-
-export type I18NConfigFields = {
- i18nEnabled: boolean;
- isI18NPluginEnabled: boolean | undefined;
- pruneObsoleteI18nNavigations: boolean;
- defaultLocale?: string | null;
-};
-
-export type AddI18nWhereClause = {
- previousWhere: T;
- strapi: IStrapi;
- query: StringMap & { localeCode?: string };
- modelUid: string;
-};
-
-export type I18nNavigationContentsCopyInput = {
- target: Navigation;
- source: Navigation;
- service: ICommonService;
- strapi: IStrapi;
-};
-
-export type I18nNavigationItemReadInput = {
- path: string;
- source: Navigation;
- strapi: IStrapi;
- target: Navigation;
-};
-
-export type SourceNavigationItem = NestedStructure<
- NotVoid[0]
->;
-
-export type ResultNavigationItem = NestedStructure;
-
-export type FillCopyContext = {
- master: Navigation;
- strapi: IStrapi;
- localeCode: string;
-};
-
-export type MinimalEntityWithI18n = {
- id: number;
- localizations?: Array<{
- id: number;
- locale?: string;
- }>;
-};
diff --git a/server/i18n/utils.ts b/server/i18n/utils.ts
deleted file mode 100644
index 4f270103..00000000
--- a/server/i18n/utils.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { IStrapi } from "strapi-typed";
-import { NavigationPluginConfig } from "../../types";
-import { prop } from "lodash/fp";
-
-type GetI18nStatusInput = {
- strapi: IStrapi;
-};
-
-type I18NStatus = {
- hasI18NPlugin: boolean;
- enabled: boolean;
- defaultLocale?: string | null;
- locales?: string[] | undefined
-};
-
-export const getI18nStatus = async ({
- strapi,
-}: GetI18nStatusInput): Promise => {
- const i18nPlugin: null | any = strapi.plugin("i18n")
- const hasI18NPlugin = !!i18nPlugin;
- const pluginStore = strapi.store({
- type: "plugin",
- name: "navigation",
- });
-
- const config: NavigationPluginConfig = await pluginStore.get({
- key: "config",
- });
- const localeService = i18nPlugin ? i18nPlugin.service("locales") : null;
- const defaultLocale: string | undefined = await localeService?.getDefaultLocale();
- const locales: string[] | undefined = (await localeService?.find({}))?.map(prop("code"));
-
- return hasI18NPlugin
- ? {
- hasI18NPlugin,
- enabled: config.i18nEnabled,
- defaultLocale,
- locales,
- }
- : {
- hasI18NPlugin,
- enabled: false,
- defaultLocale: undefined,
- locales: undefined
- };
-};
diff --git a/server/navigation/index.ts b/server/navigation/index.ts
deleted file mode 100644
index 871478ea..00000000
--- a/server/navigation/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./setupStrategy";
diff --git a/server/navigation/setupStrategy.ts b/server/navigation/setupStrategy.ts
deleted file mode 100644
index b997396a..00000000
--- a/server/navigation/setupStrategy.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { IStrapi, StrapiPlugin } from "strapi-typed";
-import { INavigationSetupStrategy, Navigation } from "../../types";
-import { i18nNavigationSetupStrategy } from "../i18n";
-import { DEFAULT_NAVIGATION_ITEM, DEFAULT_POPULATE, getPluginService } from "../utils";
-
-export const navigationSetupStrategy: INavigationSetupStrategy = async (
- context
-) => {
- const i18nPlugin: StrapiPlugin | undefined = context.strapi.plugin("i18n");
- const defaultLocale: string | null = i18nPlugin ? await i18nPlugin.service("locales").getDefaultLocale() : null;
-
- if (defaultLocale) {
- return await i18nNavigationSetupStrategy(context);
- } else {
- return await regularNavigationSetupStrategy(context);
- }
-};
-
-const regularNavigationSetupStrategy: INavigationSetupStrategy = async ({
- strapi,
-}) => {
- const navigations = await getCurrentNavigations();
-
- if (!navigations.length) {
- return [
- await createNavigation({
- strapi,
- payload: {
- ...DEFAULT_NAVIGATION_ITEM,
- },
- populate: DEFAULT_POPULATE
- }),
- ];
- }
-
- return navigations;
-};
-
-// TODO: Move to service
-const createNavigation = ({
- strapi,
- payload,
- populate
-}: {
- strapi: IStrapi;
- payload: Partial;
- populate?: (keyof Navigation)[]
-}): Promise =>
- strapi.query("plugin::navigation.navigation").create({
- data: {
- ...payload,
- },
- populate,
- });
-
-const getCurrentNavigations = (): Promise =>
- getPluginService('admin').get();
diff --git a/server/register/index.ts b/server/register/index.ts
deleted file mode 100644
index 18554e89..00000000
--- a/server/register/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export default async () => {};
diff --git a/server/routes/admin.ts b/server/routes/admin.ts
deleted file mode 100644
index d925645d..00000000
--- a/server/routes/admin.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import { StrapiRoutes } from "../../types";
-import pluginPermissions from "../../permissions";
-
-const routes: StrapiRoutes = {
- type: 'admin',
- routes: [
- {
- method: 'GET',
- path: '/',
- handler: 'admin.get',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('read')],
- },
- }]
- }
- },
- {
- method: 'POST',
- path: '/',
- handler: 'admin.post',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('update')],
- },
- }]
- }
- },
- {
- method: 'GET',
- path: '/config',
- handler: 'admin.config',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('read')],
- },
- }]
- }
- },
- {
- method: 'PUT',
- path: '/config',
- handler: 'admin.updateConfig',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('settings')],
- },
- }]
- }
- },
- {
- method: 'DELETE',
- path: '/config',
- handler: 'admin.restoreConfig',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('settings')],
- },
- }]
- }
- },
- {
- method: 'DELETE',
- path: '/cache/purge/:id',
- handler: 'admin.purgeNavigationCache',
- config: {
- policies: [
- 'admin::isAuthenticatedAdmin'
- ]
- }
- },
- {
- method: 'DELETE',
- path: '/cache/purge',
- handler: 'admin.purgeNavigationsCache',
- config: {
- policies: [
- 'admin::isAuthenticatedAdmin'
- ]
- }
- },
- {
- method: 'GET',
- path: '/slug',
- handler: 'admin.getSlug',
- config: {
- policies: [
- 'admin::isAuthenticatedAdmin'
- ]
- }
- },
- {
- method: 'GET',
- path: '/:id',
- handler: 'admin.getById',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('read')],
- },
- }]
- }
- },
- {
- method: 'PUT',
- path: '/:id',
- handler: 'admin.put',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('update')],
- },
- }]
- }
- },
- {
- method: 'DELETE',
- path: '/:id',
- handler: 'admin.delete',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('update')],
- },
- }]
- }
- },
- {
- method: 'GET',
- path: '/content-type-items/:model',
- handler: 'admin.getContentTypeItems',
- config: {
- policies: [
- 'admin::isAuthenticatedAdmin'
- ]
- }
- },
- {
- method: 'GET',
- path: '/settings/config',
- handler: 'admin.settingsConfig',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('settings')],
- },
- }]
- }
- },
- {
- method: 'GET',
- path: '/settings/restart',
- handler: 'admin.settingsRestart',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('settings')],
- },
- }]
- }
- },
- {
- method: 'PUT',
- path: '/i18n/copy/:source/:target',
- handler: 'admin.fillFromOtherLocale',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('update')],
- },
- }]
- }
- },
- {
- method: 'GET',
- path: '/i18n/item/read/:source/:target',
- handler: 'admin.readNavigationItemFromLocale',
- config: {
- policies: [{
- name: "admin::hasPermissions",
- config: {
- actions: [pluginPermissions.render('read')],
- },
- }]
- }
- },
- ]
-}
-
-export default routes;
diff --git a/server/services/__tests__/service.test.ts b/server/services/__tests__/service.test.ts
deleted file mode 100644
index 9344b20e..00000000
--- a/server/services/__tests__/service.test.ts
+++ /dev/null
@@ -1,1568 +0,0 @@
-import { IStrapi, StrapiContext, StrapiStore } from "strapi-typed";
-import {
- ICommonService,
- Navigation,
- NavigationItem,
- ToBeFixed,
-} from "../../../types";
-
-import setupStrapi from "../../../__mocks__/strapi";
-import { allLifecycleHooks, getPluginService, RENDER_TYPES } from "../../utils";
-import adminService from "../admin";
-import clientService from "../client";
-
-declare var strapi: IStrapi;
-
-describe("Navigation services", () => {
- beforeAll(async () => {
- setupStrapi();
- });
-
- describe("Correct config", () => {
- it("Declares Strapi instance", () => {
- expect(strapi).toBeDefined();
- expect(strapi.plugin("navigation").service("admin")).toBeDefined();
- expect(strapi.plugin("navigation").service("client")).toBeDefined();
- expect(strapi.plugin("navigation").service("common")).toBeDefined();
- });
-
- it("Defines proper content types", () => {
- expect(strapi.contentTypes).toBeDefined();
- expect(strapi.plugin("navigation").contentTypes).toBeDefined();
- });
-
- it("Can read and return plugins config", () => {
- expect(
- strapi.plugin("navigation").config("additionalFields")
- ).toBeDefined();
- expect(strapi.plugin("navigation").config("contentTypes")).toBeDefined();
- expect(
- strapi.plugin("navigation").config("contentTypesNameFields")
- ).toBeDefined();
- expect(
- strapi.plugin("navigation").config("contentTypesPopulate")
- ).toBeDefined();
- expect(strapi.plugin("navigation").config("allowedLevels")).toBeDefined();
- expect(strapi.plugin("navigation").config("gql")).toBeDefined();
- expect(strapi.plugin("navigation").config("preferCustomContentTypes")).toBeDefined();
- });
- });
-
- describe("Render navigation", () => {
- it("Can render branch in flat format", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({ idOrSlug: 1 });
-
- expect(result).toBeDefined();
- expect(result.length).toBe(2);
- expect(result).toHaveProperty([0, "related", "id"], 1);
- expect(result).toHaveProperty([0, "related", "title"], "Page nr 1");
- expect(result).toHaveProperty(
- [0, "string_test_field"],
- "Custom field value"
- );
- });
-
- it("Can render branch in flat format for GraphQL", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- wrapRelated: true,
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBe(2);
- expect(result).toHaveProperty([0, "related", "id"], 1);
- expect(result).toHaveProperty(
- [0, "related", "attributes", "title"],
- "Page nr 1"
- );
- expect(result).toHaveProperty(
- [0, "string_test_field"],
- "Custom field value"
- );
- });
-
- it("Can render branch in tree format", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.TREE,
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBeGreaterThan(0);
- expect(result[0].items).toBeDefined();
- expect(result[0].items.length).toBeGreaterThan(0);
- expect(result).toHaveProperty(
- [0, "items", 0, "string_test_field"],
- "Custom field value"
- );
- });
-
- it("Can render branch in tree format for GraphQL", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.TREE,
- wrapRelated: true,
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBeGreaterThan(0);
- expect(result).toHaveProperty([0, "related", "id"], 1);
- expect(result).toHaveProperty(
- [0, "related", "attributes", "title"],
- "Page nr 1"
- );
- expect(result).toHaveProperty([0, "items", 0, "related", "id"], 2);
- expect(result).toHaveProperty(
- [0, "items", 0, "related", "attributes", "title"],
- "Page nr 2"
- );
- expect(result).toHaveProperty(
- [0, "string_test_field"],
- "Custom field value"
- );
- });
-
- it("Can render branch in rfr format", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.RFR,
- });
-
- expect(result).toBeDefined();
- expect(result.pages).toBeDefined();
- expect(result.nav).toBeDefined();
- });
-
- it("Can render branch in rfr format for GraphQL", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.RFR,
- wrapRelated: true,
- });
-
- expect(result).toBeDefined();
- expect(result.pages).toBeDefined();
- expect(result.nav).toBeDefined();
- expect(result).toHaveProperty(["pages", "home", "related", "id"], 1);
- });
-
- it("Can render only menu attached elements", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.FLAT,
- menuOnly: true,
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBe(1);
- });
-
- it("Can render branch by path", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.render({
- idOrSlug: 1,
- type: RENDER_TYPES.FLAT,
- menuOnly: false,
- rootPath: "/home/side",
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBe(1);
- });
- });
-
- describe("Render child", () => {
- it("Can render child", async () => {
- const clientService = getPluginService("client");
- const result = await clientService.renderChildren({
- idOrSlug: 1,
- childUIKey: "home",
- });
-
- expect(result).toBeDefined();
- expect(result.length).toBe(1);
- });
- });
-
- describe("Lifecycle hooks", () => {
- it.each(allLifecycleHooks)(
- "should trigger for %s hook listener",
- async (hookName: ToBeFixed) => {
- // Given
- const commonService = getPluginService("common");
- const listenerA = jest.fn().mockResolvedValue("ABC");
- const listenerB = jest.fn();
- const event = {} as ToBeFixed;
-
- commonService.registerLifecycleHook({
- callback: listenerA,
- contentTypeName: "navigation",
- hookName,
- });
- commonService.registerLifecycleHook({
- callback: listenerB,
- contentTypeName: "navigation",
- hookName,
- });
-
- // When
- await commonService.runLifecycleHook({
- contentTypeName: "navigation",
- event,
- hookName,
- });
-
- // Then
- expect(listenerA).toHaveBeenCalledTimes(1);
- expect(listenerB).toHaveBeenCalledTimes(1);
- }
- );
- });
- describe("ClientService", () => {
- let index = 0;
- const generateNavigation = (
- rest: Partial = {}
- ): Partial => ({
- name: `Navigation-${++index}`,
- id: ++index,
- ...rest,
- });
-
- describe("readAll()", () => {
- it("should read results", async () => {
- // Given
- const allLocale = ["en", "pl", "ff", "fr"];
- const activeLocale = allLocale.filter((locale) => locale != "fr");
- const locale = allLocale[0];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: false };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const navigations = allLocale.map((localeCode) =>
- generateNavigation({
- localeCode,
- localizations: allLocale
- .filter((locale) => locale !== localeCode)
- .map((_localeCode) =>
- generateNavigation({ localeCode: _localeCode })
- ) as ToBeFixed,
- })
- );
- const service = clientService({
- strapi: strapi as StrapiContext["strapi"],
- });
- const orderBy = "name";
- const orderDirection = "ASC";
-
- findMany.mockResolvedValue(navigations);
-
- // When
- const result = await service.readAll({
- orderBy,
- locale,
- orderDirection,
- });
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "orderBy": Object {
- "name": "ASC",
- },
- "populate": false,
- "where": Object {
- "localeCode": "en",
- },
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 8,
- "localeCode": "en",
- "localizations": Array [
- Object {
- "id": 2,
- "localeCode": "pl",
- "name": "Navigation-1",
- },
- Object {
- "id": 4,
- "localeCode": "ff",
- "name": "Navigation-3",
- },
- Object {
- "id": 6,
- "localeCode": "fr",
- "name": "Navigation-5",
- },
- ],
- "name": "Navigation-7",
- },
- Object {
- "id": 16,
- "localeCode": "pl",
- "localizations": Array [
- Object {
- "id": 10,
- "localeCode": "en",
- "name": "Navigation-9",
- },
- Object {
- "id": 12,
- "localeCode": "ff",
- "name": "Navigation-11",
- },
- Object {
- "id": 14,
- "localeCode": "fr",
- "name": "Navigation-13",
- },
- ],
- "name": "Navigation-15",
- },
- Object {
- "id": 24,
- "localeCode": "ff",
- "localizations": Array [
- Object {
- "id": 18,
- "localeCode": "en",
- "name": "Navigation-17",
- },
- Object {
- "id": 20,
- "localeCode": "pl",
- "name": "Navigation-19",
- },
- Object {
- "id": 22,
- "localeCode": "fr",
- "name": "Navigation-21",
- },
- ],
- "name": "Navigation-23",
- },
- Object {
- "id": 32,
- "localeCode": "fr",
- "localizations": Array [
- Object {
- "id": 26,
- "localeCode": "en",
- "name": "Navigation-25",
- },
- Object {
- "id": 28,
- "localeCode": "pl",
- "name": "Navigation-27",
- },
- Object {
- "id": 30,
- "localeCode": "ff",
- "name": "Navigation-29",
- },
- ],
- "name": "Navigation-31",
- },
- ]
- `);
- });
- it("should read locale aware results", async () => {
- // Given
- const allLocale = ["en", "pl", "ff", "fr"];
- const activeLocale = allLocale.filter((locale) => locale != "fr");
- const locale = allLocale[0];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: true };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const navigations = allLocale.map((localeCode) =>
- generateNavigation({
- localeCode,
- localizations: allLocale
- .filter((locale) => locale !== localeCode)
- .map((_localeCode) =>
- generateNavigation({ localeCode: _localeCode })
- ) as ToBeFixed,
- })
- );
- const service = clientService({
- strapi: strapi as StrapiContext["strapi"],
- });
- const orderBy = "name";
- const orderDirection = "ASC";
-
- findMany.mockResolvedValue(navigations);
-
- // When
- const result = await service.readAll({
- orderBy,
- locale,
- orderDirection,
- });
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "orderBy": Object {
- "name": "ASC",
- },
- "populate": false,
- "where": Object {
- "localeCode": "en",
- },
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 40,
- "localeCode": "en",
- "localizations": Array [
- Object {
- "id": 34,
- "localeCode": "pl",
- "name": "Navigation-33",
- },
- Object {
- "id": 36,
- "localeCode": "ff",
- "name": "Navigation-35",
- },
- ],
- "name": "Navigation-39",
- },
- Object {
- "id": 48,
- "localeCode": "pl",
- "localizations": Array [
- Object {
- "id": 42,
- "localeCode": "en",
- "name": "Navigation-41",
- },
- Object {
- "id": 44,
- "localeCode": "ff",
- "name": "Navigation-43",
- },
- ],
- "name": "Navigation-47",
- },
- Object {
- "id": 56,
- "localeCode": "ff",
- "localizations": Array [
- Object {
- "id": 50,
- "localeCode": "en",
- "name": "Navigation-49",
- },
- Object {
- "id": 52,
- "localeCode": "pl",
- "name": "Navigation-51",
- },
- ],
- "name": "Navigation-55",
- },
- ]
- `);
- });
- });
- });
-
- describe("AdminService", () => {
- let navigationIndex = 0;
- let itemIndex = 0;
- const generateNavigation = (
- rest: Partial = {}
- ): Partial => ({
- name: `Navigation-${++navigationIndex}`,
- id: ++navigationIndex,
- ...rest,
- });
-
- beforeEach(() => {
- navigationIndex = 0;
- });
- const generateNavigationItem = (
- rest: Partial = {}
- ): Partial => ({
- path: `/navigation-item-${++itemIndex}`,
- id: ++itemIndex,
- ...rest,
- });
-
- describe("get()", () => {
- it("should ignore i18n when required", async () => {
- // Given
- const locale = "en";
- const allLocale = [locale, "fr", "ff", "de"];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return allLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: true };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
- const navigations = allLocale
- .map((localeCode) =>
- generateNavigation({
- localeCode,
- localizations: allLocale
- .filter((locale) => locale !== localeCode)
- .map((_localeCode) =>
- generateNavigation({ localeCode: _localeCode })
- ) as ToBeFixed,
- })
- )
- .concat([generateNavigation({ localeCode: null })]);
-
- findMany.mockResolvedValue(navigations);
-
- // When
- const result = await adminServiceBuilt.get(undefined, true);
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "populate": Array [
- "localizations",
- ],
- "where": Object {},
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 8,
- "localeCode": "en",
- "localizations": Array [
- Object {
- "id": 2,
- "localeCode": "fr",
- "name": "Navigation-1",
- },
- Object {
- "id": 4,
- "localeCode": "ff",
- "name": "Navigation-3",
- },
- Object {
- "id": 6,
- "localeCode": "de",
- "name": "Navigation-5",
- },
- ],
- "name": "Navigation-7",
- },
- Object {
- "id": 16,
- "localeCode": "fr",
- "localizations": Array [
- Object {
- "id": 10,
- "localeCode": "en",
- "name": "Navigation-9",
- },
- Object {
- "id": 12,
- "localeCode": "ff",
- "name": "Navigation-11",
- },
- Object {
- "id": 14,
- "localeCode": "de",
- "name": "Navigation-13",
- },
- ],
- "name": "Navigation-15",
- },
- Object {
- "id": 24,
- "localeCode": "ff",
- "localizations": Array [
- Object {
- "id": 18,
- "localeCode": "en",
- "name": "Navigation-17",
- },
- Object {
- "id": 20,
- "localeCode": "fr",
- "name": "Navigation-19",
- },
- Object {
- "id": 22,
- "localeCode": "de",
- "name": "Navigation-21",
- },
- ],
- "name": "Navigation-23",
- },
- Object {
- "id": 32,
- "localeCode": "de",
- "localizations": Array [
- Object {
- "id": 26,
- "localeCode": "en",
- "name": "Navigation-25",
- },
- Object {
- "id": 28,
- "localeCode": "fr",
- "name": "Navigation-27",
- },
- Object {
- "id": 30,
- "localeCode": "ff",
- "name": "Navigation-29",
- },
- ],
- "name": "Navigation-31",
- },
- Object {
- "id": 34,
- "localeCode": null,
- "name": "Navigation-33",
- },
- ]
- `);
- });
- it("should read all navigations", async () => {
- // Given
- const ids = [1, 2, 3, 4, 5, 6, 7];
- const navigations = ids.map((id) => generateNavigation({ id }));
- const locale = "en";
- const activeLocale = [locale];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: false };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- findMany.mockResolvedValue(navigations);
-
- // When
- const result = await adminServiceBuilt.get();
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "populate": Array [
- "localizations",
- ],
- "where": Object {},
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 1,
- "name": "Navigation-1",
- },
- Object {
- "id": 2,
- "name": "Navigation-3",
- },
- Object {
- "id": 3,
- "name": "Navigation-5",
- },
- Object {
- "id": 4,
- "name": "Navigation-7",
- },
- Object {
- "id": 5,
- "name": "Navigation-9",
- },
- Object {
- "id": 6,
- "name": "Navigation-11",
- },
- Object {
- "id": 7,
- "name": "Navigation-13",
- },
- ]
- `);
- });
-
- it("should read navigations only for specified ids", async () => {
- // Given
- const ids = [1, 2, 3, 4, 5, 6, 7];
- const navigations = ids.map((id) => generateNavigation({ id }));
- const locale = "en";
- const activeLocale = [locale];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: false };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- const selectedIds = [3, 4, 7];
- findMany.mockResolvedValue(
- navigations.filter(({ id }) => selectedIds.includes(Number(id)))
- );
-
- // When
- const result = await adminServiceBuilt.get([3, 4, 7]);
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "populate": Array [
- "localizations",
- ],
- "where": Object {
- "id": Object {
- "$in": Array [
- 3,
- 4,
- 7,
- ],
- },
- },
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 3,
- "name": "Navigation-5",
- },
- Object {
- "id": 4,
- "name": "Navigation-7",
- },
- Object {
- "id": 7,
- "name": "Navigation-13",
- },
- ]
- `);
- });
-
- it("should be internationalisation aware", async () => {
- // Given
- const locale = "en";
- const activeLocale = [locale, "fr", "ff"];
- const allLocale = [locale, "fr", "ff", "de"];
- const findMany = jest.fn();
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: true };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
- const navigations = allLocale.map((localeCode) =>
- generateNavigation({
- localeCode,
- localizations: allLocale
- .filter((locale) => locale !== localeCode)
- .map((_localeCode) =>
- generateNavigation({ localeCode: _localeCode })
- ) as ToBeFixed,
- })
- );
- const ids = navigations
- .map(({ id }) => id)
- .slice(1, 3)
- .map(Number);
-
- findMany.mockResolvedValue(
- navigations.filter(({ id }) => ids.includes(Number(id)))
- );
-
- // When
- const result = await adminServiceBuilt.get(ids);
-
- // Then
- expect(findMany.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "populate": Array [
- "localizations",
- ],
- "where": Object {
- "id": Object {
- "$in": Array [
- 16,
- 24,
- ],
- },
- },
- },
- ],
- ]
- `);
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": 16,
- "localeCode": "fr",
- "localizations": Array [
- Object {
- "id": 10,
- "localeCode": "en",
- "name": "Navigation-9",
- },
- Object {
- "id": 12,
- "localeCode": "ff",
- "name": "Navigation-11",
- },
- ],
- "name": "Navigation-15",
- },
- Object {
- "id": 24,
- "localeCode": "ff",
- "localizations": Array [
- Object {
- "id": 18,
- "localeCode": "en",
- "name": "Navigation-17",
- },
- Object {
- "id": 20,
- "localeCode": "fr",
- "name": "Navigation-19",
- },
- ],
- "name": "Navigation-23",
- },
- ]
- `);
- });
- });
-
- describe("delete()", () => {
- it("should delete navigation with items", async () => {
- // Given
- const auditLogMock = jest.fn();
- const ids = [1];
- const navigations = ids.map((id) => generateNavigation({ id }));
- const locale = "en";
- const activeLocale = [locale];
- const findManyNavigation = jest.fn();
- const findManyNavigationItem = jest.fn();
- const deleteNavigation = jest.fn();
- const deleteManyNavigation = jest.fn();
- const deleteManyNavigationItem = jest.fn();
- const query = (uid: string): any => {
- if (uid === "plugin::navigation.navigation") {
- return {
- findMany: findManyNavigation,
- delete: deleteNavigation,
- deleteMany: deleteManyNavigation,
- };
- }
-
- return {
- findMany: findManyNavigationItem,
- deleteMany: deleteManyNavigationItem,
- };
- };
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: false };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- findManyNavigation.mockResolvedValue(navigations);
- findManyNavigationItem.mockResolvedValueOnce([
- generateNavigationItem({}),
- generateNavigationItem({}),
- ]);
-
- adminServiceBuilt.getById = jest.fn();
-
- (adminServiceBuilt.getById as jest.Mock).mockReturnValue(
- navigations[0]
- );
-
- // When
- await adminServiceBuilt.delete(1, { emit: auditLogMock });
-
- // Then
- expect(findManyNavigation.mock.calls).toMatchInlineSnapshot(`Array []`);
- expect(findManyNavigationItem.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "where": Object {
- "$or": Array [
- Object {
- "master": 1,
- },
- ],
- },
- },
- ],
- ]
- `);
- expect(deleteManyNavigation.mock.calls).toMatchInlineSnapshot(
- `Array []`
- );
- expect(deleteManyNavigationItem.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "where": Object {
- "id": Array [
- 2,
- 4,
- ],
- },
- },
- ],
- ]
- `);
- expect(deleteNavigation.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "where": Object {
- "id": 1,
- },
- },
- ],
- ]
- `);
- expect(auditLogMock.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- "onNavigationDeletion",
- Object {
- "actionType": "DELETE",
- "entity": Object {
- "id": 1,
- "name": "Navigation-1",
- },
- },
- ],
- ]
- `);
- });
-
- it("should delete navigation with localisations", async () => {
- // Given
- const auditLogMock = jest.fn();
- const allLocale = ["en", "pl"];
- const activeLocale = allLocale.filter((locale) => locale != "fr");
- const locale = allLocale[0];
- const navigations = allLocale.map((localeCode) =>
- generateNavigation({
- localeCode,
- localizations: allLocale
- .filter((locale) => locale !== localeCode)
- .map((_localeCode) =>
- generateNavigation({ localeCode: _localeCode })
- ) as ToBeFixed,
- })
- );
- const findManyNavigation = jest.fn();
- const findManyNavigationItem = jest.fn();
- const deleteNavigation = jest.fn();
- const deleteManyNavigation = jest.fn();
- const deleteManyNavigationItem = jest.fn();
- const query = (uid: string): any => {
- if (uid === "plugin::navigation.navigation") {
- return {
- findMany: findManyNavigation,
- delete: deleteNavigation,
- deleteMany: deleteManyNavigation,
- };
- }
-
- return {
- findMany: findManyNavigationItem,
- deleteMany: deleteManyNavigationItem,
- };
- };
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return activeLocale.map((code) => ({ code }));
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const store = () =>
- ({
- get() {
- return { i18nEnabled: true };
- },
- } as ToBeFixed);
- const strapi: Partial = {
- query,
- plugin(name): any {
- return name === "i18n" ? i18nPlugin : null;
- },
- store,
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
- const id = navigations[0].id;
-
- findManyNavigation.mockResolvedValue(navigations);
-
- [1, 2, 3, 4, 5].forEach(() => {
- findManyNavigationItem.mockResolvedValueOnce([
- generateNavigationItem({}),
- generateNavigationItem({}),
- ]);
- });
-
- adminServiceBuilt.getById = jest.fn();
-
- (adminServiceBuilt.getById as jest.Mock).mockReturnValue(
- navigations[0]
- );
-
- // When
- await adminServiceBuilt.delete(id!, { emit: auditLogMock });
-
- // Then
- expect(findManyNavigation.mock.calls).toMatchInlineSnapshot(`Array []`);
- expect(findManyNavigationItem.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "limit": 9007199254740991,
- "where": Object {
- "$or": Array [
- Object {
- "master": 4,
- },
- ],
- },
- },
- ],
- Array [
- Object {
- "limit": 9007199254740991,
- "where": Object {
- "$or": Array [
- Object {
- "master": 2,
- },
- ],
- },
- },
- ],
- ]
- `);
- expect(deleteManyNavigation.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "where": Object {
- "id": Object {
- "$in": Array [
- 2,
- ],
- },
- },
- },
- ],
- ]
- `);
- expect(deleteManyNavigationItem.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "where": Object {
- "id": Array [
- 6,
- 8,
- ],
- },
- },
- ],
- Array [
- Object {
- "where": Object {
- "id": Array [
- 10,
- 12,
- ],
- },
- },
- ],
- ]
- `);
- expect(deleteNavigation.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Object {
- "where": Object {
- "id": 4,
- },
- },
- ],
- ]
- `);
- expect(auditLogMock.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- "onNavigationDeletion",
- Object {
- "actionType": "DELETE",
- "entity": Object {
- "id": 4,
- "localeCode": "en",
- "localizations": Array [
- Object {
- "id": 2,
- "localeCode": "pl",
- "name": "Navigation-1",
- },
- ],
- "name": "Navigation-3",
- },
- },
- ],
- ]
- `);
- });
- });
-
- describe("purgeNavigationsCache()", () => {
- it("should clear all navigations", async () => {
- // Given
- const cacheCacheStore = { clearByRegexp: jest.fn() };
- const cachePlugin = {
- service() {
- return cacheCacheStore;
- },
- } as ToBeFixed;
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- } as ToBeFixed as IStrapi;
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- // When
- await adminServiceBuilt.purgeNavigationsCache();
-
- // Then
- expect(cacheCacheStore.clearByRegexp).toHaveBeenCalled();
- expect(cacheCacheStore.clearByRegexp.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Array [
- /\\\\/api\\\\/navigation\\\\/render\\(\\.\\*\\)/,
- ],
- ],
- ]
- `);
- });
- });
-
- describe("purgeNavigationCache()", () => {
- it("should purge a single navigation's cache", async () => {
- // Given
- const cacheCacheStore = { clearByRegexp: jest.fn() };
- const cachePlugin = {
- service() {
- return cacheCacheStore;
- },
- } as ToBeFixed;
- const strapi = {
- plugin() {
- return cachePlugin;
- },
- } as ToBeFixed as IStrapi;
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- adminServiceBuilt.getById = jest.fn();
-
- (adminServiceBuilt.getById as jest.Mock).mockResolvedValue({
- id: 1,
- localizations: [{ id: 2 }, { id: 3 }],
- });
-
- // When
- await adminServiceBuilt.purgeNavigationCache(1, false);
-
- // Then
- expect(cacheCacheStore.clearByRegexp).toHaveBeenCalled();
- expect(cacheCacheStore.clearByRegexp.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Array [
- /\\\\/api\\\\/navigation\\\\/render\\\\/1/,
- ],
- ],
- ]
- `);
- });
-
- it("should purge a single navigation's cache and localisation when requested (if i18n enabled)", async () => {
- // Given
- const cacheCacheStore = { clearByRegexp: jest.fn() };
- const findMany = jest.fn();
- const locale = "en";
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return [locale];
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const cachePlugin = {
- service() {
- return cacheCacheStore;
- },
- } as ToBeFixed;
- const store = () =>
- ({
- get() {
- return { i18nEnabled: true };
- },
- } as ToBeFixed);
- const strapi = {
- query,
- plugin(name: string): any {
- return name === "i18n" ? i18nPlugin : cachePlugin;
- },
- store,
- } as ToBeFixed as IStrapi;
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- adminServiceBuilt.getById = jest.fn();
-
- (adminServiceBuilt.getById as jest.Mock).mockResolvedValue({
- id: 1,
- localizations: [{ id: 2 }, { id: 3 }],
- });
-
- // When
- await adminServiceBuilt.purgeNavigationCache(1, true);
-
- // Then
- expect(cacheCacheStore.clearByRegexp).toHaveBeenCalled();
- expect(cacheCacheStore.clearByRegexp.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Array [
- /\\\\/api\\\\/navigation\\\\/render\\\\/2/,
- /\\\\/api\\\\/navigation\\\\/render\\\\/3/,
- /\\\\/api\\\\/navigation\\\\/render\\\\/1/,
- ],
- ],
- ]
- `);
- });
-
- it("should purge a single navigation's cache only when requested to clear localisations (if i18n disabled)", async () => {
- // Given
- const cacheCacheStore = { clearByRegexp: jest.fn() };
- const findMany = jest.fn();
- const locale = "en";
- const query = (): any => ({ findMany });
- const i18nPluginService = {
- getDefaultLocale() {
- return locale;
- },
- find() {
- return [locale];
- },
- } as ToBeFixed;
- const i18nPlugin = {
- service() {
- return i18nPluginService;
- },
- };
- const cachePlugin = {
- service() {
- return cacheCacheStore;
- },
- } as ToBeFixed;
- const store = () =>
- ({
- get() {
- return { i18nEnabled: false };
- },
- } as ToBeFixed);
- const strapi = {
- query,
- plugin(name: string): any {
- return name === "i18n" ? i18nPlugin : cachePlugin;
- },
- store,
- } as ToBeFixed as IStrapi;
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- adminServiceBuilt.getById = jest.fn();
-
- (adminServiceBuilt.getById as jest.Mock).mockResolvedValue({
- id: 1,
- localizations: [{ id: 2 }, { id: 3 }],
- });
-
- // When
- await adminServiceBuilt.purgeNavigationCache(1, true);
-
- // Then
- expect(cacheCacheStore.clearByRegexp).toHaveBeenCalled();
- expect(cacheCacheStore.clearByRegexp.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
- Array [
- /\\\\/api\\\\/navigation\\\\/render\\\\/1/,
- ],
- ],
- ]
- `);
- });
- });
-
- describe("restoreConfig()", () => {
- it("should restore config", async () => {
- // Given
- const pluginStore: Partial = {
- delete: jest.fn().mockResolvedValue(undefined),
- };
- const commonService: Partial = {
- async getPluginStore() {
- return pluginStore as StrapiStore;
- },
- setDefaultConfig: jest.fn().mockResolvedValue(undefined),
- };
- const strapi: Partial = {
- plugin() {
- return {
- service() {
- return commonService;
- },
- } as any;
- },
- };
- const adminServiceBuilt = adminService({ strapi } as StrapiContext);
-
- // When
- await adminServiceBuilt.restoreConfig();
-
- // Then
- expect(pluginStore.delete).toHaveBeenCalled();
- expect(commonService.setDefaultConfig).toHaveBeenCalled();
- });
- });
- });
-});
diff --git a/server/services/admin.ts b/server/services/admin.ts
deleted file mode 100644
index 12e4c661..00000000
--- a/server/services/admin.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-// @ts-ignore
-import { errors } from "@strapi/utils";
-import { differenceBy, get, isEmpty, isNil, isObject } from "lodash";
-import { Id, StrapiContext, StrapiDBQueryArgs } from "strapi-typed";
-import {
- Audience,
- AuditLogContext,
- IAdminService,
- Navigation,
- NavigationItem,
- NavigationItemCustomField,
- NavigationItemEntity,
- NavigationPluginConfig,
- ToBeFixed,
-} from "../../types";
-import {
- ALLOWED_CONTENT_TYPES,
- buildNestedStructure,
- CONTENT_TYPES_NAME_FIELDS_DEFAULTS,
- DEFAULT_POPULATE,
- getPluginModels,
- getPluginService,
- prepareAuditLog,
- RESTRICTED_CONTENT_TYPES,
- sendAuditLog,
- validateAdditionalFields,
-} from "../utils";
-import {
- addI18NConfigFields,
- getI18nStatus,
- I18NConfigFields,
- i18nNavigationContentsCopy,
- i18nNavigationSetupStrategy,
- i18nNavigationItemRead,
-} from "../i18n";
-import { NavigationError } from "../../utils/NavigationError";
-import { addCacheConfigFields } from "../cache/serviceEnhancers";
-import { CacheConfigFields } from "../cache/types";
-
-type SettingsPageConfig = NavigationPluginConfig & I18NConfigFields & CacheConfigFields;
-
-const adminService: (context: StrapiContext) => IAdminService = ({
- strapi,
-}) => ({
- async config(viaSettingsPage = false): Promise {
- const commonService = getPluginService("common");
- const { audienceModel } = getPluginModels();
- const pluginStore = await commonService.getPluginStore();
- const config = await pluginStore.get({
- key: "config",
- });
-
- const additionalFields = config.additionalFields;
- const cascadeMenuAttached = config.cascadeMenuAttached;
- const contentTypesNameFields = config.contentTypesNameFields;
- const contentTypesPopulate = config.contentTypesPopulate;
- const pathDefaultFields = config.pathDefaultFields;
- const allowedLevels = config.allowedLevels;
- const preferCustomContentTypes = config.preferCustomContentTypes;
- const isGQLPluginEnabled = !isNil(strapi.plugin("graphql"));
-
- let extendedResult: Record = {
- allowedContentTypes: ALLOWED_CONTENT_TYPES,
- restrictedContentTypes: RESTRICTED_CONTENT_TYPES,
- };
- const configContentTypes = await commonService.configContentTypes();
- const result = {
- contentTypes: await commonService.configContentTypes(viaSettingsPage),
- contentTypesNameFields: {
- default: CONTENT_TYPES_NAME_FIELDS_DEFAULTS,
- ...(isObject(contentTypesNameFields) ? contentTypesNameFields : {}),
- },
- contentTypesPopulate: isObject(contentTypesPopulate)
- ? contentTypesPopulate
- : {},
- pathDefaultFields: isObject(pathDefaultFields) ? pathDefaultFields : {},
- allowedLevels,
- additionalFields: viaSettingsPage
- ? additionalFields
- : additionalFields.filter(
- (field) => typeof field === "string" || get(field, "enabled", false)
- ),
- gql: {
- navigationItemRelated: configContentTypes.map(({ labelSingular }) =>
- labelSingular.replace(/\s+/g, "")
- ),
- },
- isGQLPluginEnabled: viaSettingsPage ? isGQLPluginEnabled : undefined,
- cascadeMenuAttached,
- preferCustomContentTypes,
- };
- const i18nConfig = await addI18NConfigFields({
- strapi,
- viaSettingsPage,
- previousConfig: {},
- });
- const cacheConfig = await addCacheConfigFields({ strapi, previousConfig: {} })
-
- if (additionalFields.includes("audience")) {
- const audienceItems = await strapi
- .query(audienceModel.uid)
- .findMany({
- limit: Number.MAX_SAFE_INTEGER,
- });
- extendedResult = {
- ...extendedResult,
- availableAudience: audienceItems,
- };
- }
- return {
- ...result,
- ...extendedResult,
- ...i18nConfig,
- ...cacheConfig,
- };
- },
-
- async get(ids, ignoreLocale = false): Promise {
- const { masterModel } = getPluginModels();
- const { enabled: i18nEnabled, locales } = await getI18nStatus({ strapi });
- const whereClause: StrapiDBQueryArgs['where'] = {};
-
- if (ids) {
- whereClause.id = { $in: ids };
- }
-
- let entities = await strapi.query(masterModel.uid).findMany({
- limit: Number.MAX_SAFE_INTEGER,
- populate: DEFAULT_POPULATE,
- where: whereClause,
- });
-
- if (i18nEnabled && !ignoreLocale) {
- entities = entities.reduce((acc, entity) => {
- if (entity.localeCode && locales?.includes(entity.localeCode)) {
- acc.push({
- ...entity,
- localizations: entity.localizations?.filter(({ localeCode }) => localeCode && locales?.includes(localeCode)),
- });
- }
-
- return acc;
- }, [] as Navigation[]);
- }
-
- return entities;
- },
-
- async getById(id: Id): Promise {
- const commonService = getPluginService("common");
-
- const { masterModel, itemModel } = getPluginModels();
- const entity = await strapi
- .query(masterModel.uid)
- .findOne({ where: { id }, populate: DEFAULT_POPULATE });
-
- const entityItems = await strapi
- .query(itemModel.uid)
- .findMany({
- where: {
- master: id,
- },
- limit: Number.MAX_SAFE_INTEGER,
- orderBy: [{ order: "asc" }],
- populate: ["related", "parent", "audience"],
- });
- const entities = await commonService.getRelatedItems(entityItems);
- return {
- ...entity,
- items: buildNestedStructure(entities),
- };
- },
-
- async post(payload: ToBeFixed, auditLog: AuditLogContext) {
- const commonService = getPluginService("common");
- const adminService = getPluginService("admin");
- const { enabled: i18nEnabled, defaultLocale } = await getI18nStatus({
- strapi,
- });
-
- const { masterModel } = getPluginModels();
- const { name, visible } = payload;
- const data = {
- name,
- slug: await commonService.getSlug(name),
- visible,
- localeCode: i18nEnabled && defaultLocale ? defaultLocale : null
- };
-
- const existingEntity = await strapi
- .query(masterModel.uid)
- .create({ data });
-
- const result = await commonService
- .createBranch(payload.items, existingEntity, null, {})
- .then(() => adminService.getById(existingEntity.id))
- .then((newEntity: Navigation) => {
- sendAuditLog(auditLog, "onChangeNavigation", {
- actionType: "CREATE",
- oldEntity: existingEntity,
- newEntity,
- });
- return newEntity;
- });
-
- await commonService.emitEvent(
- masterModel.uid,
- "entry.create",
- existingEntity
- );
-
- if (i18nEnabled && defaultLocale) {
- await i18nNavigationSetupStrategy({ strapi });
- }
-
- return result;
- },
-
- async put(
- id: Id,
- payload: Navigation & { items: ToBeFixed },
- auditLog: AuditLogContext
- ) {
- const adminService = getPluginService("admin");
- const commonService = getPluginService("common");
- const { enabled: i18nEnabled } = await getI18nStatus({ strapi });
-
- const { masterModel } = getPluginModels();
- const { name, visible } = payload;
-
- const existingEntity = await adminService.getById(id);
- const detailsHaveChanged =
- existingEntity.name !== name || existingEntity.visible !== visible;
-
- if (detailsHaveChanged) {
- const newName = detailsHaveChanged ? name : existingEntity.name;
- const newSlug = detailsHaveChanged
- ? await commonService.getSlug(name)
- : existingEntity.slug;
-
- await strapi.query(masterModel.uid).update({
- where: { id },
- data: {
- name: newName,
- slug: newSlug,
- visible,
- },
- });
-
- if (i18nEnabled && existingEntity.localizations) {
- for (const locale of existingEntity.localizations) {
- await strapi.query(masterModel.uid).update({
- where: {
- id: locale.id,
- },
- data: {
- name: newName,
- slug: `${newSlug}-${locale.localeCode}`,
- visible,
- },
- });
- }
- }
- }
- const result = await commonService
- .analyzeBranch(payload.items, existingEntity)
- .then((auditLogsOperations: ToBeFixed) =>
- Promise.all([
- auditLog
- ? prepareAuditLog(
- (auditLogsOperations || []).flat(Number.MAX_SAFE_INTEGER)
- )
- : [],
- adminService.getById(existingEntity.id),
- ])
- )
- .then(([actionType, newEntity]: ToBeFixed) => {
- sendAuditLog(auditLog, "onChangeNavigation", {
- actionType,
- oldEntity: existingEntity,
- newEntity,
- });
- return newEntity;
- });
-
- const navigationEntity = await strapi
- .query(masterModel.uid)
- .findOne({ where: { id } });
- await commonService.emitEvent(
- masterModel.uid,
- "entry.update",
- navigationEntity
- );
- return result;
- },
-
- async delete(id, auditLog) {
- const { masterModel, itemModel } = getPluginModels();
- const entity = await this.getById(id);
- const { enabled: i18nEnabled } = await getI18nStatus({ strapi });
- // TODO: remove when cascade deletion is present
- // NOTE: Delete many with relation `where` crashes ORM
- const cleanNavigationItems = async (masterIds: Array) => {
- if (masterIds.length < 1) {
- return;
- }
-
- const navigationItems = await strapi.query(itemModel.uid).findMany({
- where: {
- $or: masterIds.map((id) => ({ master: id })),
- },
- limit: Number.MAX_SAFE_INTEGER,
- });
-
- await strapi.query(itemModel.uid).deleteMany({
- where: {
- id: navigationItems.map(({ id }) => (id)),
- },
- });
- };
-
- await cleanNavigationItems([id]);
- await strapi.query(masterModel.uid).delete({
- where: {
- id,
- },
- });
-
- if (i18nEnabled && entity.localizations) {
- await cleanNavigationItems(entity.localizations.map(_ => _.id));
- await strapi.query(masterModel.uid).deleteMany({
- where: {
- id: {
- $in: entity.localizations.map((_) => _.id),
- },
- },
- });
- }
-
- sendAuditLog(auditLog, "onNavigationDeletion", {
- entity,
- actionType: "DELETE",
- });
- },
-
- async restart(): Promise {
- setImmediate(() => strapi.reload());
- },
-
- async restoreConfig(): Promise {
- const commonService = getPluginService("common", strapi);
- const pluginStore = await commonService.getPluginStore();
- await pluginStore.delete({ key: "config" });
- await commonService.setDefaultConfig();
- },
-
- async updateConfig(newConfig: NavigationPluginConfig): Promise {
- const commonService = getPluginService("common");
- const pluginStore = await commonService.getPluginStore();
- const config = await pluginStore.get({
- key: "config",
- });
- validateAdditionalFields(newConfig.additionalFields);
- await pluginStore.set({ key: "config", value: newConfig });
-
- const removedFields = differenceBy(
- config.additionalFields,
- newConfig.additionalFields,
- "name"
- ).filter((i) => i !== "audience") as NavigationItemCustomField[];
- if (!isEmpty(removedFields)) {
- await commonService.pruneCustomFields(removedFields);
- }
- },
-
- async fillFromOtherLocale({ target, source, auditLog }) {
- const { enabled } = await getI18nStatus({ strapi });
-
- if (!enabled) {
- throw new NavigationError("Not yet implemented.");
- }
-
- const adminService = getPluginService("admin");
- const commonService = getPluginService("common");
- const targetEntity = await adminService.getById(target);
-
- return await i18nNavigationContentsCopy({
- source: await adminService.getById(source),
- target: targetEntity,
- service: commonService,
- strapi,
- })
- .then(() => adminService.getById(target))
- .then((updated) => {
- sendAuditLog(auditLog, "onChangeNavigation", {
- actionType: "UPDATE",
- oldEntity: targetEntity,
- newEntity: updated,
- });
- return updated;
- });
- },
- async readNavigationItemFromLocale({ source, target, path }) {
- const sourceNavigation = await this.getById(source);
- const targetNavigation = await this.getById(target);
-
- if (!sourceNavigation) {
- throw new errors.NotFoundError(
- "Unable to find source navigation for specified query"
- );
- }
-
- if (!targetNavigation) {
- throw new errors.NotFoundError(
- "Unable to find target navigation for specified query"
- );
- }
-
- return await i18nNavigationItemRead({
- path,
- source: sourceNavigation,
- target: targetNavigation,
- strapi,
- });
- },
-
- async purgeNavigationCache(id, clearLocalisations) {
- const entity = await this.getById(id);
- const regexps = [];
- const mapToRegExp = (id: Id) => new RegExp(`/api/navigation/render/${id}`);
-
- if (!entity) {
- throw new errors.NotFoundError("Navigation is not defined");
- }
-
- if (clearLocalisations) {
- const { enabled: isI18nEnabled } = await getI18nStatus({ strapi });
-
- if (isI18nEnabled) {
- entity.localizations?.forEach((navigation) => {
- regexps.push(mapToRegExp(navigation.id));
- });
- }
- }
-
- const restCachePlugin = strapi.plugin("rest-cache");
- const cacheStore = restCachePlugin.service("cacheStore");
-
- regexps.push(mapToRegExp(id));
-
- await cacheStore.clearByRegexp(regexps);
-
- return { success: true };
- },
-
- async purgeNavigationsCache() {
- const restCachePlugin = strapi.plugin("rest-cache");
- const cacheStore = restCachePlugin.service("cacheStore");
-
- const regex = new RegExp("/api/navigation/render(.*)");
-
- await cacheStore.clearByRegexp([regex]);
-
- return { success: true };
- }
-});
-
-export default adminService;
diff --git a/server/services/client.ts b/server/services/client.ts
deleted file mode 100644
index 50382ecb..00000000
--- a/server/services/client.ts
+++ /dev/null
@@ -1,452 +0,0 @@
-import { first, get, isEmpty, isNil, isString, isArray, last, toNumber } from "lodash";
-import { Id, StrapiContext } from "strapi-typed";
-import { validate } from "uuid";
-import { assertNotEmpty, ContentTypeEntity, IClientService, Navigation, NavigationItem, NavigationItemCustomField, NavigationItemEntity, RFRNavItem, ToBeFixed } from "../../types"
-import { composeItemTitle, getPluginModels, filterByPath, filterOutUnpublished, getPluginService, templateNameFactory, RENDER_TYPES, compareArraysOfNumbers, getCustomFields } from "../utils";
-//@ts-ignore
-import { errors } from '@strapi/utils';
-import { getI18nStatus, i18nAwareEntityReadHandler } from "../i18n";
-import { NavigationError } from "../../utils/NavigationError";
-import { identity, pick } from "lodash/fp";
-
-const clientService: (context: StrapiContext) => IClientService = ({ strapi }) => ({
- async readAll({ locale, orderBy = 'createdAt', orderDirection = "DESC" }) {
- const { masterModel } = getPluginModels();
- const { enabled: i18nEnabled, locales } = await getI18nStatus({ strapi });
-
- let navigations = await strapi
- .query(masterModel.uid)
- .findMany({
- where: locale
- ? {
- localeCode: locale,
- }
- : undefined,
- orderBy: { [orderBy]: orderDirection } as ToBeFixed,
- limit: Number.MAX_SAFE_INTEGER,
- populate: false
- });
-
- if (i18nEnabled) {
- navigations = navigations.reduce((acc, navigation) => {
- if (navigation.localeCode && locales?.includes(navigation.localeCode)) {
- acc.push({
- ...navigation,
- localizations: navigation.localizations?.filter(({ localeCode }) => localeCode && locales?.includes(localeCode)),
- });
- }
-
- return acc;
- }, [] as Navigation[]);
- }
-
- return navigations;
- },
-
- async render({
- idOrSlug,
- type = RENDER_TYPES.FLAT,
- menuOnly = false,
- rootPath = null,
- wrapRelated = false,
- locale,
- populate,
- }) {
- const clientService = getPluginService('client');
-
- const findById = !isNaN(toNumber(idOrSlug)) || validate(idOrSlug as string);
- const criteria = findById ? { id: idOrSlug } : { slug: idOrSlug };
- const itemCriteria = menuOnly ? { menuAttached: true } : {};
- return await clientService.renderType({
- type, criteria, itemCriteria, filter: null, rootPath, wrapRelated, locale, populate
- });
- },
-
- async renderChildren({
- idOrSlug,
- childUIKey,
- type = RENDER_TYPES.FLAT,
- menuOnly = false,
- wrapRelated = false,
- locale,
- }) {
- const clientService = getPluginService('client');
- const findById = !isNaN(toNumber(idOrSlug)) || validate(idOrSlug as string);
- const criteria = findById ? { id: idOrSlug } : { slug: idOrSlug };
- const filter = type === RENDER_TYPES.FLAT ? null : childUIKey;
-
- const itemCriteria = {
- ...(menuOnly && { menuAttached: true }),
- ...(type === RENDER_TYPES.FLAT ? { uiRouterKey: childUIKey } : {}),
- };
-
- return clientService.renderType({ type, criteria, itemCriteria, filter, rootPath: null, wrapRelated, locale });
- },
-
- renderRFR({
- items,
- parent = null,
- parentNavItem = null,
- contentTypes = [],
- enabledCustomFieldsNames,
- }) {
- const clientService = getPluginService('client');
- let pages = {};
- let nav = {};
- let navItems: RFRNavItem[] = [];
-
- items.forEach(item => {
- const { items: itemChilds, ...itemProps } = item;
- const itemNav = clientService.renderRFRNav(itemProps);
- const itemPage = clientService.renderRFRPage(
- itemProps,
- parent,
- enabledCustomFieldsNames,
- );
-
- if (item.type !== "EXTERNAL") {
- pages = {
- ...pages,
- [itemPage.id]: {
- ...itemPage,
- },
- };
- }
-
- if (item.menuAttached) {
- navItems.push(itemNav);
- }
-
- if (!parent) {
- nav = {
- ...nav,
- root: navItems,
- };
- } else {
- const navLevel = navItems
- .filter(navItem => navItem.type !== "EXTERNAL");
- if (!isEmpty(navLevel))
- nav = {
- ...nav,
- [parent]: navLevel.concat(parentNavItem ? parentNavItem : []),
- };
- }
-
- if (!isEmpty(itemChilds)) {
- const { nav: nestedNavs } = clientService.renderRFR({
- items: itemChilds,
- parent: itemPage.id,
- parentNavItem: itemNav,
- contentTypes,
- enabledCustomFieldsNames,
- });
- const { pages: nestedPages } = clientService.renderRFR({
- items: (itemChilds).filter(child => child.type !== "EXTERNAL"),
- parent: itemPage.id,
- parentNavItem: itemNav,
- contentTypes,
- enabledCustomFieldsNames,
- });
- pages = {
- ...pages,
- ...nestedPages,
- };
- nav = {
- ...nav,
- ...nestedNavs,
- };
- }
- });
-
- return {
- pages,
- nav,
- };
- },
-
- renderRFRNav(item): RFRNavItem {
- const { uiRouterKey, title, path, type, audience } = item;
- const itemCommon = {
- label: title,
- type: type,
- audience,
- }
-
- if (type === "EXTERNAL") {
- assertNotEmpty(path, new NavigationError("External navigation item's path is undefined", item));
- return {
- ...itemCommon,
- url: path
- };
- }
-
- if (type === "INTERNAL") {
- return {
- ...itemCommon,
- page: uiRouterKey,
- };
- }
-
- if (type === "WRAPPER") {
- return {
- ...itemCommon,
- }
- }
-
- throw new NavigationError("Unknown item type", item);
- },
-
- renderRFRPage(
- item,
- parent,
- enabledCustomFieldsNames,
- ) {
- const { uiRouterKey, title, path, slug, related, type, audience, menuAttached } = item;
- const { __contentType, id, __templateName } = related || {};
- const contentType = __contentType || '';
- return {
- id: uiRouterKey,
- title,
- templateName: __templateName,
- related: type === "INTERNAL" ? {
- contentType,
- id,
- } : undefined,
- path,
- slug,
- parent,
- audience,
- menuAttached,
- ...enabledCustomFieldsNames.reduce((acc, field) => ({ ...acc, [field]: get(item, field) }), {})
- };
- },
-
- async renderTree(
- items = [],
- id = null,
- field = 'parent',
- path = '',
- itemParser = (i: ToBeFixed) => i,
- ) {
- return (await Promise.all(
- items
- .filter(
- (item) => {
- if (item[field] === null && id === null) {
- return true;
- }
- let data = item[field];
- if (data && typeof id === 'string') {
- data = data.toString();
- }
- if (!!data && typeof data === 'object' && 'id' in data) {
- return data.id === id
- }
-
- return (data && data === id);
- },
- )
- .filter(filterOutUnpublished)
- .map(async (item) => itemParser(
- {
- ...item,
- },
- path,
- field
- )
- )
- )
- )
- .sort((x, y) => {
- if (x.order !== undefined && y.order !== undefined)
- return x.order - y.order;
- else
- return 0;
- });
- },
-
- async renderType({
- type = RENDER_TYPES.FLAT,
- criteria = {},
- itemCriteria = {},
- filter = null,
- rootPath = null,
- wrapRelated = false,
- locale,
- populate,
- }) {
- const clientService = getPluginService('client');
- const adminService = getPluginService('admin');
- const commonService = getPluginService('common');
- const entityWhereClause = {
- ...criteria,
- visible: true,
- }
-
- const { masterModel, itemModel } = getPluginModels();
-
- const entity = await i18nAwareEntityReadHandler({
- entity: await strapi
- .query(masterModel.uid)
- .findOne({
- where: entityWhereClause,
- }),
- entityUid: masterModel.uid,
- strapi,
- whereClause: entityWhereClause,
- localeCode: locale,
- });
-
- if (entity && entity.id) {
- const entities = await strapi.query(itemModel.uid).findMany({
- where: {
- master: entity.id,
- ...itemCriteria,
- },
- limit: Number.MAX_SAFE_INTEGER,
- orderBy: [{ order: 'asc', }],
- populate: ['related', 'audience', 'parent'],
- });
-
- if (!entities) {
- return [];
- }
- const items = await commonService.getRelatedItems(entities, populate);
- const { contentTypes, contentTypesNameFields, additionalFields } = await adminService.config(false);
- const enabledCustomFieldsNames = getCustomFields(additionalFields)
- .reduce((acc, curr) => curr.enabled ? [...acc, curr.name] : acc, []);
-
- const wrapContentType = (itemContentType: ToBeFixed) => wrapRelated && itemContentType ? {
- id: itemContentType.id,
- attributes: { ...itemContentType }
- } : itemContentType;
- const pickMediaFields = pick(["name", "url", "mime", "width", "height", "previewUrl"]);
- const customFieldsDefinitions = additionalFields.filter(_ => typeof _ !== "string") as NavigationItemCustomField[];
-
-
- switch (type) {
- case RENDER_TYPES.TREE:
- case RENDER_TYPES.RFR:
- const getTemplateName = await templateNameFactory(items, strapi, contentTypes);
- const itemParser = async (item: NavigationItemEntity, path = '', field: keyof NavigationItemEntity) => {
- const isExternal = item.type === "EXTERNAL";
- const parentPath = isExternal ? undefined : `${path === '/' ? '' : path}/${first(item.path) === '/'
- ? item.path!.substring(1)
- : item.path}`;
- const slug = isString(parentPath) ? await commonService.getSlug(
- (first(parentPath) === '/' ? parentPath.substring(1) : parentPath).replace(/\//g, '-')) : undefined;
- const lastRelated = isArray(item.related) ? last(item.related) : item.related;
- const relatedContentType = wrapContentType(lastRelated);
- const customFields = enabledCustomFieldsNames.reduce((acc, field) => {
- const mapper = customFieldsDefinitions.find(({ name }) => name === field)?.type === "media"
- ? (_: string) => pickMediaFields(JSON.parse(_))
- : identity;
- const content = get(item, `additionalFields.${field}`);
-
- return { ...acc, [field]: content ? mapper(content) : content }
- }, {});
-
- return {
- id: item.id,
- title: composeItemTitle(item, contentTypesNameFields, contentTypes),
- menuAttached: item.menuAttached,
- order: item.order,
- path: isExternal ? item.externalPath : parentPath,
- type: item.type,
- uiRouterKey: item.uiRouterKey,
- slug: !slug && item.uiRouterKey ? commonService.getSlug(item.uiRouterKey) : slug,
- external: isExternal,
- related: isExternal || !lastRelated ? undefined : {
- ...relatedContentType,
- __templateName: getTemplateName((lastRelated.relatedType || lastRelated.__contentType), lastRelated.id),
- },
- audience: !isEmpty(item.audience) ? item.audience!.map(({ key }) => key) : undefined,
- items: isExternal ? undefined : await clientService.renderTree(
- items,
- item.id,
- field,
- parentPath,
- itemParser,
- ),
- ...customFields
- };
- };
-
- const {
- items: itemsFilteredByPath,
- root: rootElement,
- } = filterByPath(items, rootPath);
-
- const treeStructure = await clientService.renderTree(
- isNil(rootPath) ? items : itemsFilteredByPath,
- get(rootElement, 'parent.id') ?? null,
- 'parent',
- get(rootElement, 'parent.path'),
- itemParser,
- );
-
- const filteredStructure = filter
- ? treeStructure.filter((item: NavigationItem) => item.uiRouterKey === filter)
- : treeStructure;
-
- if (type === RENDER_TYPES.RFR) {
- return clientService.renderRFR({
- items: filteredStructure,
- contentTypes,
- enabledCustomFieldsNames,
- });
- }
- return filteredStructure;
- default:
- const publishedItems = items.filter(filterOutUnpublished);
- const result = isNil(rootPath) ? items : filterByPath(publishedItems, rootPath).items;
-
- const defaultCache = new Map>();
- const getNestedOrders = (id: Id, cache: Map> = defaultCache): Array => {
- const cached = cache.get(id);
- if (!isNil(cached))
- return cached;
-
- const item = result.find(item => item.id === id);
-
- if (isNil(item))
- return [0];
-
- const { order, parent } = item;
-
- const nestedOrders = parent
- ? getNestedOrders(parent.id, cache).concat(order)
- : [order];
-
- cache.set(id, nestedOrders);
-
- return nestedOrders;
- }
-
- return result
- .map(({ additionalFields, autoSync: _, ...item }: NavigationItemEntity) => {
- const customFields = enabledCustomFieldsNames.reduce((acc, field) => {
- const mapper = customFieldsDefinitions.find(({ name }) => name === field)?.type === "media"
- ? (_: string | boolean) => pickMediaFields(JSON.parse(_.toString()))
- : identity;
- const content = get(additionalFields, field);
-
- return { ...acc, [field]: content ? mapper(content) : content }
- }, {});
-
- return ({
- ...item,
- audience: item.audience?.map(_ => (_).key),
- title: composeItemTitle({ ...item, additionalFields }, contentTypesNameFields, contentTypes) || '',
- related: wrapContentType(item.related),//omit(item.related, 'localizations'),
- items: null,
- ...customFields,
- })})
- .sort((a, b) => compareArraysOfNumbers(getNestedOrders(a.id), getNestedOrders(b.id)));
- }
- }
- throw new errors.NotFoundError();
- },
-});
-
-export default clientService;
diff --git a/server/services/common.ts b/server/services/common.ts
deleted file mode 100644
index 70cef9ca..00000000
--- a/server/services/common.ts
+++ /dev/null
@@ -1,492 +0,0 @@
-import { find, get, isEmpty, isNil, last, map, omit, upperFirst } from "lodash";
-import pluralize from "pluralize";
-import { Id, StrapiContentType, StrapiContext, StrapiStore, StringMap } from "strapi-typed";
-//@ts-ignore
-import { sanitize } from '@strapi/utils';
-import { ContentTypeEntity, Effect, ICommonService, LifeCycleHookName, Navigation, NavigationActions, NavigationActionsPerItem, NavigationItem, NavigationItemCustomField, NavigationItemEntity, NavigationItemRelated, NavigationPluginConfig, NestedStructure, RelatedRef, ToBeFixed } from "../../types";
-import { configSetupStrategy } from "../config";
-import { addI18nWhereClause } from "../i18n";
-import { checkDuplicatePath, ContentType, getPluginModels, getPluginService, isContentTypeEligible, KIND_TYPES, parsePopulateQuery, purgeSensitiveData, singularize } from "../utils";
-import slugify from "@sindresorhus/slugify";
-
-type LifecycleHookRecord = Partial>>>;
-
-const lifecycleHookListeners: Record