From 2db812de6b995fc28cc317a3cc7c9be5120de5b5 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 1 Apr 2025 17:35:04 +0200 Subject: [PATCH 01/12] feat: allow addons to register a route without a nav item --- .changeset/light-apes-invent.md | 5 +++++ packages/core/admin/containers/App/index.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/light-apes-invent.md diff --git a/.changeset/light-apes-invent.md b/.changeset/light-apes-invent.md new file mode 100644 index 00000000..98502657 --- /dev/null +++ b/.changeset/light-apes-invent.md @@ -0,0 +1,5 @@ +--- +"strapi-plugin-webtools": minor +--- + +feat: allow addons to register a route without a nav item diff --git a/packages/core/admin/containers/App/index.tsx b/packages/core/admin/containers/App/index.tsx index 0aa12785..973e0c3e 100644 --- a/packages/core/admin/containers/App/index.tsx +++ b/packages/core/admin/containers/App/index.tsx @@ -67,7 +67,7 @@ const App = () => { {routerComponents.length > 0 && ( - {routerComponents.map(({ path, label }) => ( + {routerComponents.map(({ path, label }) => label && ( {label} From 20660d18029f4008283abcabe73b8775705fc6ce Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 1 Apr 2025 17:35:30 +0200 Subject: [PATCH 02/12] chore: delete cypress test for admin plugin --- packages/core/admin/index.cy.tsx | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 packages/core/admin/index.cy.tsx diff --git a/packages/core/admin/index.cy.tsx b/packages/core/admin/index.cy.tsx deleted file mode 100644 index c3a4550b..00000000 --- a/packages/core/admin/index.cy.tsx +++ /dev/null @@ -1,9 +0,0 @@ -/// -// - -describe('Webtools Core', () => { - it('Load the homepage and check if somethings there', () => { - cy.visit('/'); - cy.contains('Welcome to Strapi'); - }); -}); From b20524da1a3ad6da2e04bce42846015106604a8a Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 1 Apr 2025 17:36:16 +0200 Subject: [PATCH 03/12] chore: better dev tools for writing cypress tests --- cypress.config.js | 4 +-- cypress/support/commands.js | 71 +++++++++++++++++++++++++++++++++++++ cypress/support/commands.ts | 27 -------------- 3 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 cypress/support/commands.js delete mode 100644 cypress/support/commands.ts diff --git a/cypress.config.js b/cypress.config.js index dc48cfb9..793cc19c 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -9,7 +9,7 @@ module.exports = defineConfig({ require('cypress-terminal-report/src/installLogsPrinter')(on); }, video: true, - defaultCommandTimeout: 10000, - requestTimeout: 10000, + defaultCommandTimeout: 30000, + requestTimeout: 30000, }, }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 00000000..d6ce8448 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,71 @@ +// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// + +Cypress.Commands.add('login', (path) => { + cy.visit('/'); + + cy.intercept({ + method: 'GET', + url: '/admin/users/me', + }).as('sessionCheck'); + + cy.intercept({ + method: 'GET', + url: '/admin/init', + }).as('adminInit'); + + // Wait for the initial request to complete. + cy.wait('@adminInit').its('response.statusCode').should('equal', 200); + + // Wait for the form to render. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + cy.get('body').then(($body) => { + // Login + if ($body.text().includes('Log in to your Strapi account')) { + cy.get('input[name="email"]').type('johndoe@example.com'); + cy.get('input[name="password"]').type('Abc12345678'); + cy.get('button[type="submit"]').click(); + cy.wait('@sessionCheck').its('response.statusCode').should('equal', 200); + } + // Register + if ($body.text().includes('Credentials are only used to authenticate in Strapi')) { + cy.get('input[name="firstname"]').type('John'); + cy.get('input[name="email"]').type('johndoe@example.com'); + cy.get('input[name="password"]').type('Abc12345678'); + cy.get('input[name="confirmPassword"]').type('Abc12345678'); + cy.get('button[type="submit"]').click(); + cy.wait('@sessionCheck').its('response.statusCode').should('equal', 200); + } + }); +}); + +Cypress.Commands.add('navigateToAdminPage', (path) => { + cy.get('a[href="/admin/plugins/webtools"]').click(); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index e1f54df1..00000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,27 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// From ed9fd3085287a058718d4b80c434275a0be1a53f Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Tue, 1 Apr 2025 17:37:44 +0200 Subject: [PATCH 04/12] fix: remove constraints for running cypress tests --- playground/config/admin.ts | 3 +++ playground/config/env/test/database.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/playground/config/admin.ts b/playground/config/admin.ts index b97b1788..d24df185 100644 --- a/playground/config/admin.ts +++ b/playground/config/admin.ts @@ -10,6 +10,9 @@ export default ({ env }) => ({ salt: env('TRANSFER_TOKEN_SALT'), }, }, + rateLimit: { + enabled: false, + }, flags: { nps: env.bool('FLAG_NPS', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true), diff --git a/playground/config/env/test/database.ts b/playground/config/env/test/database.ts index d8812c29..218d9c32 100644 --- a/playground/config/env/test/database.ts +++ b/playground/config/env/test/database.ts @@ -7,7 +7,8 @@ export default ({ env }) => ({ filename: path.join( __dirname, '..', - // We need to go back once more to get out of the dist folder + '..', + '..', '..', env("DATABASE_TEST_FILENAME", ".tmp/test.db"), ), From 9c99c0d36a3651fe9adfdf30995d98e14acca0a4 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 3 Apr 2025 20:25:05 +0200 Subject: [PATCH 05/12] feat(redirects): Initial commit --- .changeset/rich-meals-happen.md | 5 + packages/addons/redirects/.eslintignore | 13 ++ packages/addons/redirects/.gitignore | 18 ++ packages/addons/redirects/.npmignore | 6 + packages/addons/redirects/LICENSE.md | 7 + packages/addons/redirects/README.md | 77 ++++++ .../admin/components/RedirectForm/index.tsx | 138 +++++++++++ .../admin/helpers/displayedFilters.ts | 30 +++ .../addons/redirects/admin/helpers/getTrad.ts | 5 + .../redirects/admin/helpers/pluginId.ts | 10 + .../admin/helpers/prefixPluginTranslations.js | 11 + .../admin/helpers/useActiveElement.ts | 19 ++ packages/addons/redirects/admin/index.cy.jsx | 46 ++++ packages/addons/redirects/admin/index.ts | 58 +++++ .../addons/redirects/admin/permissions.ts | 12 + .../redirects/admin/screens/Create/index.tsx | 125 ++++++++++ .../redirects/admin/screens/Edit/index.tsx | 147 ++++++++++++ .../components/DeleteConfirmModal/index.tsx | 77 ++++++ .../screens/List/components/Filters/index.tsx | 75 ++++++ .../components/PaginationFooter/index.tsx | 21 ++ .../screens/List/components/Table/index.tsx | 86 +++++++ .../List/components/TableRow/index.tsx | 95 ++++++++ .../screens/List/hooks/useQueryParams.ts | 32 +++ .../redirects/admin/screens/List/index.tsx | 79 +++++++ .../redirects/admin/translations/en.json | 1 + .../redirects/admin/translations/es.json | 1 + .../redirects/admin/translations/index.ts | 9 + .../redirects/admin/translations/nl.json | 1 + .../redirects/admin/translations/tr.json | 1 + .../redirects/admin/types/content-api.ts | 30 +++ .../addons/redirects/admin/types/redirect.ts | 7 + packages/addons/redirects/package.json | 100 ++++++++ packages/addons/redirects/packup.config.ts | 27 +++ packages/addons/redirects/server/bootstrap.ts | 38 +++ packages/addons/redirects/server/config.ts | 18 ++ .../redirects/server/content-types/index.ts | 7 + .../server/content-types/redirect/schema.json | 36 +++ .../controllers/__tests__/redirect.test.js | 46 ++++ .../redirects/server/controllers/index.ts | 5 + .../redirects/server/controllers/redirect.ts | 9 + packages/addons/redirects/server/index.ts | 18 ++ .../__tests__/automated-redirects.test.js | 87 +++++++ .../middleware/__tests__/validation.test.js | 220 ++++++++++++++++++ .../server/middleware/automated-redirects.ts | 41 ++++ .../redirects/server/middleware/validation.ts | 126 ++++++++++ packages/addons/redirects/server/register.ts | 9 + .../addons/redirects/server/routes/index.ts | 80 +++++++ .../redirects/server/services/detect.ts | 52 +++++ .../addons/redirects/server/services/index.ts | 7 + .../redirects/server/services/redirect.ts | 5 + .../server/util/enabledContentTypes.ts | 21 ++ .../redirects/server/util/getPluginService.ts | 15 ++ .../addons/redirects/server/util/pluginId.ts | 10 + packages/addons/redirects/strapi-server.js | 3 + packages/addons/redirects/types | 1 + 55 files changed, 2223 insertions(+) create mode 100644 .changeset/rich-meals-happen.md create mode 100644 packages/addons/redirects/.eslintignore create mode 100644 packages/addons/redirects/.gitignore create mode 100644 packages/addons/redirects/.npmignore create mode 100644 packages/addons/redirects/LICENSE.md create mode 100644 packages/addons/redirects/README.md create mode 100644 packages/addons/redirects/admin/components/RedirectForm/index.tsx create mode 100644 packages/addons/redirects/admin/helpers/displayedFilters.ts create mode 100644 packages/addons/redirects/admin/helpers/getTrad.ts create mode 100644 packages/addons/redirects/admin/helpers/pluginId.ts create mode 100644 packages/addons/redirects/admin/helpers/prefixPluginTranslations.js create mode 100644 packages/addons/redirects/admin/helpers/useActiveElement.ts create mode 100644 packages/addons/redirects/admin/index.cy.jsx create mode 100644 packages/addons/redirects/admin/index.ts create mode 100644 packages/addons/redirects/admin/permissions.ts create mode 100644 packages/addons/redirects/admin/screens/Create/index.tsx create mode 100644 packages/addons/redirects/admin/screens/Edit/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/components/DeleteConfirmModal/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/components/Filters/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/components/PaginationFooter/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/components/Table/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/components/TableRow/index.tsx create mode 100644 packages/addons/redirects/admin/screens/List/hooks/useQueryParams.ts create mode 100644 packages/addons/redirects/admin/screens/List/index.tsx create mode 100644 packages/addons/redirects/admin/translations/en.json create mode 100644 packages/addons/redirects/admin/translations/es.json create mode 100644 packages/addons/redirects/admin/translations/index.ts create mode 100644 packages/addons/redirects/admin/translations/nl.json create mode 100644 packages/addons/redirects/admin/translations/tr.json create mode 100644 packages/addons/redirects/admin/types/content-api.ts create mode 100644 packages/addons/redirects/admin/types/redirect.ts create mode 100644 packages/addons/redirects/package.json create mode 100644 packages/addons/redirects/packup.config.ts create mode 100644 packages/addons/redirects/server/bootstrap.ts create mode 100644 packages/addons/redirects/server/config.ts create mode 100644 packages/addons/redirects/server/content-types/index.ts create mode 100644 packages/addons/redirects/server/content-types/redirect/schema.json create mode 100644 packages/addons/redirects/server/controllers/__tests__/redirect.test.js create mode 100644 packages/addons/redirects/server/controllers/index.ts create mode 100644 packages/addons/redirects/server/controllers/redirect.ts create mode 100644 packages/addons/redirects/server/index.ts create mode 100644 packages/addons/redirects/server/middleware/__tests__/automated-redirects.test.js create mode 100644 packages/addons/redirects/server/middleware/__tests__/validation.test.js create mode 100644 packages/addons/redirects/server/middleware/automated-redirects.ts create mode 100644 packages/addons/redirects/server/middleware/validation.ts create mode 100644 packages/addons/redirects/server/register.ts create mode 100644 packages/addons/redirects/server/routes/index.ts create mode 100644 packages/addons/redirects/server/services/detect.ts create mode 100644 packages/addons/redirects/server/services/index.ts create mode 100644 packages/addons/redirects/server/services/redirect.ts create mode 100644 packages/addons/redirects/server/util/enabledContentTypes.ts create mode 100644 packages/addons/redirects/server/util/getPluginService.ts create mode 100644 packages/addons/redirects/server/util/pluginId.ts create mode 100644 packages/addons/redirects/strapi-server.js create mode 120000 packages/addons/redirects/types diff --git a/.changeset/rich-meals-happen.md b/.changeset/rich-meals-happen.md new file mode 100644 index 00000000..549af6c3 --- /dev/null +++ b/.changeset/rich-meals-happen.md @@ -0,0 +1,5 @@ +--- +"webtools-addon-redirects": major +--- + +Initial release of Webtools Redirects addon diff --git a/packages/addons/redirects/.eslintignore b/packages/addons/redirects/.eslintignore new file mode 100644 index 00000000..d4185808 --- /dev/null +++ b/packages/addons/redirects/.eslintignore @@ -0,0 +1,13 @@ +**/node_modules +**/playground +**/public +**/build +**/dist +**/bundle +**/config +**/scripts +**/docs +**/types/generated +**/__tests__ +strapi-admin.js +strapi-server.js diff --git a/packages/addons/redirects/.gitignore b/packages/addons/redirects/.gitignore new file mode 100644 index 00000000..e7a1942a --- /dev/null +++ b/packages/addons/redirects/.gitignore @@ -0,0 +1,18 @@ +# Don't check auto-generated stuff into git +coverage +node_modules +stats.json +package-lock.json + +# Cruft +.DS_Store +npm-debug.log +.idea + +# Strapi +.strapi-updater.json + +# Production build +build +dist +bundle diff --git a/packages/addons/redirects/.npmignore b/packages/addons/redirects/.npmignore new file mode 100644 index 00000000..572309c0 --- /dev/null +++ b/packages/addons/redirects/.npmignore @@ -0,0 +1,6 @@ +# ignore the .ts and .tsx files +*.ts +*.tsx + +# include the .d.ts files +!*.d.ts diff --git a/packages/addons/redirects/LICENSE.md b/packages/addons/redirects/LICENSE.md new file mode 100644 index 00000000..6c093860 --- /dev/null +++ b/packages/addons/redirects/LICENSE.md @@ -0,0 +1,7 @@ +Copyright (c) 2024 PluginPal. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/addons/redirects/README.md b/packages/addons/redirects/README.md new file mode 100644 index 00000000..69b983f3 --- /dev/null +++ b/packages/addons/redirects/README.md @@ -0,0 +1,77 @@ +
+

Webtools Redirects add-on

+ +

Redirects management in Strapi CMS.

+ +Read the documentation + +

+ + NPM Version + + + Monthly download on NPM + + + CI build status + + + codecov.io + +

+ +
+ +## ✨ Features + +[TODO] + +## ⏳ Installation + +[Read the Getting Started tutorial](https://docs.pluginpal.io/webtools/addons/redirects) or follow the steps below: + +```bash +# using yarn +yarn add webtools-addon-redirects + +# using npm +npm install webtools-addon-redirects --save +``` + +After successful installation you have to rebuild the admin UI so it'll include this plugin. To rebuild and restart Strapi run: + +```bash +# using yarn +yarn build +yarn develop + +# using npm +npm run build +npm run develop +``` + +Enjoy 🎉 + +## 📓 Documentation + +- [Webtools Redirects add-on documentation](https://docs.pluginpal.io/webtools/addons/redirects) + +## 🔌 Addons + +Webtools can be extended by installing addons that hook into the core Webtools functionality. Read more about how addons work and how to install them in the [addons documentation](https://docs.pluginpal.io/webtools/addons). + +## 🔗 Links + +- [PluginPal marketplace](https://www.pluginpal.io/plugin/webtools) +- [NPM package](https://www.npmjs.com/package/webtools-addon-redirects) +- [GitHub repository](https://github.com/pluginpal/strapi-webtools) +- [Strapi marketplace](https://market.strapi.io/plugins/@pluginpal-webtools-core) + +## 🌎 Community support + +- For general help using Strapi, please refer to [the official Strapi documentation](https://strapi.io/documentation/). +- You can contact me on the Strapi Discord [channel](https://discord.strapi.io/). + +## 📝 Resources + +- [MIT License](https://github.com/pluginpal/strapi-webtools/blob/master/LICENSE.md) diff --git a/packages/addons/redirects/admin/components/RedirectForm/index.tsx b/packages/addons/redirects/admin/components/RedirectForm/index.tsx new file mode 100644 index 00000000..847b0215 --- /dev/null +++ b/packages/addons/redirects/admin/components/RedirectForm/index.tsx @@ -0,0 +1,138 @@ +import { translatedErrors } from '@strapi/admin/strapi-admin'; +import { getFetchClient } from '@strapi/strapi/admin'; +import isEmpty from 'lodash/isEmpty'; + +import { + Button, + Field, + Flex, + SingleSelect, + SingleSelectOption, + TextInput, +} from '@strapi/design-system'; +import { Form, Formik } from 'formik'; +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { useQuery } from 'react-query'; +import * as yup from 'yup'; +import { Config } from '../../../server/config'; + +export type RedirectFormValues = { + from: string; + to: string; + status_code: number; +}; + +type Props = { + handleSubmit: (values: RedirectFormValues) => void; + defaultValues?: RedirectFormValues; + remoteErrors?: { [key: string]: string } +}; + +const possibleStatusCodes = [ + 300, + 301, + 302, + 303, + 304, + 305, + 306, + 307, + 308, +]; + +const RedirectForm = (props: Props) => { + const { + handleSubmit, + defaultValues, + remoteErrors, + } = props; + + const { formatMessage } = useIntl(); + const { get } = getFetchClient(); + const data = useQuery('config', async () => get('/webtools/redirects/config')); + + return ( + + initialValues={defaultValues || { from: '', to: '', status_code: data.data?.data?.default_status_code }} + validationSchema={yup.object().shape({ + from: yup.string().required(translatedErrors.required.defaultMessage), + to: yup.string().required(translatedErrors.required.defaultMessage), + status_code: yup.mixed().required(translatedErrors.required.defaultMessage), + })} + onSubmit={handleSubmit} + enableReinitialize + validateOnChange={false} + initialErrors={remoteErrors} + > + {({ + setFieldValue, + values, + errors, + setErrors, + initialErrors, + }) => { + if (!isEmpty(errors)) { + setErrors(errors); + } else if (!isEmpty(initialErrors)) { + setErrors(initialErrors); + } + + return ( +
+ + + + {formatMessage({ id: 'webtools-addon-redirects.form.from.label', defaultMessage: 'From' })} + + setFieldValue('from', e.target.value)} + value={values.from} + /> + + + + + {formatMessage({ id: 'webtools-addon-redirects.form.to.label', defaultMessage: 'To' })} + + setFieldValue('to', e.target.value)} + value={values.to} + /> + + + + + {formatMessage({ id: 'webtools-addon-redirects.form.status_code.label', defaultMessage: 'Status Code' })} + + setFieldValue('status_code', value)} + value={values.status_code} + > + {possibleStatusCodes.map((code) => ( + + {formatMessage({ id: `webtools-addon-redirects.form.status_code.${code}`, defaultMessage: `${code}` })} + + ))} + + + + {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} + + +
+ ); + }} + + ); +}; + +export default RedirectForm; diff --git a/packages/addons/redirects/admin/helpers/displayedFilters.ts b/packages/addons/redirects/admin/helpers/displayedFilters.ts new file mode 100644 index 00000000..895594c7 --- /dev/null +++ b/packages/addons/redirects/admin/helpers/displayedFilters.ts @@ -0,0 +1,30 @@ +const displayedFilters = [ + { + name: 'createdAt', + fieldSchema: { + type: 'date', + }, + metadatas: { label: 'createdAt' }, + }, + { + name: 'updatedAt', + fieldSchema: { + type: 'date', + }, + metadatas: { label: 'updatedAt' }, + }, + { + name: 'mime', + fieldSchema: { + type: 'enumeration', + options: [ + { label: 'image', value: 'image' }, + { label: 'video', value: 'video' }, + { label: 'file', value: 'file' }, + ], + }, + metadatas: { label: 'type' }, + }, +]; + +export default displayedFilters; diff --git a/packages/addons/redirects/admin/helpers/getTrad.ts b/packages/addons/redirects/admin/helpers/getTrad.ts new file mode 100644 index 00000000..28cf39a9 --- /dev/null +++ b/packages/addons/redirects/admin/helpers/getTrad.ts @@ -0,0 +1,5 @@ +import pluginId from './pluginId'; + +const getTrad = (id: string) => `${pluginId}.${id}`; + +export default getTrad; diff --git a/packages/addons/redirects/admin/helpers/pluginId.ts b/packages/addons/redirects/admin/helpers/pluginId.ts new file mode 100644 index 00000000..4f69c433 --- /dev/null +++ b/packages/addons/redirects/admin/helpers/pluginId.ts @@ -0,0 +1,10 @@ +import pluginPkg from '../../package.json'; + +/** + * A helper function to obtain the plugin id. + * + * @return {string} The plugin id. + */ +const pluginId: string = pluginPkg.strapi.name; + +export default pluginId; diff --git a/packages/addons/redirects/admin/helpers/prefixPluginTranslations.js b/packages/addons/redirects/admin/helpers/prefixPluginTranslations.js new file mode 100644 index 00000000..05035866 --- /dev/null +++ b/packages/addons/redirects/admin/helpers/prefixPluginTranslations.js @@ -0,0 +1,11 @@ +const prefixPluginTranslations = (trad, pluginId) => { + if (!pluginId) { + throw new TypeError('pluginId can not be empty'); + } + return Object.keys(trad).reduce((acc, current) => { + acc[`${pluginId}.${current}`] = trad[current]; + return acc; + }, {}); +}; + +export { prefixPluginTranslations }; diff --git a/packages/addons/redirects/admin/helpers/useActiveElement.ts b/packages/addons/redirects/admin/helpers/useActiveElement.ts new file mode 100644 index 00000000..147a3329 --- /dev/null +++ b/packages/addons/redirects/admin/helpers/useActiveElement.ts @@ -0,0 +1,19 @@ +import React from 'react'; + +export default () => { + const [active, setActive] = React.useState(document.activeElement); + + const handleFocusIn = () => { + setActive(document.activeElement); + }; + + React.useEffect(() => { + document.addEventListener('focusin', handleFocusIn); + return () => { + document.removeEventListener('focusin', handleFocusIn); + }; + }, []); + + return active; +}; + diff --git a/packages/addons/redirects/admin/index.cy.jsx b/packages/addons/redirects/admin/index.cy.jsx new file mode 100644 index 00000000..958873f7 --- /dev/null +++ b/packages/addons/redirects/admin/index.cy.jsx @@ -0,0 +1,46 @@ +describe('Redirects', () => { + it('Create, Update, Filter on and Delete a redirect', () => { + // Navigate to redirects list. + cy.login(); + cy.navigateToAdminPage(); + cy.get('a[href="/admin/plugins/webtools/redirects"]').click(); + cy.contains('No redirects were found.'); + + // Create a redirect. + cy.get('button').contains('Add new redirect').click(); + cy.intercept({ + method: 'GET', + url: '/webtools/redirects/config', + }).as('getConfig'); + cy.wait('@getConfig').its('response.statusCode').should('equal', 200); + cy.get('input[name="from"]').type('/old-url'); + cy.get('input[name="to"]').type('/new-url'); + cy.get('button').contains('Save redirect').click(); + cy.contains('The redirect was successfully created.'); + + // Edit a redirect. + cy.get('button').contains('Edit').click({ force: true }); + cy.get('input[name="to"]').clear(); + cy.get('input[name="to"]').type('/another-new-url'); + cy.get('button').contains('Save redirect').click(); + cy.contains('The redirect was successfully updated.'); + + // Filter on a redirect. + cy.contains('No redirects were found.').should('not.exist'); + cy.get('button').contains('Search').click({ force: true }); + cy.get('input[name="search"]').type('/no-url'); + cy.get('input[name="search"]').type('{enter}'); + cy.contains('No redirects were found.').should('exist'); + cy.get('input[name="search"]').clear(); + cy.get('input[name="search"]').type('old'); + cy.get('input[name="search"]').type('{enter}'); + cy.contains('No redirects were found.').should('not.exist'); + + // Delete a redirect. + cy.get('button').contains('Delete').click({ force: true }); + cy.get('div[role="alertdialog"] button').contains('Delete').click(); + cy.contains('The redirect was successfully deleted.'); + cy.get('div[role="alertdialog"]').should('not.exist'); + cy.contains('No redirects were found.'); + }); +}); diff --git a/packages/addons/redirects/admin/index.ts b/packages/addons/redirects/admin/index.ts new file mode 100644 index 00000000..52d98fee --- /dev/null +++ b/packages/addons/redirects/admin/index.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { StrapiApp } from '@strapi/admin/strapi-admin'; +import pluginId from './helpers/pluginId'; +import { prefixPluginTranslations } from './helpers/prefixPluginTranslations'; + +import List from './screens/List'; +import Create from './screens/Create'; +import Edit from './screens/Edit'; + +export default { + register() {}, + bootstrap(app: StrapiApp) { + app.getPlugin('webtools').injectComponent('webtoolsRouter', 'route', { + name: 'settings-route', + // @ts-expect-error + label: 'Redirects', + path: '/redirects', + Component: List, + }); + app.getPlugin('webtools').injectComponent('webtoolsRouter', 'route', { + name: 'settings-route', + // @ts-expect-error + path: '/redirects/new', + Component: Create, + }); + app.getPlugin('webtools').injectComponent('webtoolsRouter', 'route', { + name: 'settings-route', + // @ts-expect-error + path: '/redirects/:id', + Component: Edit, + }); + }, + async registerTrads(app: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { locales } = app; + + const importedTranslations = await Promise.all( + (locales as string[]).map((locale) => { + return import(`./translations/${locale}.json`) + .then(({ default: data }) => { + return { + data: prefixPluginTranslations(data, pluginId), + locale, + }; + }) + .catch(() => { + return { + data: {}, + locale, + }; + }); + }), + ); + + return importedTranslations; + }, +}; diff --git a/packages/addons/redirects/admin/permissions.ts b/packages/addons/redirects/admin/permissions.ts new file mode 100644 index 00000000..534b2e83 --- /dev/null +++ b/packages/addons/redirects/admin/permissions.ts @@ -0,0 +1,12 @@ +const pluginPermissions = { + // This permission regards the main component (App) and is used to tell + // If the plugin link should be displayed in the menu + // And also if the plugin is accessible. This use case is found when a user types the url of the + // plugin directly in the browser + 'settings.list': [{ action: 'plugin::webtools-addon-redirects.settings.list', subject: null }], + 'settings.edit': [{ action: 'plugin::webtools-addon-redirects.settings.edit', subject: null }], + 'settings.create': [{ action: 'plugin::webtools-addon-redirects.settings.create', subject: null }], + 'settings.delete': [{ action: 'plugin::webtools-addon-redirects.settings.delete', subject: null }], +}; + +export default pluginPermissions; diff --git a/packages/addons/redirects/admin/screens/Create/index.tsx b/packages/addons/redirects/admin/screens/Create/index.tsx new file mode 100644 index 00000000..f1681378 --- /dev/null +++ b/packages/addons/redirects/admin/screens/Create/index.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; +import { + useMutation, +} from 'react-query'; + +import { + Box, + Link as DsLink, +} from '@strapi/design-system'; + +import { + Page, + getFetchClient, + Layouts, + useNotification, + useAPIErrorHandler, +} from '@strapi/strapi/admin'; + +import { ArrowLeft } from '@strapi/icons'; +import type { errors } from '@strapi/utils'; + +import { Link, useNavigate } from 'react-router-dom'; + +import pluginPermissions from '../../permissions'; +import RedirectForm, { RedirectFormValues } from '../../components/RedirectForm'; + +export type Pagination = { + page: number; + pageSize: number; + pageCount: number; + total: number; +}; + +type ApiError = + | errors.ApplicationError + | errors.ForbiddenError + | errors.NotFoundError + | errors.NotImplementedError + | errors.PaginationError + | errors.PayloadTooLargeError + | errors.PolicyError + | errors.RateLimitError + | errors.UnauthorizedError + | errors.ValidationError + | errors.YupValidationError; + +const Create = () => { + const { post } = getFetchClient(); + const { toggleNotification } = useNotification(); + const { _unstableFormatValidationErrors: formatValidationErrors } = useAPIErrorHandler(); + const [errors, setErrors] = React.useState<{ [key: string]: string } | null>(null); + const navigate = useNavigate(); + + const { formatMessage } = useIntl(); + + const mutation = useMutation( + (values: RedirectFormValues) => post('/webtools/redirects', { data: values }), + { + onSuccess: () => { + toggleNotification({ type: 'success', message: formatMessage({ id: 'webtools-addon-redirects.settings.success.create', defaultMessage: 'The redirect was successfully created.' }) }); + navigate('/plugins/webtools/redirects'); + }, + onError: (error) => { + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const apiError = error.response?.data?.error as ApiError; + + toggleNotification({ + type: 'danger', + message: + apiError?.message || + formatMessage({ + id: 'notification.error', + defaultMessage: 'An unexpected error occurred', + }), + }); + + if ( + apiError?.name === 'ValidationError' + ) { + setErrors(formatValidationErrors(apiError)); + } + }, + }, + ); + + const handleSubmit = (values: RedirectFormValues) => { + mutation.mutate(values); + }; + + return ( + + } tag={Link} to="/plugins/webtools/redirects"> + {formatMessage({ + id: 'global.back', + defaultMessage: 'Back', + })} + + )} + /> + + + + + + + ); +}; + +export default Create; diff --git a/packages/addons/redirects/admin/screens/Edit/index.tsx b/packages/addons/redirects/admin/screens/Edit/index.tsx new file mode 100644 index 00000000..181d7f87 --- /dev/null +++ b/packages/addons/redirects/admin/screens/Edit/index.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; +import { + useMutation, + useQuery, +} from 'react-query'; + +import { + Box, + Link as DsLink, +} from '@strapi/design-system'; + +import { + Page, + getFetchClient, + Layouts, + useNotification, + useAPIErrorHandler, +} from '@strapi/strapi/admin'; + +import { ArrowLeft } from '@strapi/icons'; +import type { errors } from '@strapi/utils'; + +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import pluginPermissions from '../../permissions'; +import RedirectForm, { RedirectFormValues } from '../../components/RedirectForm'; +import { GenericResponse } from '../../types/content-api'; +import { Redirect } from '../../types/redirect'; + +export type Pagination = { + page: number; + pageSize: number; + pageCount: number; + total: number; +}; + +type ApiError = + | errors.ApplicationError + | errors.ForbiddenError + | errors.NotFoundError + | errors.NotImplementedError + | errors.PaginationError + | errors.PayloadTooLargeError + | errors.PolicyError + | errors.RateLimitError + | errors.UnauthorizedError + | errors.ValidationError + | errors.YupValidationError; + +const Edit = () => { + const { get, put } = getFetchClient(); + const { formatMessage } = useIntl(); + const { toggleNotification } = useNotification(); + const { _unstableFormatValidationErrors: formatValidationErrors } = useAPIErrorHandler(); + const [errors, setErrors] = React.useState<{ [key: string]: string } | null>(null); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + + const redirect = useQuery(['redirect', id], async () => get>(`/webtools/redirects/${id}`)); + + const mutation = useMutation( + (values: RedirectFormValues) => put(`/webtools/redirects/${id}`, { data: values }), + { + onSuccess: () => { + toggleNotification({ type: 'success', message: formatMessage({ id: 'webtools-addon-redirects.settings.success.edit', defaultMessage: 'The redirect was successfully updated.' }) }); + navigate('/plugins/webtools/redirects'); + }, + onError: (error) => { + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const apiError = error.response?.data?.error as ApiError; + + toggleNotification({ + type: 'danger', + message: + apiError?.message || + formatMessage({ + id: 'notification.error', + defaultMessage: 'An unexpected error occurred', + }), + }); + + if ( + apiError?.name === 'ValidationError' + ) { + setErrors(formatValidationErrors(apiError)); + } + }, + }, + ); + + if (redirect.isLoading) { + return ( + + ); + } + + if (redirect.isError) { + return ( + + ); + } + + const handleSubmit = (values: RedirectFormValues) => { + mutation.mutate(values); + }; + + return ( + + } tag={Link} to="/plugins/webtools/redirects"> + {formatMessage({ + id: 'global.back', + defaultMessage: 'Back', + })} + + )} + /> + + + + + + + ); +}; + +export default Edit; diff --git a/packages/addons/redirects/admin/screens/List/components/DeleteConfirmModal/index.tsx b/packages/addons/redirects/admin/screens/List/components/DeleteConfirmModal/index.tsx new file mode 100644 index 00000000..175a0412 --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/components/DeleteConfirmModal/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; + +import { + Dialog, + Flex, + Typography, + Button, +} from '@strapi/design-system'; +import { WarningCircle } from '@strapi/icons'; + +type Props = { + onSubmit: () => void; + children: React.ReactElement; +}; + +const DeleteConfirmModal = (props: Props) => { + const { + onSubmit, + children, + } = props; + + const { formatMessage } = useIntl(); + + return ( + + + {children} + + + + {formatMessage({ + id: 'webtools-addon-redirects.settings.page.list.delete_confirm_modal.title', + defaultMessage: 'Delete item', + })} + + }> + + + + {formatMessage({ + id: 'webtools-addon-redirects.settings.page.list.delete_confirm_modal.body', + defaultMessage: 'Are you sure you want to delete this item?', + })} + + + + + + + + + + + + + ); +}; + +export default DeleteConfirmModal; diff --git a/packages/addons/redirects/admin/screens/List/components/Filters/index.tsx b/packages/addons/redirects/admin/screens/List/components/Filters/index.tsx new file mode 100644 index 00000000..d769bd60 --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/components/Filters/index.tsx @@ -0,0 +1,75 @@ +import React, { + useMemo, +} from 'react'; +import { useIntl } from 'react-intl'; + +import { + Flex, +} from '@strapi/design-system'; + +import { Filters as StrapiFilters, SearchInput } from '@strapi/strapi/admin'; + +const locationFilterOperators = [ + { + label: 'Is', + value: '$eq', + }, + { + label: 'Is not', + value: '$notEq', + }, + { + label: 'Contains', + value: '$contains', + }, +]; + +const Filters = () => { + const { formatMessage } = useIntl(); + + const filters = useMemo(() => { + const newFilters: StrapiFilters.Filter[] = []; + + newFilters.push( + { + label: 'From', + operators: locationFilterOperators, + name: 'from', + type: 'string', + }, + { + label: 'To', + operators: locationFilterOperators, + name: 'to', + type: 'string', + }, + { + label: 'Status Code', + name: 'status_code', + type: 'integer', + }, + ); + + return newFilters; + }, []); + + + return ( + + + + + + + + + ); +}; + +export default Filters; diff --git a/packages/addons/redirects/admin/screens/List/components/PaginationFooter/index.tsx b/packages/addons/redirects/admin/screens/List/components/PaginationFooter/index.tsx new file mode 100644 index 00000000..b192c22c --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/components/PaginationFooter/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Box } from '@strapi/design-system'; +import { Pagination as StrapiPagination } from '@strapi/strapi/admin'; +import type { Pagination } from '../..'; + +type Props = { + pagination: Pagination; +}; + +const PaginationFooter = ({ pagination }: Props) => { + return ( + + + + + + + ); +}; + +export default PaginationFooter; diff --git a/packages/addons/redirects/admin/screens/List/components/Table/index.tsx b/packages/addons/redirects/admin/screens/List/components/Table/index.tsx new file mode 100644 index 00000000..28f9ab7e --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/components/Table/index.tsx @@ -0,0 +1,86 @@ +import React, { + FC, +} from 'react'; +import { useIntl } from 'react-intl'; + +import { + Table, + Tr, + Thead, + Th, + Typography, + Tbody, + EmptyStateLayout, +} from '@strapi/design-system'; + +import TableRow from '../TableRow'; +import PaginationFooter from '../PaginationFooter'; +import type { Pagination } from '../..'; +import Filters from '../Filters'; +import { Redirect } from '../../../../types/redirect'; + +type Props = { + items: Redirect[], + onDelete: () => any, + pagination: Pagination, +}; + +const TableComponent: FC = (props) => { + const { + items, + pagination, + onDelete, + } = props; + + const { formatMessage } = useIntl(); + + return ( +
+ + {items && items.length > 0 ? ( + + + + + + + + + + {items.map((path) => ( + + ))} + +
+ + {formatMessage({ id: 'webtools-addon-redirects.settings.page.list.table.head.from', defaultMessage: 'From' })} + + + + {formatMessage({ id: 'webtools-addon-redirects.settings.page.list.table.head.to', defaultMessage: 'To' })} + + + + {formatMessage({ id: 'webtools-addon-redirects.settings.page.list.table.head.status_code', defaultMessage: 'Status Code' })} + +
+ ) : ( + + )} + +
+ ); +}; + +export default TableComponent; diff --git a/packages/addons/redirects/admin/screens/List/components/TableRow/index.tsx b/packages/addons/redirects/admin/screens/List/components/TableRow/index.tsx new file mode 100644 index 00000000..fbf9bd69 --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/components/TableRow/index.tsx @@ -0,0 +1,95 @@ +import React, { FC } from 'react'; +import { + Typography, + Box, + Tr, + Td, + Flex, + IconButton, +} from '@strapi/design-system'; +import { useNotification, getFetchClient, useRBAC } from '@strapi/strapi/admin'; +import { useIntl } from 'react-intl'; +import { Trash, Pencil } from '@strapi/icons'; +import { useNavigate } from 'react-router-dom'; +import DeleteConfirmModal from '../DeleteConfirmModal'; +import { Redirect } from '../../../../types/redirect'; +import pluginPermissions from '../../../../permissions'; + +type Props = { + row: Redirect; + onDelete?: () => void; +}; + +const TableRow: FC = ({ + row, + onDelete, +}) => { + const { toggleNotification } = useNotification(); + const { + allowedActions: { canEdit, canDelete }, + } = useRBAC(pluginPermissions); + const { del } = getFetchClient(); + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + + const handleDelete = (id: string) => { + del(`/webtools/redirects/${id}`) + .then(() => { + if (onDelete) onDelete(); + toggleNotification({ type: 'success', message: formatMessage({ id: 'webtools-addon-redirects.settings.success.delete', defaultMessage: 'The redirect was successfully deleted.' }) }); + }) + .catch(() => { + if (onDelete) onDelete(); + toggleNotification({ type: 'warning', message: formatMessage({ id: 'notification.error' }) }); + }); + }; + + return ( + + + + {row.from} + + + + + {row.to} + + + + + {row.status_code} + + + + + {canEdit && ( + navigate(`/plugins/webtools/redirects/${row.documentId}`)} + label={formatMessage( + { id: 'webtools-addon-redirects.settings.page.list.table.actions.edit', defaultMessage: 'Edit' }, + )} + > + + + )} + {canDelete && ( + handleDelete(row.documentId)} + > + + + + + )} + + + + ); +}; + +export default TableRow; diff --git a/packages/addons/redirects/admin/screens/List/hooks/useQueryParams.ts b/packages/addons/redirects/admin/screens/List/hooks/useQueryParams.ts new file mode 100644 index 00000000..b8c66866 --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/hooks/useQueryParams.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +const useQueryParams = () => { + const location = useLocation(); + const [params, setParams] = useState(); + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const page = searchParams.get('page'); + const pageSize = searchParams.get('pageSize'); + searchParams.delete('page'); + searchParams.delete('pageSize'); + + + if (!page && !pageSize) { + searchParams.append('pagination[page]', '1'); + searchParams.append('pagination[pageSize]', '10'); + } + + if (page && pageSize) { + searchParams.append('pagination[page]', page); + searchParams.append('pagination[pageSize]', pageSize); + } + + setParams(searchParams.toString()); + }, [location]); + + return params; +}; + +export default useQueryParams; diff --git a/packages/addons/redirects/admin/screens/List/index.tsx b/packages/addons/redirects/admin/screens/List/index.tsx new file mode 100644 index 00000000..6cf39f57 --- /dev/null +++ b/packages/addons/redirects/admin/screens/List/index.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; +import { + useQuery, + useQueryClient, +} from 'react-query'; +import { Button } from '@strapi/design-system'; +import { useNavigate } from 'react-router-dom'; +import { Plus } from '@strapi/icons'; +import { + Page, + getFetchClient, + Layouts, + useRBAC, +} from '@strapi/strapi/admin'; + +import pluginPermissions from '../../permissions'; +import TableComponent from './components/Table'; +import { GenericResponse } from '../../types/content-api'; +import { Redirect } from '../../types/redirect'; +import useQueryParams from './hooks/useQueryParams'; + +export type Pagination = { + page: number; + pageSize: number; + pageCount: number; + total: number; +}; + +const List = () => { + const { get } = getFetchClient(); + const params = useQueryParams(); + const { + allowedActions: { canCreate }, + } = useRBAC(pluginPermissions); + + const items = useQuery(['redirects', params], async () => get>(`/webtools/redirects?${params}`)); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { formatMessage } = useIntl(); + + if (items.isLoading) { + return ( + + ); + } + + if (items.isError) { + return ( + + ); + } + + return ( + + navigate('/plugins/webtools/redirects/new')} startIcon={}> + {formatMessage({ + id: 'webtools-addon-redirects.settings.page.list.button.add', + defaultMessage: 'Add new redirect', + })} + + )} + /> + + queryClient.invalidateQueries('redirects')} + /> + + + ); +}; + +export default List; diff --git a/packages/addons/redirects/admin/translations/en.json b/packages/addons/redirects/admin/translations/en.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/addons/redirects/admin/translations/en.json @@ -0,0 +1 @@ +{} diff --git a/packages/addons/redirects/admin/translations/es.json b/packages/addons/redirects/admin/translations/es.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/addons/redirects/admin/translations/es.json @@ -0,0 +1 @@ +{} diff --git a/packages/addons/redirects/admin/translations/index.ts b/packages/addons/redirects/admin/translations/index.ts new file mode 100644 index 00000000..f695d5af --- /dev/null +++ b/packages/addons/redirects/admin/translations/index.ts @@ -0,0 +1,9 @@ +import en from './en.json'; +import nl from './nl.json'; + +const trads = { + en, + nl, +}; + +export default trads; diff --git a/packages/addons/redirects/admin/translations/nl.json b/packages/addons/redirects/admin/translations/nl.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/addons/redirects/admin/translations/nl.json @@ -0,0 +1 @@ +{} diff --git a/packages/addons/redirects/admin/translations/tr.json b/packages/addons/redirects/admin/translations/tr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/addons/redirects/admin/translations/tr.json @@ -0,0 +1 @@ +{} diff --git a/packages/addons/redirects/admin/types/content-api.ts b/packages/addons/redirects/admin/types/content-api.ts new file mode 100644 index 00000000..5119d31b --- /dev/null +++ b/packages/addons/redirects/admin/types/content-api.ts @@ -0,0 +1,30 @@ +interface DocumentData { + documentId: string; + createdAt: string; + updatedAt: string; + [key: string]: any; +} + +interface Pagination { + page: number; + pageSize: number; + pageCount: number; + total: number; +} + +interface Meta { + pagination?: Pagination; +} + +export interface GenericResponse { + data: T; + meta: Meta; +} + +export interface GenericContentManagerResponse { + results: T; + pagination: Pagination; +} + +export type GenericDocumentResponse = GenericResponse; +export type GenericMultiDocumentResponse = GenericResponse; diff --git a/packages/addons/redirects/admin/types/redirect.ts b/packages/addons/redirects/admin/types/redirect.ts new file mode 100644 index 00000000..05e08bc4 --- /dev/null +++ b/packages/addons/redirects/admin/types/redirect.ts @@ -0,0 +1,7 @@ +export type Redirect = { + id: number + documentId: string + from: string; + to: string; + status_code: number; +}; diff --git a/packages/addons/redirects/package.json b/packages/addons/redirects/package.json new file mode 100644 index 00000000..aeba85e7 --- /dev/null +++ b/packages/addons/redirects/package.json @@ -0,0 +1,100 @@ +{ + "name": "webtools-addon-redirects", + "version": "0.0.0", + "description": "Redirects management in Strapi CMS.", + "strapi": { + "name": "webtools-addon-redirects", + "icon": "list", + "displayName": "Webtools Redirects", + "description": "Redirects management in Strapi CMS.", + "required": false, + "kind": "plugin", + "webtoolsAddon": true, + "addonName": "Redirects" + }, + "files": [ + "dist", + "strapi-server.js" + ], + "exports": { + "./strapi-admin": { + "types": "./dist/admin/index.d.ts", + "source": "./admin/index.ts", + "import": "./dist/admin/index.mjs", + "require": "./dist/admin/index.js", + "default": "./dist/admin/index.js" + }, + "./strapi-server": { + "types": "./dist/server/index.d.ts", + "source": "./server/index.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "pack-up build && yalc push --publish", + "watch": "pack-up watch", + "watch:link": "../../../node_modules/.bin/strapi-plugin watch:link", + "eslint": "../../../node_modules/.bin/eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}'", + "eslint:fix": "../../../node_modules/.bin/eslint --fix './**/*.{js,jsx,ts,tsx}'" + }, + "peerDependencies": { + "@strapi/admin": "^5.0.0", + "@strapi/design-system": "^2.0.0-rc.14", + "@strapi/icons": "^2.0.0-rc.14", + "@strapi/strapi": "^5.0.0", + "@strapi/utils": "^5.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.0.0", + "strapi-plugin-webtools": "^1.4", + "styled-components": "^6.0.0" + }, + "devDependencies": { + "@strapi/admin": "^5.0.0", + "@strapi/design-system": "^2.0.0-rc.14", + "@strapi/icons": "^2.0.0-rc.14", + "@strapi/pack-up": "^5.0.0", + "@strapi/sdk-plugin": "^5.0.0", + "@strapi/strapi": "^5.0.0", + "@strapi/utils": "^5.0.0", + "@types/koa": "^2.15.0", + "@types/lodash": "^4", + "@types/react-copy-to-clipboard": "^5.0.7", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.0.0", + "styled-components": "^6.0.0" + }, + "dependencies": { + "formik": "^2.4.0", + "lodash": "^4.17.21", + "react-copy-to-clipboard": "^5.1.0", + "react-intl": "^6.4.1", + "react-query": "^3.39.3", + "yup": "^0.32.9" + }, + "author": { + "name": "Boaz Poolman", + "email": "boaz@pluginpal.io", + "url": "https://github.com/boazpoolman" + }, + "maintainers": [ + { + "name": "Boaz Poolman", + "email": "boaz@pluginpal.io", + "url": "https://github.com/boazpoolman" + } + ], + "bugs": { + "url": "https://github.com/pluginpal/strapi-webtools/issues" + }, + "homepage": "https://www.pluginpal.io/plugin/webtools", + "engines": { + "node": ">=18.x.x <=20.x.x", + "npm": ">=6.0.0" + }, + "license": "MIT" +} diff --git a/packages/addons/redirects/packup.config.ts b/packages/addons/redirects/packup.config.ts new file mode 100644 index 00000000..3fc65a1f --- /dev/null +++ b/packages/addons/redirects/packup.config.ts @@ -0,0 +1,27 @@ +import { Config, defineConfig } from '@strapi/pack-up'; + +const config: Config = defineConfig({ + bundles: [ + { + source: './admin/index.ts', + import: './dist/admin/index.mjs', + require: './dist/admin/index.js', + runtime: 'web', + }, + { + source: './server/index.ts', + import: './dist/server/index.mjs', + require: './dist/server/index.js', + runtime: 'node', + }, + ], + dist: './dist', + /** + * Because we're exporting a server & client package + * which have different runtimes we want to ignore + * what they look like in the package.json + */ + exports: {}, +}); + +export default config; diff --git a/packages/addons/redirects/server/bootstrap.ts b/packages/addons/redirects/server/bootstrap.ts new file mode 100644 index 00000000..1c7d4fbd --- /dev/null +++ b/packages/addons/redirects/server/bootstrap.ts @@ -0,0 +1,38 @@ +import { Core } from '@strapi/strapi'; + +export default ({ strapi }: { strapi: Core.Strapi }) => { + try { + // Register permission actions. + const actions = [ + { + section: 'plugins', + displayName: 'Access the overview page', + uid: 'settings.list', + pluginName: 'webtools-addon-redirects', + }, + { + section: 'plugins', + displayName: 'Edit existing redirects', + uid: 'settings.edit', + pluginName: 'webtools-addon-redirects', + }, + { + section: 'plugins', + displayName: 'Create new redirects', + uid: 'settings.create', + pluginName: 'webtools-addon-redirects', + }, + { + section: 'plugins', + displayName: 'Delete existing redirects', + uid: 'settings.delete', + pluginName: 'webtools-addon-redirects', + }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (strapi.admin.services.permission.actionProvider.registerMany as (a: any) => void)(actions); + } catch (error) { + strapi.log.error(`Bootstrap failed. ${String(error)}`); + } +}; diff --git a/packages/addons/redirects/server/config.ts b/packages/addons/redirects/server/config.ts new file mode 100644 index 00000000..64546108 --- /dev/null +++ b/packages/addons/redirects/server/config.ts @@ -0,0 +1,18 @@ + +export interface Config { + default_status_code: number; + auto_generate: boolean; +} + +const config: { + default: Config, + validator: () => void +} = { + default: { + default_status_code: 307, + auto_generate: true, + }, + validator() {}, +}; + +export default config; diff --git a/packages/addons/redirects/server/content-types/index.ts b/packages/addons/redirects/server/content-types/index.ts new file mode 100644 index 00000000..98404476 --- /dev/null +++ b/packages/addons/redirects/server/content-types/index.ts @@ -0,0 +1,7 @@ +import redirectSchema from './redirect/schema.json'; + +export default { + redirect: { + schema: redirectSchema, + }, +}; diff --git a/packages/addons/redirects/server/content-types/redirect/schema.json b/packages/addons/redirects/server/content-types/redirect/schema.json new file mode 100644 index 00000000..5df4299e --- /dev/null +++ b/packages/addons/redirects/server/content-types/redirect/schema.json @@ -0,0 +1,36 @@ +{ + "kind": "collectionType", + "collectionName": "wt_redirect", + "info": { + "singularName": "redirect", + "pluralName": "redirects", + "displayName": "Redirect" + }, + "options": { + "draftAndPublish": false, + "comment": "" + }, + "pluginOptions": { + "content-manager": { + "visible": true + }, + "content-type-builder": { + "visible": true + } + }, + "attributes": { + "from": { + "type": "string", + "required": true, + "unique": true + }, + "to": { + "type": "string", + "required": true + }, + "status_code": { + "type": "integer", + "required": true + } + } +} diff --git a/packages/addons/redirects/server/controllers/__tests__/redirect.test.js b/packages/addons/redirects/server/controllers/__tests__/redirect.test.js new file mode 100644 index 00000000..8c3ce9fd --- /dev/null +++ b/packages/addons/redirects/server/controllers/__tests__/redirect.test.js @@ -0,0 +1,46 @@ +import request from 'supertest'; +import { setupStrapi, stopStrapi } from '../../../../../../playground/tests/helpers'; + +beforeAll(async () => { + await setupStrapi(); +}); + +afterAll(async () => { + await stopStrapi(); +}); + +describe('Redirect controller', () => { + it('Should return a transformed response', async () => { + const redirects = await request(strapi.server.httpServer) + .get('/api/webtools/redirects') + .expect(200) + .then((data) => data.body); + + expect(redirects).toHaveProperty('data'); + expect(redirects).toHaveProperty('meta.pagination'); + }); + + it('Should should be filterable', async () => { + await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/new-url', + status_code: 307, + }, + }); + + const premanentRedirects = await request(strapi.server.httpServer) + .get('/api/webtools/redirects?filters[status_code][$eq]=301') + .expect(200) + .then((data) => data.body); + + expect(premanentRedirects).toHaveProperty('data', []); + + const temporaryRedirects = await request(strapi.server.httpServer) + .get('/api/webtools/redirects?filters[status_code][$eq]=307') + .expect(200) + .then((data) => data.body); + + expect(temporaryRedirects.data.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/addons/redirects/server/controllers/index.ts b/packages/addons/redirects/server/controllers/index.ts new file mode 100644 index 00000000..ac1733d5 --- /dev/null +++ b/packages/addons/redirects/server/controllers/index.ts @@ -0,0 +1,5 @@ +import redirect from './redirect'; + +export default { + redirect, +}; diff --git a/packages/addons/redirects/server/controllers/redirect.ts b/packages/addons/redirects/server/controllers/redirect.ts new file mode 100644 index 00000000..6cbdd662 --- /dev/null +++ b/packages/addons/redirects/server/controllers/redirect.ts @@ -0,0 +1,9 @@ +import { factories } from '@strapi/strapi'; + +const contentTypeSlug = 'plugin::webtools-addon-redirects.redirect'; + +export default factories.createCoreController(contentTypeSlug, ({ strapi }) => ({ + config(ctx) { + ctx.body = strapi.config.get('plugin::webtools-addon-redirects'); + }, +})); diff --git a/packages/addons/redirects/server/index.ts b/packages/addons/redirects/server/index.ts new file mode 100644 index 00000000..210825cc --- /dev/null +++ b/packages/addons/redirects/server/index.ts @@ -0,0 +1,18 @@ + +import register from './register'; +import routes from './routes'; +import controllers from './controllers'; +import services from './services'; +import contentTypes from './content-types'; +import bootstrap from './bootstrap'; +import config from './config'; + +export default { + register, + bootstrap, + config, + routes, + controllers, + services, + contentTypes, +}; diff --git a/packages/addons/redirects/server/middleware/__tests__/automated-redirects.test.js b/packages/addons/redirects/server/middleware/__tests__/automated-redirects.test.js new file mode 100644 index 00000000..e0e5c4c0 --- /dev/null +++ b/packages/addons/redirects/server/middleware/__tests__/automated-redirects.test.js @@ -0,0 +1,87 @@ +import { setupStrapi, stopStrapi } from '../../../../../../playground/tests/helpers'; + +beforeAll(async () => { + await setupStrapi(); +}); + +afterAll(async () => { + await stopStrapi(); +}); + +describe('Automated redirects middleware', () => { + it('Should create a redirect when an URL alias changes when auto_generate is set to true', async () => { + strapi.config.set('plugin::webtools-addon-redirects.auto_generate', true); + + let redirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: '/test-url', + }, + }); + + expect(redirect).toBeNull(); + + const alias = await strapi.documents('plugin::webtools.url-alias').create({ + data: { + url_path: '/test-url', + contenttype: 'api::test.test', + }, + }); + + await strapi.documents('plugin::webtools.url-alias').update({ + documentId: alias.documentId, + data: { + url_path: '/new-test-url', + }, + }); + + redirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: '/test-url', + }, + }); + + expect(redirect).toMatchObject({ + from: '/test-url', + to: '/new-test-url', + status_code: 307, + }); + }); + + it('Should gracefully exit if creating a redirect has failed', async () => { + + }); + + it('Should not create a redirect when an URL alias changes when auto_generate is set to false', async () => { + strapi.config.set('plugin::webtools-addon-redirects.auto_generate', false); + + let redirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: '/another-test-url', + }, + }); + + expect(redirect).toBeNull(); + + const alias = await strapi.documents('plugin::webtools.url-alias').create({ + data: { + url_path: '/another-test-url', + contenttype: 'api::test.test', + }, + }); + + await strapi.documents('plugin::webtools.url-alias').update({ + documentId: alias.documentId, + data: { + url_path: '/another-new-test-url', + }, + }); + + redirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: '/another-test-url', + }, + }); + + expect(redirect).toBeNull(); + }); +}); diff --git a/packages/addons/redirects/server/middleware/__tests__/validation.test.js b/packages/addons/redirects/server/middleware/__tests__/validation.test.js new file mode 100644 index 00000000..079d350c --- /dev/null +++ b/packages/addons/redirects/server/middleware/__tests__/validation.test.js @@ -0,0 +1,220 @@ +import { setupStrapi, stopStrapi } from '../../../../../../playground/tests/helpers'; + +beforeAll(async () => { + await setupStrapi(); +}); + +afterAll(async () => { + await stopStrapi(); +}); + +describe('Validation middleware', () => { + it('Should throw if "from" is missing', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + to: '/new-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "from" field is required.', + }); + }); + + it('Should throw if "to" is missing', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "to" field is required.', + }); + }); + + it('Should throw if "status_code" is missing', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/new-url', + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "status_code" field is required.', + }); + }); + + it('Should throw if "from" and "to" are the same', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/old-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "from" and "to" fields cannot be the same.', + }); + }); + + it('Should throw if "from" is an external URL', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: 'https://example.com/old-url', + to: '/new-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "from" field must not be an external URL.', + }); + }); + + it('Should throw if "from" does not start with a leading slash', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: 'old-url', + to: '/new-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "from" field must start with a leading slash.', + }); + }); + + it('Should throw if "to" does not start with a leading slash', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: 'new-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'Internal redirects must start with a leading slash.', + }); + }); + + it('Should not throw if "to" is an external URL', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/external-old-url', + to: 'https://example.com/new-url', + status_code: 307, + }, + }), + ).resolves.not.toThrow(); + }); + + it('Should throw for an invalid redirect status_code', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/new-url', + status_code: 200, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'The "status_code" must be between 300 and 308.', + }); + }); + + it('Should throw if there is already a redirect originating from this "from"', async () => { + await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/new-url', + status_code: 307, + }, + }); + + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/another-new-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'A redirect with the same "from" value already exists.', + }); + }); + + it('Should throw when the redirect would create a loop', async () => { + await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/another-old-url', + to: '/new-url', + status_code: 307, + }, + }); + + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/new-url', + to: '/another-old-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'Creating this redirect would create a loop, please change it.', + }); + }); + + it('Should throw when the redirect would create a chain', async () => { + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/some-other-old-url', + to: '/another-old-url', + status_code: 307, + }, + }), + ).rejects.toMatchObject({ + name: 'ValidationError', + message: 'Creating this redirect would create a chain, please change it.', + }); + }); + + it('Should not throw for required fields when updating the redirect', async () => { + const redirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/yet-another-old-url', + to: '/new-url', + status_code: 307, + }, + }); + + await expect( + strapi.documents('plugin::webtools-addon-redirects.redirect').update({ + documentId: redirect.documentId, + data: { + status_code: 301, + }, + }), + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/addons/redirects/server/middleware/automated-redirects.ts b/packages/addons/redirects/server/middleware/automated-redirects.ts new file mode 100644 index 00000000..98e1f92f --- /dev/null +++ b/packages/addons/redirects/server/middleware/automated-redirects.ts @@ -0,0 +1,41 @@ +import { Modules } from '@strapi/strapi'; + +// eslint-disable-next-line max-len +const automatedRedirectsMiddleware: Modules.Documents.Middleware.Middleware = async (context, next) => { + const { uid, action } = context; + + // Only run this for the URL alias entities. + if (uid !== 'plugin::webtools.url-alias') { + return next(); + } + + // Run this middleware only for the update action. + if (!['update'].includes(action)) { + return next(); + } + + // Run this middleware only if the auto_generate setting is enabled. + if (!strapi.config.get('plugin::webtools-addon-redirects.auto_generate', true)) { + return next(); + } + + const params = context.params as Modules.Documents.ServiceParams<'plugin::webtools.url-alias'>['update'] & { documentId: string }; + + const existingAlias = await strapi.documents('plugin::webtools.url-alias').findOne({ + documentId: params.documentId, + }); + + if (params.data.url_path !== existingAlias.url_path) { + await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: existingAlias.url_path, + to: params.data.url_path, + status_code: strapi.config.get('plugin::webtools-addon-redirects.default_status_code', 307), + }, + }); + } + + return next(); +}; + +export default automatedRedirectsMiddleware; diff --git a/packages/addons/redirects/server/middleware/validation.ts b/packages/addons/redirects/server/middleware/validation.ts new file mode 100644 index 00000000..12fcbaf7 --- /dev/null +++ b/packages/addons/redirects/server/middleware/validation.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-new */ +import { Modules } from '@strapi/strapi'; +import { errors, validateYupSchema, yup } from '@strapi/utils'; +import { getPluginService } from '../util/getPluginService'; + +// eslint-disable-next-line max-len +const validationMiddleware: Modules.Documents.Middleware.Middleware = async (context, next) => { + const { uid, action } = context; + + // Only run this for the redirect entities. + if (uid !== 'plugin::webtools-addon-redirects.redirect') { + return next(); + } + + // Run this middleware only for the update & create action. + if (!['create', 'update'].includes(action)) { + return next(); + } + + const params = context.params as Modules.Documents.ServiceParams<'plugin::webtools-addon-redirects.redirect'>['update' | 'create'] & { documentId: string }; + + const existingRedirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findOne({ + documentId: params.documentId, + }); + + const newRedirect = { + ...existingRedirect, + ...params.data, + } as Modules.Documents.Document<'plugin::webtools-addon-redirects.redirect'>; + + const existingRedirectFromSameLocation = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: params.data.from, + documentId: { + $not: params.documentId, + }, + }, + }); + + let toExternalUrl: boolean; + let fromExternalUrl: boolean; + + try { + new URL(newRedirect.to); + toExternalUrl = true; + } catch { + toExternalUrl = false; + } + + try { + new URL(newRedirect.from); + fromExternalUrl = true; + } catch { + fromExternalUrl = false; + } + + const validator = yup.object().shape({ + from: yup + .string() + .required('The "from" field is required.') + .test( + 'start-with-slash', + 'The "from" field must start with a leading slash.', + (value) => fromExternalUrl || !value || value?.startsWith('/'), + ) + .test( + 'not-external-url', + 'The "from" field must not be an external URL.', + (value) => { + try { + new URL(value || ''); + return false; + } catch { + return true; + } + }, + ) + .test( + 'unique', + 'A redirect with the same "from" value already exists.', + () => !existingRedirectFromSameLocation, + ), + to: yup + .string() + .test( + 'start-with-slash', + 'Internal redirects must start with a leading slash.', + (value) => toExternalUrl || !value || value?.startsWith('/'), + ) + .required('The "to" field is required.') + .notOneOf([yup.ref('from')], 'The "from" and "to" fields cannot be the same.'), + status_code: yup + .number() + .required('The "status_code" field is required.') + .min(300, 'The "status_code" must be between 300 and 308.') + .max(308, 'The "status_code" must be between 300 and 308.'), + }); + + /** + * Simple validations using yup. + */ + await validateYupSchema(validator, { + strict: false, + abortEarly: false, + })(newRedirect); + + /** + * Throw error if the redirect would create a loop. + */ + const loop = await getPluginService('detect').loop(newRedirect); + if (loop) { + throw new errors.ValidationError('Creating this redirect would create a loop, please change it.'); + } + + /** + * Throw error if the redirect would create a chain. + */ + const chain = await getPluginService('detect').chain(newRedirect); + if (chain) { + throw new errors.ValidationError('Creating this redirect would create a chain, please change it.'); + } + + return next(); +}; + +export default validationMiddleware; diff --git a/packages/addons/redirects/server/register.ts b/packages/addons/redirects/server/register.ts new file mode 100644 index 00000000..4b709be0 --- /dev/null +++ b/packages/addons/redirects/server/register.ts @@ -0,0 +1,9 @@ +import { Core } from '@strapi/strapi'; + +import automatedRedirectsMiddleware from './middleware/automated-redirects'; +import validationMiddleware from './middleware/validation'; + +export default ({ strapi }: { strapi: Core.Strapi }) => { + strapi.documents.use(validationMiddleware); + strapi.documents.use(automatedRedirectsMiddleware); +}; diff --git a/packages/addons/redirects/server/routes/index.ts b/packages/addons/redirects/server/routes/index.ts new file mode 100644 index 00000000..d697011e --- /dev/null +++ b/packages/addons/redirects/server/routes/index.ts @@ -0,0 +1,80 @@ +export default { + 'content-api': { + type: 'content-api', + routes: [ + { + method: 'GET', + path: '/webtools/redirects', + handler: 'redirect.find', + config: { + policies: [], + prefix: '', + }, + }, + ], + }, + admin: { + type: 'admin', + routes: [ + { + method: 'GET', + path: '/webtools/redirects', + handler: 'redirect.find', + config: { + policies: [], + prefix: '', + }, + }, + { + method: 'GET', + path: '/webtools/redirects/config', + handler: 'redirect.config', + config: { + policies: [], + prefix: '', + + }, + }, + { + method: 'GET', + path: '/webtools/redirects/:id', + handler: 'redirect.findOne', + config: { + policies: [], + prefix: '', + + }, + }, + { + method: 'DELETE', + path: '/webtools/redirects/:id', + handler: 'redirect.delete', + config: { + policies: [], + prefix: '', + + }, + }, + { + method: 'PUT', + path: '/webtools/redirects/:id', + handler: 'redirect.update', + config: { + policies: [], + prefix: '', + + }, + }, + { + method: 'POST', + path: '/webtools/redirects', + handler: 'redirect.create', + config: { + policies: [], + prefix: '', + + }, + }, + ], + }, +}; diff --git a/packages/addons/redirects/server/services/detect.ts b/packages/addons/redirects/server/services/detect.ts new file mode 100644 index 00000000..e1fc73b1 --- /dev/null +++ b/packages/addons/redirects/server/services/detect.ts @@ -0,0 +1,52 @@ +import { Modules } from '@strapi/strapi'; + +type Redirect = Modules.Documents.Document<'plugin::webtools-addon-redirects.redirect'>; + +const detectLoop = async (redirect: Redirect): Promise => { + let loop = false; + + const findNextRedirectInChain = async (to: string) => { + const chainedRedirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + from: to, + }, + }); + + if (!chainedRedirect) { + return; + } + + if (chainedRedirect.to === redirect.from) { + loop = true; + return; + } + + await findNextRedirectInChain(chainedRedirect.to); + }; + + await findNextRedirectInChain(redirect.to); + + return loop; +}; + +const detectChain = async (redirect: Redirect): Promise => { + const chainedRedirect = await strapi.documents('plugin::webtools-addon-redirects.redirect').findFirst({ + filters: { + $or: [ + { to: redirect.from }, + { from: redirect.to }, + ], + }, + }); + + if (!chainedRedirect) { + return false; + } + + return true; +}; + +export default () => ({ + chain: detectChain, + loop: detectLoop, +}); diff --git a/packages/addons/redirects/server/services/index.ts b/packages/addons/redirects/server/services/index.ts new file mode 100644 index 00000000..b455e1ba --- /dev/null +++ b/packages/addons/redirects/server/services/index.ts @@ -0,0 +1,7 @@ +import detect from './detect'; +import redirect from './redirect'; + +export default { + redirect, + detect, +}; diff --git a/packages/addons/redirects/server/services/redirect.ts b/packages/addons/redirects/server/services/redirect.ts new file mode 100644 index 00000000..bf016f27 --- /dev/null +++ b/packages/addons/redirects/server/services/redirect.ts @@ -0,0 +1,5 @@ +import { factories } from '@strapi/strapi'; + +const contentTypeSlug = 'plugin::webtools-addon-redirects.redirect'; + +export default factories.createCoreService(contentTypeSlug); diff --git a/packages/addons/redirects/server/util/enabledContentTypes.ts b/packages/addons/redirects/server/util/enabledContentTypes.ts new file mode 100644 index 00000000..c0ad9220 --- /dev/null +++ b/packages/addons/redirects/server/util/enabledContentTypes.ts @@ -0,0 +1,21 @@ +import get from 'lodash/get'; +import { Schema } from '@strapi/strapi'; + +import { pluginId } from './pluginId'; + +export const isContentTypeEnabled = (ct: Schema.ContentType) => { + let contentType: Schema.ContentType; + + if (typeof ct === 'string') { + contentType = strapi.contentTypes[ct]; + } else { + contentType = ct; + } + + const { pluginOptions } = contentType; + const enabled = get(pluginOptions, [pluginId, 'enabled'], false) as boolean; + + if (!enabled) return false; + + return true; +}; diff --git a/packages/addons/redirects/server/util/getPluginService.ts b/packages/addons/redirects/server/util/getPluginService.ts new file mode 100644 index 00000000..794f878b --- /dev/null +++ b/packages/addons/redirects/server/util/getPluginService.ts @@ -0,0 +1,15 @@ +import { pluginId } from './pluginId'; +import type config from '..'; + +type Config = typeof config; +type Services = Config['services']; +/** + * A helper function to obtain a plugin service. + * @param {string} name The name of the service. + * + * @return {any} service. + */ +export const getPluginService = (name: ServiceName) => { + const service = strapi.service(`plugin::${pluginId}.${name}`); + return service as ReturnType; +}; diff --git a/packages/addons/redirects/server/util/pluginId.ts b/packages/addons/redirects/server/util/pluginId.ts new file mode 100644 index 00000000..50f2cff1 --- /dev/null +++ b/packages/addons/redirects/server/util/pluginId.ts @@ -0,0 +1,10 @@ + + +import pluginPkg from '../../package.json'; + +/** + * A helper function to obtain the plugin id. + * + * @return {string} The plugin id. + */ +export const pluginId = pluginPkg.strapi.name; diff --git a/packages/addons/redirects/strapi-server.js b/packages/addons/redirects/strapi-server.js new file mode 100644 index 00000000..bf559588 --- /dev/null +++ b/packages/addons/redirects/strapi-server.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./dist/server'); diff --git a/packages/addons/redirects/types b/packages/addons/redirects/types new file mode 120000 index 00000000..2d9ee678 --- /dev/null +++ b/packages/addons/redirects/types @@ -0,0 +1 @@ +../../../playground/types/ \ No newline at end of file From 12102cf45edf29320301d5e143ec30847e17c1b0 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 3 Apr 2025 20:30:06 +0200 Subject: [PATCH 06/12] chore: installation of redirects addon --- README.md | 2 +- package.json | 3 ++- playground/package.json | 1 + playground/src/index.ts | 8 ++++++++ playground/yarn.lock | 7 +++++++ yarn.lock | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4b49089f..633e95b2 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The full documentation of this plugin can be found on it's dedicated documentati - [Webtools core plugin](https://docs.pluginpal.io/webtools) - [Webtools sitemap addon](https://docs.pluginpal.io/webtools/addons/sitemap) - +- [Webtools redirects addon](https://docs.pluginpal.io/webtools/addons/redirects) ## 🔌 Addons diff --git a/package.json b/package.json index 01568862..9776e1bb 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "eslint:fix": "turbo run eslint:fix", "release:publish": "turbo run build && changeset publish", "release:prepare": "changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install", - "playground:install": "cd playground && yarn dlx yalc add --link strapi-plugin-webtools webtools-addon-sitemap && yarn install", + "playground:install": "cd playground && yarn dlx yalc add --link strapi-plugin-webtools webtools-addon-sitemap webtools-addon-redirects && yarn install", "playground:build": "cd playground && yarn build", "playground:start": "cd playground && yarn start", "playground:develop": "rm -rf playground/node_modules/.strapi/ && cd playground && yarn develop --watch-admin --bundler=vite", + "playground:develop:test": "cd playground && rm -rf .tmp/test.db && NODE_ENV=test yarn develop", "docs:start": "cd packages/docs/ && yarn start", "docs:build": "cd packages/docs/ && yarn build", "test:e2e": "cypress open", diff --git a/playground/package.json b/playground/package.json index c0dc51e3..c3affc78 100644 --- a/playground/package.json +++ b/playground/package.json @@ -21,6 +21,7 @@ "react-router-dom": "^6.0.0", "strapi-plugin-webtools": "link:.yalc/strapi-plugin-webtools", "styled-components": "^6.0.0", + "webtools-addon-redirects": "link:.yalc/webtools-addon-redirects", "webtools-addon-sitemap": "link:.yalc/webtools-addon-sitemap" }, "devDependencies": { diff --git a/playground/src/index.ts b/playground/src/index.ts index 54e5675e..b15ea454 100644 --- a/playground/src/index.ts +++ b/playground/src/index.ts @@ -42,6 +42,14 @@ export default { }, }; + publicRole.permissions['plugin::webtools-addon-redirects'] = { + controllers: { + redirect: { + find: { enabled: true }, + }, + }, + }; + publicRole.permissions['api::test'] = { controllers: { test: { diff --git a/playground/yarn.lock b/playground/yarn.lock index 15be141b..2b877eb3 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -11221,6 +11221,7 @@ __metadata: strapi-plugin-webtools: "link:.yalc/strapi-plugin-webtools" styled-components: "npm:^6.0.0" typescript: "npm:^5" + webtools-addon-redirects: "link:.yalc/webtools-addon-redirects" webtools-addon-sitemap: "link:.yalc/webtools-addon-sitemap" languageName: unknown linkType: soft @@ -14362,6 +14363,12 @@ __metadata: languageName: node linkType: hard +"webtools-addon-redirects@link:.yalc/webtools-addon-redirects::locator=playground-5%40workspace%3A.": + version: 0.0.0-use.local + resolution: "webtools-addon-redirects@link:.yalc/webtools-addon-redirects::locator=playground-5%40workspace%3A." + languageName: node + linkType: soft + "webtools-addon-sitemap@link:.yalc/webtools-addon-sitemap::locator=playground-5%40workspace%3A.": version: 0.0.0-use.local resolution: "webtools-addon-sitemap@link:.yalc/webtools-addon-sitemap::locator=playground-5%40workspace%3A." diff --git a/yarn.lock b/yarn.lock index fbea1ded..3eb03ede 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30518,6 +30518,44 @@ __metadata: languageName: node linkType: hard +"webtools-addon-redirects@workspace:packages/addons/redirects": + version: 0.0.0-use.local + resolution: "webtools-addon-redirects@workspace:packages/addons/redirects" + dependencies: + "@strapi/admin": "npm:^5.0.0" + "@strapi/design-system": "npm:^2.0.0-rc.14" + "@strapi/icons": "npm:^2.0.0-rc.14" + "@strapi/pack-up": "npm:^5.0.0" + "@strapi/sdk-plugin": "npm:^5.0.0" + "@strapi/strapi": "npm:^5.0.0" + "@strapi/utils": "npm:^5.0.0" + "@types/koa": "npm:^2.15.0" + "@types/lodash": "npm:^4" + "@types/react-copy-to-clipboard": "npm:^5.0.7" + formik: "npm:^2.4.0" + lodash: "npm:^4.17.21" + react: "npm:^18.0.0" + react-copy-to-clipboard: "npm:^5.1.0" + react-dom: "npm:^18.0.0" + react-intl: "npm:^6.4.1" + react-query: "npm:^3.39.3" + react-router-dom: "npm:^6.0.0" + styled-components: "npm:^6.0.0" + yup: "npm:^0.32.9" + peerDependencies: + "@strapi/admin": ^5.0.0 + "@strapi/design-system": ^2.0.0-rc.14 + "@strapi/icons": ^2.0.0-rc.14 + "@strapi/strapi": ^5.0.0 + "@strapi/utils": ^5.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.0.0 + strapi-plugin-webtools: ^1.4 + styled-components: ^6.0.0 + languageName: unknown + linkType: soft + "webtools-addon-sitemap@workspace:packages/addons/sitemap": version: 0.0.0-use.local resolution: "webtools-addon-sitemap@workspace:packages/addons/sitemap" From 5fc0e849a43498e3562a9bb9a779f680d8717be9 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 3 Apr 2025 20:46:21 +0200 Subject: [PATCH 07/12] chore: add generated redirect types --- playground/types/generated/contentTypes.d.ts | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/playground/types/generated/contentTypes.d.ts b/playground/types/generated/contentTypes.d.ts index 6a99026e..d06ac688 100644 --- a/playground/types/generated/contentTypes.d.ts +++ b/playground/types/generated/contentTypes.d.ts @@ -1050,6 +1050,48 @@ export interface PluginUsersPermissionsUser }; } +export interface PluginWebtoolsAddonRedirectsRedirect + extends Struct.CollectionTypeSchema { + collectionName: 'wt_redirect'; + info: { + displayName: 'Redirect'; + pluralName: 'redirects'; + singularName: 'redirect'; + }; + options: { + comment: ''; + draftAndPublish: false; + }; + pluginOptions: { + 'content-manager': { + visible: true; + }; + 'content-type-builder': { + visible: true; + }; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + from: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.Unique; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'plugin::webtools-addon-redirects.redirect' + > & + Schema.Attribute.Private; + publishedAt: Schema.Attribute.DateTime; + status_code: Schema.Attribute.Integer & Schema.Attribute.Required; + to: Schema.Attribute.String & Schema.Attribute.Required; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface PluginWebtoolsAddonSitemapSitemap extends Struct.CollectionTypeSchema { collectionName: 'wt_sitemap'; @@ -1211,6 +1253,7 @@ declare module '@strapi/strapi' { 'plugin::users-permissions.permission': PluginUsersPermissionsPermission; 'plugin::users-permissions.role': PluginUsersPermissionsRole; 'plugin::users-permissions.user': PluginUsersPermissionsUser; + 'plugin::webtools-addon-redirects.redirect': PluginWebtoolsAddonRedirectsRedirect; 'plugin::webtools-addon-sitemap.sitemap': PluginWebtoolsAddonSitemapSitemap; 'plugin::webtools.url-alias': PluginWebtoolsUrlAlias; 'plugin::webtools.url-pattern': PluginWebtoolsUrlPattern; From 69641d8717ae5a93cbc68b8cd85dff77adc71bbe Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 3 Apr 2025 20:47:08 +0200 Subject: [PATCH 08/12] docs: initial documentation for the redirects addon --- .changeset/plenty-pillows-study.md | 5 ++ packages/docs/docs/addons/introduction.md | 1 + .../addons/redirects/api/document-service.md | 21 +++++++ .../docs/docs/addons/redirects/api/rest.md | 57 ++++++++++++++++++ .../redirects/configuration/auto-generate.md | 13 ++++ .../configuration/default-status-code.md | 13 ++++ .../redirects/configuration/introduction.md | 21 +++++++ .../redirects/getting-started/installation.md | 54 +++++++++++++++++ .../redirects/getting-started/introduction.md | 22 +++++++ .../addons/redirects/getting-started/usage.md | 39 ++++++++++++ packages/docs/sidebars.ts | 51 +++++++++++++++- .../img/assets/addons/redirects/admin.png | Bin 0 -> 51275 bytes 12 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 .changeset/plenty-pillows-study.md create mode 100644 packages/docs/docs/addons/redirects/api/document-service.md create mode 100644 packages/docs/docs/addons/redirects/api/rest.md create mode 100644 packages/docs/docs/addons/redirects/configuration/auto-generate.md create mode 100644 packages/docs/docs/addons/redirects/configuration/default-status-code.md create mode 100644 packages/docs/docs/addons/redirects/configuration/introduction.md create mode 100644 packages/docs/docs/addons/redirects/getting-started/installation.md create mode 100644 packages/docs/docs/addons/redirects/getting-started/introduction.md create mode 100644 packages/docs/docs/addons/redirects/getting-started/usage.md create mode 100644 packages/docs/static/img/assets/addons/redirects/admin.png diff --git a/.changeset/plenty-pillows-study.md b/.changeset/plenty-pillows-study.md new file mode 100644 index 00000000..f3ac61b1 --- /dev/null +++ b/.changeset/plenty-pillows-study.md @@ -0,0 +1,5 @@ +--- +"docs": minor +--- + +Initial documentation for the redirects addon diff --git a/packages/docs/docs/addons/introduction.md b/packages/docs/docs/addons/introduction.md index eac07f68..244f140c 100644 --- a/packages/docs/docs/addons/introduction.md +++ b/packages/docs/docs/addons/introduction.md @@ -12,4 +12,5 @@ To enhance Webtools in a modular way, the core plugin allows addons to be regist + diff --git a/packages/docs/docs/addons/redirects/api/document-service.md b/packages/docs/docs/addons/redirects/api/document-service.md new file mode 100644 index 00000000..ecd2bc74 --- /dev/null +++ b/packages/docs/docs/addons/redirects/api/document-service.md @@ -0,0 +1,21 @@ +--- +sidebar_label: 'Document service' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/api/document-service +--- + +# Document Service + +Redirects can also be created programmatically using the document service of Strapi. + +Example: + +```ts +await strapi.documents('plugin::webtools-addon-redirects.redirect').create({ + data: { + from: '/old-url', + to: '/new-url', + status_code: 307, + }, +}); +``` diff --git a/packages/docs/docs/addons/redirects/api/rest.md b/packages/docs/docs/addons/redirects/api/rest.md new file mode 100644 index 00000000..71b8b7d9 --- /dev/null +++ b/packages/docs/docs/addons/redirects/api/rest.md @@ -0,0 +1,57 @@ +--- +sidebar_label: 'Rest API' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/api/rest +--- + +# REST API + +The plugin exposes a REST API endpoint that you can use to implement redirects in your front-end of choice. This endpoint is essentially a native `find` request and thus behaves the same. Allowing for filtering and fields selection. + + + + + +`GET http://localhost:1337/api/webtools/redirects` + + + + + +```json +{ + "data": [ + { + "id": 10, + "documentId": "ke1s1aroaexv8jt03iuxn81g", + "from": "/old-url", + "to": "/new-url", + "status_code": 307, + "createdAt": "2025-03-09T16:45:24.886Z", + "updatedAt": "2025-03-13T20:38:43.112Z", + }, + { + "id": 14, + "documentId": "f4x8aamrfaec0t5oea408n34", + "from": "/very-old-url", + "from": "/new-url", + "generated": 301, + "createdAt": "2025-03-09T16:45:24.886Z", + "updatedAt": "2025-03-13T20:38:43.112Z", + }, + ], + "meta": { + "pagination": { + "page": 1, + "pageSize": 25, + "pageCount": 1, + "total": 2 + } + } +} + +``` + + + + diff --git a/packages/docs/docs/addons/redirects/configuration/auto-generate.md b/packages/docs/docs/addons/redirects/configuration/auto-generate.md new file mode 100644 index 00000000..b87c6a48 --- /dev/null +++ b/packages/docs/docs/addons/redirects/configuration/auto-generate.md @@ -0,0 +1,13 @@ +--- +sidebar_label: 'Auto generate' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/configuration/auto-generate +--- + +# Auto generate + +This plugin cleverly integrates with the core Webtools plugin. By doing so it is able to automatically create a redirect whenever one of your URL changes. It will create a redirect from the old URL to the new, and thus preventing the old URL from becoming a dead link anywhere it's been used. + +###### Key: `auto_generate ` + +> `required:` NO | `type:` bool | `default:` true diff --git a/packages/docs/docs/addons/redirects/configuration/default-status-code.md b/packages/docs/docs/addons/redirects/configuration/default-status-code.md new file mode 100644 index 00000000..4df902e1 --- /dev/null +++ b/packages/docs/docs/addons/redirects/configuration/default-status-code.md @@ -0,0 +1,13 @@ +--- +sidebar_label: 'Default status code' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/configuration/default-status-code +--- + +# Default status code + +A redirect needs a status code somewhere between `300` and `308` to be valid. If no status code is provided then the plugin will use the default status code. Also auto generated redirects will use the default status code. + +###### Key: `default_status_code ` + +> `required:` YES | `type:` int | `default:` 307 diff --git a/packages/docs/docs/addons/redirects/configuration/introduction.md b/packages/docs/docs/addons/redirects/configuration/introduction.md new file mode 100644 index 00000000..b2425928 --- /dev/null +++ b/packages/docs/docs/addons/redirects/configuration/introduction.md @@ -0,0 +1,21 @@ +--- +sidebar_label: 'Introduction' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/configuration +--- + +# 🔧 Configuration +The configuration of the plugin can be overridden in the `config/plugins.js` file. +In the example below you can see how, and also what the default settings are. + +```md title="config/plugins.js" +module.exports = ({ env }) => ({ + 'webtools-addon-redirects': { + enabled: true, + config: { + default_status_code: 307, + auto_generate: true, + }, + }, +}); +``` diff --git a/packages/docs/docs/addons/redirects/getting-started/installation.md b/packages/docs/docs/addons/redirects/getting-started/installation.md new file mode 100644 index 00000000..f3e9385e --- /dev/null +++ b/packages/docs/docs/addons/redirects/getting-started/installation.md @@ -0,0 +1,54 @@ +--- +sidebar_label: 'Installation' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/installation +--- + +# ⏳ Installation + +:::prerequisites +Complete installation requirements are the exact same as for Strapi itself and can be found in the Strapi documentation. + +Additionally, this plugin requires you to have the **Strapi Webtools plugin** installed. +::: + +### Supported versions + +- Strapi ^5 +- Strapi Webtools ^1.4 + +### Installation + +Install the plugin in your Strapi project. + + + + ``` + yarn add webtools-addon-redirects + ``` + + + ``` + npm install webtools-addon-redirects --save + ``` + + + +After successful installation you have to rebuild the admin UI so it'll include this plugin. To rebuild and restart Strapi run: + + + + ``` + yarn build + yarn develop + ``` + + + ``` + npm run build + npm run develop + ``` + + + +Enjoy 🎉 diff --git a/packages/docs/docs/addons/redirects/getting-started/introduction.md b/packages/docs/docs/addons/redirects/getting-started/introduction.md new file mode 100644 index 00000000..247ae204 --- /dev/null +++ b/packages/docs/docs/addons/redirects/getting-started/introduction.md @@ -0,0 +1,22 @@ +--- +sidebar_label: 'Introduction' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects +--- + +# Webtools Redirects addon + +This plugin offers the ability to configure **URL redirects** in the Strapi admin panel. It integrates with Strapi Webtools for automated redirects generation when your URLs change + +:::note +This plugin acts as an extension of the core `strapi-plugin-webtools`. Please install and configure that before proceeding. +::: + +## ✨ Features + +- **Chain & loop detection** (Chain and loop detection by default) +- **Auto generation** (Creates a redirect if you change the URL of your page) +- **Validations** (Exhaustive validation to prevent faulty redirects) +- **RBAC** (Fine grained RBAC for crud operations) +- **API** (Internal and external APIs for managing your redirects) + diff --git a/packages/docs/docs/addons/redirects/getting-started/usage.md b/packages/docs/docs/addons/redirects/getting-started/usage.md new file mode 100644 index 00000000..7dab6f92 --- /dev/null +++ b/packages/docs/docs/addons/redirects/getting-started/usage.md @@ -0,0 +1,39 @@ +--- +sidebar_label: 'Usage' +displayed_sidebar: webtoolsRedirectsSidebar +slug: /addons/redirects/usage +--- + +# 💡 Usage +This plugin offers the ability to manage your redirects in the Strapi admin panel. You can create, update and delete as many redirects as you need and fetch them using the REST API. + +URL bundle + +## Next.js example implementation + +For the redirects to take effect you need to configure them in your front-end. For example Next.js offers te ability configure redirects in the `next.config.js`. + +You could fetch your redirects like this: + +```ts +const redirects = () => { + return fetch('http://localhost:1337/api/webtools/redirects') + .then(res => res.json()) + .then(response => { + // Use redirects however you need to + }); +}; + +module.exports = redirects; +``` + +And include them in the `next.config.js` like this: + +```ts +const getRedirects = require('./redirects'); + +module.exports = { + // Other configurations... + redirects: () => getRedirects(), +}; +``` diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 3564d633..c665f483 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -48,6 +48,11 @@ const sidebars = { label: "Sitemap addon", href: '/addons/sitemap', }, + { + type: "link", + label: "Redirects addon", + href: '/addons/redirects', + }, ], }, { @@ -80,7 +85,7 @@ const sidebars = { { type: "category", collapsed: false, - label: "🔌 Sitemap addon", + label: "Sitemap addon", items: [ { type: "category", @@ -124,6 +129,50 @@ const sidebars = { ], }, ], + + webtoolsRedirectsSidebar: [ + { + type: "link", + label: "⬅️ Back to Webtools Core docs", + href: "/addons", + }, + { + type: "category", + collapsed: false, + label: "Redirects addon", + items: [ + { + type: "category", + collapsed: false, + label: "🚀 Getting Started", + items: [ + "addons/redirects/getting-started/introduction", + "addons/redirects/getting-started/installation", + "addons/redirects/getting-started/usage", + ], + }, + { + type: "category", + collapsed: false, + label: "📦 API", + items: [ + "addons/redirects/api/rest", + "addons/redirects/api/document-service", + ], + }, + { + type: "category", + collapsed: false, + label: "🔧 Configuration", + items: [ + "addons/redirects/configuration/introduction", + "addons/redirects/configuration/auto-generate", + "addons/redirects/configuration/default-status-code", + ], + }, + ], + }, + ], }; module.exports = sidebars; diff --git a/packages/docs/static/img/assets/addons/redirects/admin.png b/packages/docs/static/img/assets/addons/redirects/admin.png new file mode 100644 index 0000000000000000000000000000000000000000..c79130e1905fe3ad065eeeb66897b2129c3cd0b0 GIT binary patch literal 51275 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 Oqjp70rCjMo*na`XXT|FP literal 0 HcmV?d00001 From 8773d3fe1613d72ae681891ed9312d771a550f4d Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Thu, 3 Apr 2025 22:02:15 +0200 Subject: [PATCH 09/12] fix: redirects cypress tests --- packages/addons/redirects/admin/index.cy.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/addons/redirects/admin/index.cy.jsx b/packages/addons/redirects/admin/index.cy.jsx index 958873f7..57063b6f 100644 --- a/packages/addons/redirects/admin/index.cy.jsx +++ b/packages/addons/redirects/admin/index.cy.jsx @@ -8,11 +8,7 @@ describe('Redirects', () => { // Create a redirect. cy.get('button').contains('Add new redirect').click(); - cy.intercept({ - method: 'GET', - url: '/webtools/redirects/config', - }).as('getConfig'); - cy.wait('@getConfig').its('response.statusCode').should('equal', 200); + cy.contains('307'); cy.get('input[name="from"]').type('/old-url'); cy.get('input[name="to"]').type('/new-url'); cy.get('button').contains('Save redirect').click(); From 4ed9e3b9d5a49ee176df3309c3e8cf506b1bbdc5 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Sun, 6 Apr 2025 16:27:05 +0200 Subject: [PATCH 10/12] feat(links): Initial commit --- packages/addons/links/.eslintignore | 13 +++ packages/addons/links/.gitignore | 18 ++++ packages/addons/links/.npmignore | 6 ++ packages/addons/links/LICENSE.md | 7 ++ packages/addons/links/README.md | 77 +++++++++++++++ .../links/admin/components/Icon/index.tsx | 11 +++ .../links/admin/components/Input/index.tsx | 35 +++++++ .../links/admin/helpers/displayedFilters.ts | 30 ++++++ .../addons/links/admin/helpers/getTrad.ts | 5 + .../addons/links/admin/helpers/pluginId.ts | 10 ++ .../admin/helpers/prefixPluginTranslations.js | 11 +++ .../links/admin/helpers/useActiveElement.ts | 19 ++++ packages/addons/links/admin/index.ts | 61 ++++++++++++ packages/addons/links/admin/permissions.ts | 12 +++ .../addons/links/admin/translations/en.json | 69 +++++++++++++ .../addons/links/admin/translations/es.json | 69 +++++++++++++ .../addons/links/admin/translations/index.ts | 9 ++ .../addons/links/admin/translations/nl.json | 67 +++++++++++++ .../addons/links/admin/translations/tr.json | 69 +++++++++++++ packages/addons/links/package.json | 99 +++++++++++++++++++ packages/addons/links/packup.config.ts | 27 +++++ .../addons/links/server/controllers/index.ts | 5 + .../addons/links/server/controllers/search.ts | 66 +++++++++++++ packages/addons/links/server/index.ts | 10 ++ packages/addons/links/server/register.ts | 9 ++ packages/addons/links/server/routes/index.ts | 19 ++++ .../links/server/util/enabledContentTypes.ts | 21 ++++ .../links/server/util/getPluginService.ts | 15 +++ packages/addons/links/server/util/pluginId.ts | 10 ++ packages/addons/links/strapi-server.js | 3 + packages/addons/links/types | 1 + 31 files changed, 883 insertions(+) create mode 100644 packages/addons/links/.eslintignore create mode 100644 packages/addons/links/.gitignore create mode 100644 packages/addons/links/.npmignore create mode 100644 packages/addons/links/LICENSE.md create mode 100644 packages/addons/links/README.md create mode 100644 packages/addons/links/admin/components/Icon/index.tsx create mode 100644 packages/addons/links/admin/components/Input/index.tsx create mode 100644 packages/addons/links/admin/helpers/displayedFilters.ts create mode 100644 packages/addons/links/admin/helpers/getTrad.ts create mode 100644 packages/addons/links/admin/helpers/pluginId.ts create mode 100644 packages/addons/links/admin/helpers/prefixPluginTranslations.js create mode 100644 packages/addons/links/admin/helpers/useActiveElement.ts create mode 100644 packages/addons/links/admin/index.ts create mode 100644 packages/addons/links/admin/permissions.ts create mode 100644 packages/addons/links/admin/translations/en.json create mode 100644 packages/addons/links/admin/translations/es.json create mode 100644 packages/addons/links/admin/translations/index.ts create mode 100644 packages/addons/links/admin/translations/nl.json create mode 100644 packages/addons/links/admin/translations/tr.json create mode 100644 packages/addons/links/package.json create mode 100644 packages/addons/links/packup.config.ts create mode 100644 packages/addons/links/server/controllers/index.ts create mode 100644 packages/addons/links/server/controllers/search.ts create mode 100644 packages/addons/links/server/index.ts create mode 100644 packages/addons/links/server/register.ts create mode 100644 packages/addons/links/server/routes/index.ts create mode 100644 packages/addons/links/server/util/enabledContentTypes.ts create mode 100644 packages/addons/links/server/util/getPluginService.ts create mode 100644 packages/addons/links/server/util/pluginId.ts create mode 100644 packages/addons/links/strapi-server.js create mode 120000 packages/addons/links/types diff --git a/packages/addons/links/.eslintignore b/packages/addons/links/.eslintignore new file mode 100644 index 00000000..d4185808 --- /dev/null +++ b/packages/addons/links/.eslintignore @@ -0,0 +1,13 @@ +**/node_modules +**/playground +**/public +**/build +**/dist +**/bundle +**/config +**/scripts +**/docs +**/types/generated +**/__tests__ +strapi-admin.js +strapi-server.js diff --git a/packages/addons/links/.gitignore b/packages/addons/links/.gitignore new file mode 100644 index 00000000..e7a1942a --- /dev/null +++ b/packages/addons/links/.gitignore @@ -0,0 +1,18 @@ +# Don't check auto-generated stuff into git +coverage +node_modules +stats.json +package-lock.json + +# Cruft +.DS_Store +npm-debug.log +.idea + +# Strapi +.strapi-updater.json + +# Production build +build +dist +bundle diff --git a/packages/addons/links/.npmignore b/packages/addons/links/.npmignore new file mode 100644 index 00000000..572309c0 --- /dev/null +++ b/packages/addons/links/.npmignore @@ -0,0 +1,6 @@ +# ignore the .ts and .tsx files +*.ts +*.tsx + +# include the .d.ts files +!*.d.ts diff --git a/packages/addons/links/LICENSE.md b/packages/addons/links/LICENSE.md new file mode 100644 index 00000000..6c093860 --- /dev/null +++ b/packages/addons/links/LICENSE.md @@ -0,0 +1,7 @@ +Copyright (c) 2024 PluginPal. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/addons/links/README.md b/packages/addons/links/README.md new file mode 100644 index 00000000..18d6c900 --- /dev/null +++ b/packages/addons/links/README.md @@ -0,0 +1,77 @@ +
+

Webtools Links add-on

+ +

Custom field for internal links in Strapi CMS.

+ +Read the documentation + +

+ + NPM Version + + + Monthly download on NPM + + + CI build status + + + codecov.io + +

+ +
+ +## ✨ Features + +[TODO] + +## ⏳ Installation + +[Read the Getting Started tutorial](https://docs.pluginpal.io/webtools/addons/links) or follow the steps below: + +```bash +# using yarn +yarn add webtools-addon-links + +# using npm +npm install webtools-addon-links --save +``` + +After successful installation you have to rebuild the admin UI so it'll include this plugin. To rebuild and restart Strapi run: + +```bash +# using yarn +yarn build +yarn develop + +# using npm +npm run build +npm run develop +``` + +Enjoy 🎉 + +## 📓 Documentation + +- [Webtools Links add-on documentation](https://docs.pluginpal.io/webtools/addons/links) + +## 🔌 Addons + +Webtools can be extended by installing addons that hook into the core Webtools functionality. Read more about how addons work and how to install them in the [addons documentation](https://docs.pluginpal.io/webtools/addons). + +## 🔗 Links + +- [PluginPal marketplace](https://www.pluginpal.io/plugin/webtools) +- [NPM package](https://www.npmjs.com/package/webtools-addon-links) +- [GitHub repository](https://github.com/pluginpal/strapi-webtools) +- [Strapi marketplace](https://market.strapi.io/plugins/@pluginpal-webtools-core) + +## 🌎 Community support + +- For general help using Strapi, please refer to [the official Strapi documentation](https://strapi.io/documentation/). +- You can contact me on the Strapi Discord [channel](https://discord.strapi.io/). + +## 📝 Resources + +- [MIT License](https://github.com/pluginpal/strapi-webtools/blob/master/LICENSE.md) diff --git a/packages/addons/links/admin/components/Icon/index.tsx b/packages/addons/links/admin/components/Icon/index.tsx new file mode 100644 index 00000000..89f48e9f --- /dev/null +++ b/packages/addons/links/admin/components/Icon/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Link } from '@strapi/icons'; + +const Icon = () => { + return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + ); +}; + +export default Icon; diff --git a/packages/addons/links/admin/components/Input/index.tsx b/packages/addons/links/admin/components/Input/index.tsx new file mode 100644 index 00000000..85d93f61 --- /dev/null +++ b/packages/addons/links/admin/components/Input/index.tsx @@ -0,0 +1,35 @@ +import { InputProps, useField } from '@strapi/admin/strapi-admin'; +import { Field, TextInput } from '@strapi/design-system'; +import * as React from 'react'; + +import { useIntl } from 'react-intl'; + +const Input = (props: InputProps) => { + const { + hint, disabled, labelAction, label, name, required, + } = props; // these are just some of the props passed by the content-manager + const field = useField(name); + + const { formatMessage } = useIntl(); + + const handleChange = (e) => { + field.onChange(name, e.target.value); + }; + + console.log(label); + + return ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control + + {label} + + + ); +}; + +export default Input; diff --git a/packages/addons/links/admin/helpers/displayedFilters.ts b/packages/addons/links/admin/helpers/displayedFilters.ts new file mode 100644 index 00000000..895594c7 --- /dev/null +++ b/packages/addons/links/admin/helpers/displayedFilters.ts @@ -0,0 +1,30 @@ +const displayedFilters = [ + { + name: 'createdAt', + fieldSchema: { + type: 'date', + }, + metadatas: { label: 'createdAt' }, + }, + { + name: 'updatedAt', + fieldSchema: { + type: 'date', + }, + metadatas: { label: 'updatedAt' }, + }, + { + name: 'mime', + fieldSchema: { + type: 'enumeration', + options: [ + { label: 'image', value: 'image' }, + { label: 'video', value: 'video' }, + { label: 'file', value: 'file' }, + ], + }, + metadatas: { label: 'type' }, + }, +]; + +export default displayedFilters; diff --git a/packages/addons/links/admin/helpers/getTrad.ts b/packages/addons/links/admin/helpers/getTrad.ts new file mode 100644 index 00000000..28cf39a9 --- /dev/null +++ b/packages/addons/links/admin/helpers/getTrad.ts @@ -0,0 +1,5 @@ +import pluginId from './pluginId'; + +const getTrad = (id: string) => `${pluginId}.${id}`; + +export default getTrad; diff --git a/packages/addons/links/admin/helpers/pluginId.ts b/packages/addons/links/admin/helpers/pluginId.ts new file mode 100644 index 00000000..4f69c433 --- /dev/null +++ b/packages/addons/links/admin/helpers/pluginId.ts @@ -0,0 +1,10 @@ +import pluginPkg from '../../package.json'; + +/** + * A helper function to obtain the plugin id. + * + * @return {string} The plugin id. + */ +const pluginId: string = pluginPkg.strapi.name; + +export default pluginId; diff --git a/packages/addons/links/admin/helpers/prefixPluginTranslations.js b/packages/addons/links/admin/helpers/prefixPluginTranslations.js new file mode 100644 index 00000000..05035866 --- /dev/null +++ b/packages/addons/links/admin/helpers/prefixPluginTranslations.js @@ -0,0 +1,11 @@ +const prefixPluginTranslations = (trad, pluginId) => { + if (!pluginId) { + throw new TypeError('pluginId can not be empty'); + } + return Object.keys(trad).reduce((acc, current) => { + acc[`${pluginId}.${current}`] = trad[current]; + return acc; + }, {}); +}; + +export { prefixPluginTranslations }; diff --git a/packages/addons/links/admin/helpers/useActiveElement.ts b/packages/addons/links/admin/helpers/useActiveElement.ts new file mode 100644 index 00000000..147a3329 --- /dev/null +++ b/packages/addons/links/admin/helpers/useActiveElement.ts @@ -0,0 +1,19 @@ +import React from 'react'; + +export default () => { + const [active, setActive] = React.useState(document.activeElement); + + const handleFocusIn = () => { + setActive(document.activeElement); + }; + + React.useEffect(() => { + document.addEventListener('focusin', handleFocusIn); + return () => { + document.removeEventListener('focusin', handleFocusIn); + }; + }, []); + + return active; +}; + diff --git a/packages/addons/links/admin/index.ts b/packages/addons/links/admin/index.ts new file mode 100644 index 00000000..97afa168 --- /dev/null +++ b/packages/addons/links/admin/index.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { StrapiApp } from '@strapi/admin/strapi-admin'; +import pluginId from './helpers/pluginId'; +import { prefixPluginTranslations } from './helpers/prefixPluginTranslations'; + +import Icon from './components/Icon'; + +export default { + register(app: StrapiApp) { + app.customFields.register({ + name: 'link', + pluginId: 'webtools-addon-links', // the custom field is created by a webtools-addon-links plugin + type: 'string', // the link will be stored as a string + intlLabel: { + id: 'webtools-addon-links.link.label', + defaultMessage: 'Link', + }, + intlDescription: { + id: 'webtools-addon-links.link.description', + defaultMessage: 'For internal and external links', + }, + icon: Icon, // don't forget to create/import your icon component + components: { + Input: async () => import( + /* webpackChunkName: "input-component" */ "./components/Input" + ), + }, + options: { + // declare options here + }, + }); + }, + bootstrap() { + + }, + async registerTrads(app: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { locales } = app; + + const importedTranslations = await Promise.all( + (locales as string[]).map((locale) => { + return import(`./translations/${locale}.json`) + .then(({ default: data }) => { + return { + data: prefixPluginTranslations(data, pluginId), + locale, + }; + }) + .catch(() => { + return { + data: {}, + locale, + }; + }); + }), + ); + + return importedTranslations; + }, +}; diff --git a/packages/addons/links/admin/permissions.ts b/packages/addons/links/admin/permissions.ts new file mode 100644 index 00000000..3566f90f --- /dev/null +++ b/packages/addons/links/admin/permissions.ts @@ -0,0 +1,12 @@ +const pluginPermissions = { + // This permission regards the main component (App) and is used to tell + // If the plugin link should be displayed in the menu + // And also if the plugin is accessible. This use case is found when a user types the url of the + // plugin directly in the browser + 'settings.list': [{ action: 'plugin::webtools.settings.list', subject: null }], + // 'settings.overview': [{ action: 'plugin::webtools.settings.overview', subject: null }], + 'settings.patterns': [{ action: 'plugin::webtools.settings.patterns', subject: null }], + 'edit-view.sidebar': [{ action: 'plugin::webtools.edit-view.sidebar', subject: null }], +}; + +export default pluginPermissions; diff --git a/packages/addons/links/admin/translations/en.json b/packages/addons/links/admin/translations/en.json new file mode 100644 index 00000000..e2f7556c --- /dev/null +++ b/packages/addons/links/admin/translations/en.json @@ -0,0 +1,69 @@ +{ + "settings.title": "Webtools", + "settings.loading": "Loading content...", + + "settings.success.create": "The pattern has been created.", + "settings.success.edit": "The pattern has been updated.", + "settings.success.delete": "The pattern has been deleted.", + "settings.success.url_alias.generate": "{{ count }} URL aliases have been generated.", + "settings.success.url_alias.delete": "The URL alias has been deleted.", + + "settings.button.add_pattern": "Add new pattern", + "settings.button.generate_paths": "Bulk generate", + "settings.button.delete": "Delete", + "settings.button.cancel": "Cancel", + "settings.button.filters": "Filters", + "settings.button.copy_permalink": "Copy permalink", + + "settings.form.label.label": "Label", + "settings.form.pattern.label": "Pattern", + "settings.form.pattern.description_1": "Create a URL alias pattern", + "settings.form.pattern.description_2": "using", + "settings.form.pattern.description_3": "or", + "settings.form.contenttype.label": "Content type", + + "settings.page.list.title": "All URLs", + "settings.page.list.description": "A list of all the known URL aliases.", + "settings.page.list.body": "List all URL aliases", + + "settings.page.list.delete_confirm_modal.title": "Delete item", + "settings.page.list.delete_confirm_modal.body": "Are you sure you want to delete this item?", + + "settings.page.list.generate_paths_modal.title": "Generate URL aliases", + "settings.page.list.generate_paths_modal.types.title": "Content types", + "settings.page.list.generate_paths_modal.types.body": "Select the content types you want to generate the URLs for.", + "settings.page.list.generate_paths_modal.generation_type.title": "Generation type", + "settings.page.list.generate_paths_modal.generation_type.body": "Select how you would like to generate the URLs.", + "settings.page.list.generate_paths_modal.generation_type.only_without_alias": "Generate only for pages without an URL alias", + "settings.page.list.generate_paths_modal.generation_type.only_generated": "Re-generate only URL alias that were auto-generated", + "settings.page.list.generate_paths_modal.generation_type.all": "Re-generate all URL aliases", + + "settings.page.list.filters.label": "Search", + "settings.page.list.filters.placeholder": "Search...", + + "settings.page.list.table.empty": "You don't have any URL paths yet.", + + "settings.page.list.table.actions.delete": "Delete {target}", + "settings.page.list.table.actions.goTo": "Go to corresponding entity of {target}", + + "settings.page.patterns.title": "URL patterns", + "settings.page.patterns.description": "A list of all the known URL alias patterns.", + + "settings.page.patterns.table.head.label": "Label", + "settings.page.patterns.table.head.pattern": "Pattern", + "settings.page.patterns.table.head.actions": "Actions", + + "settings.page.patterns.table.empty": "You don't have any patterns yet.", + "settings.page.patterns.table.actions.edit": "Edit", + "settings.page.patterns.table.actions.delete": "Delete", + + "settings.page.patterns.edit.title": "Edit pattern", + "settings.page.patterns.edit.subtitle": "Pattern details", + "settings.page.patterns.edit.description": "Edit this pattern for automatic URL alias generation.", + + "settings.page.patterns.create.title": "Add new pattern", + "settings.page.patterns.create.subtitle": "Pattern details", + "settings.page.patterns.create.description": "Add a pattern for automatic URL alias generation.", + + "notification.success.permalink_copied": "Permalinkk copied to the clipboard" +} diff --git a/packages/addons/links/admin/translations/es.json b/packages/addons/links/admin/translations/es.json new file mode 100644 index 00000000..d38fd02d --- /dev/null +++ b/packages/addons/links/admin/translations/es.json @@ -0,0 +1,69 @@ +{ + "settings.title": "Herramientas Web", + "settings.loading": "Cargando contenido...", + + "settings.success.create": "El patrón ha sido creado.", + "settings.success.edit": "El patrón ha sido actualizado.", + "settings.success.delete": "El patrón ha sido eliminado.", + "settings.success.url_alias.generate": "{{ count }} alias de URL han sido generados.", + "settings.success.url_alias.delete": "El alias de URL ha sido eliminado.", + + "settings.button.add_pattern": "Agregar nuevo patrón", + "settings.button.generate_paths": "Generar en masa", + "settings.button.delete": "Eliminar", + "settings.button.cancel": "Cancelar", + "settings.button.filters": "Filtros", + "settings.button.copy_permalink": "Copiar enlace permanente", + + "settings.form.label.label": "Etiqueta", + "settings.form.pattern.label": "Patrón", + "settings.form.pattern.description_1": "Crear un patrón de alias de URL", + "settings.form.pattern.description_2": "usando", + "settings.form.pattern.description_3": "o", + "settings.form.contenttype.label": "Tipo de contenido", + + "settings.page.list.title": "Todas las URLs", + "settings.page.list.description": "Una lista de todos los alias de URL conocidos.", + "settings.page.list.body": "Listar todos los alias de URL", + + "settings.page.list.delete_confirm_modal.title": "Eliminar elemento", + "settings.page.list.delete_confirm_modal.body": "¿Estás seguro de que deseas eliminar este elemento?", + + "settings.page.list.generate_paths_modal.title": "Generar alias de URL", + "settings.page.list.generate_paths_modal.types.title": "Tipos de contenido", + "settings.page.list.generate_paths_modal.types.body": "Selecciona los tipos de contenido para los que deseas generar las URLs.", + "settings.page.list.generate_paths_modal.generation_type.title": "Tipo de generación", + "settings.page.list.generate_paths_modal.generation_type.body": "Selecciona cómo te gustaría generar las URLs.", + "settings.page.list.generate_paths_modal.generation_type.only_without_alias": "Generar solo para páginas sin un alias de URL", + "settings.page.list.generate_paths_modal.generation_type.only_generated": "Regenerar solo alias de URL que fueron auto-generados", + "settings.page.list.generate_paths_modal.generation_type.all": "Regenerar todos los alias de URL", + + "settings.page.list.filters.label": "Buscar", + "settings.page.list.filters.placeholder": "Buscar...", + + "settings.page.list.table.empty": "Aún no tienes ningún camino de URL.", + + "settings.page.list.table.actions.delete": "Eliminar {target}", + "settings.page.list.table.actions.goTo": "Ir a la entidad correspondiente de {target}", + + "settings.page.patterns.title": "Patrones de URL", + "settings.page.patterns.description": "Una lista de todos los patrones de alias de URL conocidos.", + + "settings.page.patterns.table.head.label": "Etiqueta", + "settings.page.patterns.table.head.pattern": "Patrón", + "settings.page.patterns.table.head.actions": "Acciones", + + "settings.page.patterns.table.empty": "Aún no tienes ningún patrón.", + "settings.page.patterns.table.actions.edit": "Editar {target}", + "settings.page.patterns.table.actions.delete": "Eliminar {target}", + + "settings.page.patterns.edit.title": "Editar patrón", + "settings.page.patterns.edit.subtitle": "Detalles del patrón", + "settings.page.patterns.edit.description": "Editar este patrón para la generación automática de alias de URL.", + + "settings.page.patterns.create.title": "Agregar nuevo patrón", + "settings.page.patterns.create.subtitle": "Detalles del patrón", + "settings.page.patterns.create.description": "Agregar un patrón para la generación automática de alias de URL.", + + "notification.success.permalink_copied": "Enlace permanente copiado al portapapeles" +} diff --git a/packages/addons/links/admin/translations/index.ts b/packages/addons/links/admin/translations/index.ts new file mode 100644 index 00000000..f695d5af --- /dev/null +++ b/packages/addons/links/admin/translations/index.ts @@ -0,0 +1,9 @@ +import en from './en.json'; +import nl from './nl.json'; + +const trads = { + en, + nl, +}; + +export default trads; diff --git a/packages/addons/links/admin/translations/nl.json b/packages/addons/links/admin/translations/nl.json new file mode 100644 index 00000000..a20d93bd --- /dev/null +++ b/packages/addons/links/admin/translations/nl.json @@ -0,0 +1,67 @@ +{ + "settings.title": "Webtools", + "settings.loading": "Inhoud geladen...", + + "settings.success.create": "Het patroon is aangemaakt", + "settings.success.edit": "Het patroon is bijgewerkt.", + "settings.success.delete": "Het patroon is verwijderd.", + + "settings.button.add_pattern": "Nieuw patroon toevoegen", + "settings.button.generate_paths": "Bulk genereren", + "settings.button.delete": "Verwijder", + "settings.button.cancel": "Annuleer", + "settings.button.filters": "Filters", + "settings.button.copy_permalink": "Kopieer permalink", + + "settings.form.label.label": "Label", + "settings.form.pattern.label": "Patroon", + "settings.form.pattern.description_1": "Maak een URL-aliaspatroon", + "settings.form.pattern.description_2": "gebruikt", + "settings.form.pattern.description_3": "of", + "settings.form.contenttype.label": "Inhoudstype", + + "settings.page.list.title": "Alle URL's", + "settings.page.list.description": "Een lijst met alle bekende URL-aliassen.", + "settings.page.list.body": "Laat alle URL-aliassen zien", + + "settings.page.list.delete_confirm_modal.title": "Item verwijderen", + "settings.page.list.delete_confirm_modal.body": "Weet je zeker dat je dit item wilt verwijderen?", + + "settings.page.list.generate_paths_modal.title": "URL-aliassen genereren", + "settings.page.list.generate_paths_modal.types.title": "Inhoudstypen", + "settings.page.list.generate_paths_modal.types.body": "Selecteer de inhoudstypen waarvoor u de URL's wilt genereren.", + "settings.page.list.generate_paths_modal.generation_type.title": "Generatietype", + "settings.page.list.generate_paths_modal.generation_type.body": "Selecteer hoe u de URL's wilt genereren.", + "settings.page.list.generate_paths_modal.generation_type.only_without_alias": "Genereer alleen voor pagina's zonder een URL-alias", + "settings.page.list.generate_paths_modal.generation_type.only_generated": "Genereer alleen URL-alias opnieuw die automatisch zijn gegenereerd", + "settings.page.list.generate_paths_modal.generation_type.all": "Genereer alle URL-aliassen opnieuw", + + "settings.page.list.filters.label": "Zoeken", + "settings.page.list.filters.placeholder": "Zoeken...", + + "settings.page.list.table.empty": "Je hebt nog geen URL-paden.", + + "settings.page.list.table.actions.delete": "Verwijder {target}", + "settings.page.list.table.action.goTo": "Ga naar de corresponderende entiteit van {target}", + + "settings.page.patterns.title": "URL-patronen", + "settings.page.patterns.description": "Een lijst met alle bekende URL-aliaspatronen.", + + "settings.page.patterns.table.head.label": "Label", + "settings.page.patterns.table.head.pattern": "Patroon", + "settings.page.patterns.table.head.actions": "Acties", + + "settings.page.patterns.table.empty": "Je hebt nog geen patronen.", + "settings.page.patterns.table.action.edit": "Bewerk {target}", + "settings.page.patterns.table.actions.delete": "Verwijder {target}", + + "settings.page.patterns.edit.title": "Patroon bewerken", + "settings.page.patterns.edit.subtitle": "Patroondetails", + "settings.page.patterns.edit.description": "Bewerk dit patroon voor het automatisch genereren van URL-alias.", + + "settings.page.patterns.create.title": "Nieuw patroon toevoegen", + "settings.page.patterns.create.subtitle": "Patroondetails", + "settings.page.patterns.create.description": "Voeg een patroon toe voor het automatisch genereren van URL-aliassen.", + + "notification.success.permalink_copied": "Permalink gekopieerd naar het klembord" +} diff --git a/packages/addons/links/admin/translations/tr.json b/packages/addons/links/admin/translations/tr.json new file mode 100644 index 00000000..dd352f6f --- /dev/null +++ b/packages/addons/links/admin/translations/tr.json @@ -0,0 +1,69 @@ +{ + "settings.title": "Web Araçları", + "settings.loading": "İçerik yükleniyor...", + + "settings.success.create": "Şablon oluşturuldu.", + "settings.success.edit": "Şablon güncellendi.", + "settings.success.delete": "Şablon silindi.", + "settings.success.url_alias.generate": "{{ count }} URL takma adı oluşturuldu.", + "settings.success.url_alias.delete": "URL takma adı silindi.", + + "settings.button.add_pattern": "Yeni şablon ekle", + "settings.button.generate_paths": "Toplu oluştur", + "settings.button.delete": "Sil", + "settings.button.cancel": "İptal", + "settings.button.filters": "Filtreler", + "settings.button.copy_permalink": "Kalıcı bağlantıyı kopyala", + + "settings.form.label.label": "Etiket", + "settings.form.pattern.label": "Şablon", + "settings.form.pattern.description_1": "URL takma adı şablonu oluştur", + "settings.form.pattern.description_2": "kullanarak", + "settings.form.pattern.description_3": "veya", + "settings.form.contenttype.label": "İçerik türü", + + "settings.page.list.title": "Tüm URL'ler", + "settings.page.list.description": "Bilinen tüm URL takma adlarının listesi.", + "settings.page.list.body": "Tüm URL takma adlarını listele", + + "settings.page.list.delete_confirm_modal.title": "Öğeyi Sil", + "settings.page.list.delete_confirm_modal.body": "Bu öğeyi silmek istediğinizden emin misiniz?", + + "settings.page.list.generate_paths_modal.title": "URL Takma Adları Oluştur", + "settings.page.list.generate_paths_modal.types.title": "İçerik Türleri", + "settings.page.list.generate_paths_modal.types.body": "URL'leri oluşturmak istediğiniz içerik türlerini seçin.", + "settings.page.list.generate_paths_modal.generation_type.title": "Oluşturma Türü", + "settings.page.list.generate_paths_modal.generation_type.body": "URL'leri nasıl oluşturmak istediğinizi seçin.", + "settings.page.list.generate_paths_modal.generation_type.only_without_alias": "Sadece takma adı olmayan sayfalar için oluştur", + "settings.page.list.generate_paths_modal.generation_type.only_generated": "Sadece otomatik olarak oluşturulan takma adları yeniden oluştur", + "settings.page.list.generate_paths_modal.generation_type.all": "Tüm URL takma adlarını yeniden oluştur", + + "settings.page.list.filters.label": "Ara", + "settings.page.list.filters.placeholder": "Ara...", + + "settings.page.list.table.empty": "Henüz herhangi bir URL yolu yok.", + + "settings.page.list.table.actions.delete": "{target} sil", + "settings.page.list.table.actions.goTo": "{target} ile ilgili varlığa git", + + "settings.page.patterns.title": "URL Şablonları", + "settings.page.patterns.description": "Bilinen tüm URL takma adı şablonlarının listesi.", + + "settings.page.patterns.table.head.label": "Etiket", + "settings.page.patterns.table.head.pattern": "Şablon", + "settings.page.patterns.table.head.actions": "Eylemler", + + "settings.page.patterns.table.empty": "Henüz hiçbir şablonunuz yok.", + "settings.page.patterns.table.actions.edit": "{target} düzenle", + "settings.page.patterns.table.actions.delete": "{target} sil", + + "settings.page.patterns.edit.title": "Şablon Düzenle", + "settings.page.patterns.edit.subtitle": "Şablon Detayları", + "settings.page.patterns.edit.description": "Otomatik URL takma adı oluşturma için bu şablonu düzenleyin.", + + "settings.page.patterns.create.title": "Yeni Şablon Ekle", + "settings.page.patterns.create.subtitle": "Şablon Detayları", + "settings.page.patterns.create.description": "Otomatik URL takma adı oluşturma için bir şablon ekleyin.", + + "notification.success.permalink_copied": "Kalıcı bağlantı panoya kopyalandı" +} diff --git a/packages/addons/links/package.json b/packages/addons/links/package.json new file mode 100644 index 00000000..a39d1ea3 --- /dev/null +++ b/packages/addons/links/package.json @@ -0,0 +1,99 @@ +{ + "name": "webtools-addon-links", + "version": "1.1.0", + "description": "Custom field for internal links in Strapi CMS.", + "strapi": { + "name": "webtools-addon-links", + "icon": "list", + "displayName": "Webtools Links", + "description": "Custom field for internal links in Strapi CMS.", + "required": false, + "kind": "plugin", + "webtoolsAddon": true, + "addonName": "Links" + }, + "files": [ + "dist", + "strapi-server.js" + ], + "exports": { + "./strapi-admin": { + "types": "./dist/admin/index.d.ts", + "source": "./admin/index.ts", + "import": "./dist/admin/index.mjs", + "require": "./dist/admin/index.js", + "default": "./dist/admin/index.js" + }, + "./strapi-server": { + "types": "./dist/server/index.d.ts", + "source": "./server/index.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "pack-up build && yalc push --publish", + "watch": "pack-up watch", + "watch:link": "../../../node_modules/.bin/strapi-plugin watch:link", + "eslint": "../../../node_modules/.bin/eslint --max-warnings=0 './**/*.{js,jsx,ts,tsx}'", + "eslint:fix": "../../../node_modules/.bin/eslint --fix './**/*.{js,jsx,ts,tsx}'" + }, + "peerDependencies": { + "@strapi/admin": "^5.0.0", + "@strapi/design-system": "^2.0.0-rc.14", + "@strapi/icons": "^2.0.0-rc.14", + "@strapi/strapi": "^5.0.0", + "@strapi/utils": "^5.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.0.0", + "styled-components": "^6.0.0" + }, + "devDependencies": { + "@strapi/admin": "^5.0.0", + "@strapi/design-system": "^2.0.0-rc.14", + "@strapi/icons": "^2.0.0-rc.14", + "@strapi/pack-up": "^5.0.0", + "@strapi/sdk-plugin": "^5.0.0", + "@strapi/strapi": "^5.0.0", + "@strapi/utils": "^5.0.0", + "@types/koa": "^2.15.0", + "@types/lodash": "^4", + "@types/react-copy-to-clipboard": "^5.0.7", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.0.0", + "styled-components": "^6.0.0" + }, + "dependencies": { + "formik": "^2.4.0", + "lodash": "^4.17.21", + "react-copy-to-clipboard": "^5.1.0", + "react-intl": "^6.4.1", + "react-query": "^3.39.3", + "yup": "^0.32.9" + }, + "author": { + "name": "Boaz Poolman", + "email": "boaz@pluginpal.io", + "url": "https://github.com/boazpoolman" + }, + "maintainers": [ + { + "name": "Boaz Poolman", + "email": "boaz@pluginpal.io", + "url": "https://github.com/boazpoolman" + } + ], + "bugs": { + "url": "https://github.com/pluginpal/strapi-webtools/issues" + }, + "homepage": "https://www.pluginpal.io/plugin/webtools", + "engines": { + "node": ">=18.x.x <=20.x.x", + "npm": ">=6.0.0" + }, + "license": "MIT" +} diff --git a/packages/addons/links/packup.config.ts b/packages/addons/links/packup.config.ts new file mode 100644 index 00000000..3fc65a1f --- /dev/null +++ b/packages/addons/links/packup.config.ts @@ -0,0 +1,27 @@ +import { Config, defineConfig } from '@strapi/pack-up'; + +const config: Config = defineConfig({ + bundles: [ + { + source: './admin/index.ts', + import: './dist/admin/index.mjs', + require: './dist/admin/index.js', + runtime: 'web', + }, + { + source: './server/index.ts', + import: './dist/server/index.mjs', + require: './dist/server/index.js', + runtime: 'node', + }, + ], + dist: './dist', + /** + * Because we're exporting a server & client package + * which have different runtimes we want to ignore + * what they look like in the package.json + */ + exports: {}, +}); + +export default config; diff --git a/packages/addons/links/server/controllers/index.ts b/packages/addons/links/server/controllers/index.ts new file mode 100644 index 00000000..b0b1ec1a --- /dev/null +++ b/packages/addons/links/server/controllers/index.ts @@ -0,0 +1,5 @@ +import searchController from './search'; + +export default { + search: searchController, +}; diff --git a/packages/addons/links/server/controllers/search.ts b/packages/addons/links/server/controllers/search.ts new file mode 100644 index 00000000..f60fb7f2 --- /dev/null +++ b/packages/addons/links/server/controllers/search.ts @@ -0,0 +1,66 @@ +import { Context } from 'koa'; +import { UID } from '@strapi/strapi'; +import { isContentTypeEnabled } from '../util/enabledContentTypes'; + +/** + * Search controller + */ + +export default { + index: async (ctx: Context & { params: { id: number } }) => { + const { q } = ctx.query; + const { id } = ctx.params; + let results = []; + + await Promise.all(Object.entries(strapi.contentTypes) + .map(async ([uid, config]: [UID.CollectionType, any]) => { + const hasWT = isContentTypeEnabled(uid); + + console.log(uid, hasWT); + + if (!hasWT) { + return; + } + + console.log('goo'); + + const coreStoreSettings = await strapi.query('strapi::core-store').findMany({ + where: { + key: `plugin_content_manager_configuration_content_types::${uid}`, + }, + }); + + console.log('yes'); + + if (!coreStoreSettings[0]) { + // no settings for the contnet type. + return; + } + + console.log('no'); + + // @ts-ignore + const value = JSON.parse(coreStoreSettings[0].value); + const { mainField } = value.settings; + + const entries = await strapi.entityService.findMany(uid as 'api::test.test', { + filters: { + [mainField]: { + $containsi: q, + }, + }, + fields: [mainField], + populate: { + url_alias: { + fields: ['id'], + }, + }, + }) as any[]; + + results = [...results, ...entries]; + })); + + // @ts-ignore + ctx.body = results; + }, +}; diff --git a/packages/addons/links/server/index.ts b/packages/addons/links/server/index.ts new file mode 100644 index 00000000..9e864436 --- /dev/null +++ b/packages/addons/links/server/index.ts @@ -0,0 +1,10 @@ + +import register from './register'; +import routes from './routes'; +import controllers from './controllers'; + +export default { + register, + routes, + controllers, +}; diff --git a/packages/addons/links/server/register.ts b/packages/addons/links/server/register.ts new file mode 100644 index 00000000..3922d53b --- /dev/null +++ b/packages/addons/links/server/register.ts @@ -0,0 +1,9 @@ +import { Core } from '@strapi/strapi'; + +export default ({ strapi }: { strapi: Core.Strapi }) => { + strapi.customFields.register({ + name: 'link', + plugin: 'webtools-addon-links', + type: 'string', + }); +}; diff --git a/packages/addons/links/server/routes/index.ts b/packages/addons/links/server/routes/index.ts new file mode 100644 index 00000000..e29f3145 --- /dev/null +++ b/packages/addons/links/server/routes/index.ts @@ -0,0 +1,19 @@ +export default { + 'content-api': { + type: 'content-api', + routes: [], + }, + admin: { + type: 'admin', + routes: [ + { + method: 'GET', + path: '/search', + handler: 'search.index', + config: { + policies: [], + }, + }, + ], + }, +}; diff --git a/packages/addons/links/server/util/enabledContentTypes.ts b/packages/addons/links/server/util/enabledContentTypes.ts new file mode 100644 index 00000000..c0ad9220 --- /dev/null +++ b/packages/addons/links/server/util/enabledContentTypes.ts @@ -0,0 +1,21 @@ +import get from 'lodash/get'; +import { Schema } from '@strapi/strapi'; + +import { pluginId } from './pluginId'; + +export const isContentTypeEnabled = (ct: Schema.ContentType) => { + let contentType: Schema.ContentType; + + if (typeof ct === 'string') { + contentType = strapi.contentTypes[ct]; + } else { + contentType = ct; + } + + const { pluginOptions } = contentType; + const enabled = get(pluginOptions, [pluginId, 'enabled'], false) as boolean; + + if (!enabled) return false; + + return true; +}; diff --git a/packages/addons/links/server/util/getPluginService.ts b/packages/addons/links/server/util/getPluginService.ts new file mode 100644 index 00000000..794f878b --- /dev/null +++ b/packages/addons/links/server/util/getPluginService.ts @@ -0,0 +1,15 @@ +import { pluginId } from './pluginId'; +import type config from '..'; + +type Config = typeof config; +type Services = Config['services']; +/** + * A helper function to obtain a plugin service. + * @param {string} name The name of the service. + * + * @return {any} service. + */ +export const getPluginService = (name: ServiceName) => { + const service = strapi.service(`plugin::${pluginId}.${name}`); + return service as ReturnType; +}; diff --git a/packages/addons/links/server/util/pluginId.ts b/packages/addons/links/server/util/pluginId.ts new file mode 100644 index 00000000..50f2cff1 --- /dev/null +++ b/packages/addons/links/server/util/pluginId.ts @@ -0,0 +1,10 @@ + + +import pluginPkg from '../../package.json'; + +/** + * A helper function to obtain the plugin id. + * + * @return {string} The plugin id. + */ +export const pluginId = pluginPkg.strapi.name; diff --git a/packages/addons/links/strapi-server.js b/packages/addons/links/strapi-server.js new file mode 100644 index 00000000..bf559588 --- /dev/null +++ b/packages/addons/links/strapi-server.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./dist/server'); diff --git a/packages/addons/links/types b/packages/addons/links/types new file mode 120000 index 00000000..2d9ee678 --- /dev/null +++ b/packages/addons/links/types @@ -0,0 +1 @@ +../../../playground/types/ \ No newline at end of file From 22bb0edf83bb27edf1f39444f80b22fc192e139d Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Sun, 6 Apr 2025 16:29:16 +0200 Subject: [PATCH 11/12] chore: update lockfile --- yarn.lock | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/yarn.lock b/yarn.lock index 3eb03ede..1f60b477 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30518,6 +30518,43 @@ __metadata: languageName: node linkType: hard +"webtools-addon-links@workspace:packages/addons/links": + version: 0.0.0-use.local + resolution: "webtools-addon-links@workspace:packages/addons/links" + dependencies: + "@strapi/admin": "npm:^5.0.0" + "@strapi/design-system": "npm:^2.0.0-rc.14" + "@strapi/icons": "npm:^2.0.0-rc.14" + "@strapi/pack-up": "npm:^5.0.0" + "@strapi/sdk-plugin": "npm:^5.0.0" + "@strapi/strapi": "npm:^5.0.0" + "@strapi/utils": "npm:^5.0.0" + "@types/koa": "npm:^2.15.0" + "@types/lodash": "npm:^4" + "@types/react-copy-to-clipboard": "npm:^5.0.7" + formik: "npm:^2.4.0" + lodash: "npm:^4.17.21" + react: "npm:^18.0.0" + react-copy-to-clipboard: "npm:^5.1.0" + react-dom: "npm:^18.0.0" + react-intl: "npm:^6.4.1" + react-query: "npm:^3.39.3" + react-router-dom: "npm:^6.0.0" + styled-components: "npm:^6.0.0" + yup: "npm:^0.32.9" + peerDependencies: + "@strapi/admin": ^5.0.0 + "@strapi/design-system": ^2.0.0-rc.14 + "@strapi/icons": ^2.0.0-rc.14 + "@strapi/strapi": ^5.0.0 + "@strapi/utils": ^5.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + react-router-dom: ^6.0.0 + styled-components: ^6.0.0 + languageName: unknown + linkType: soft + "webtools-addon-redirects@workspace:packages/addons/redirects": version: 0.0.0-use.local resolution: "webtools-addon-redirects@workspace:packages/addons/redirects" From 3e5b50db78315c663f6ab0818c26bfbb5911fc7f Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Sun, 6 Apr 2025 16:31:25 +0200 Subject: [PATCH 12/12] chore: update lockfile --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 793b7069..f37c61b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7670,7 +7670,7 @@ __metadata: languageName: node linkType: hard -"@strapi/design-system@npm:^2.0.0-rc": +"@strapi/design-system@npm:^2.0.0-rc, @strapi/design-system@npm:^2.0.0-rc.14": version: 2.0.0-rc.21 resolution: "@strapi/design-system@npm:2.0.0-rc.21" dependencies: @@ -7784,7 +7784,7 @@ __metadata: languageName: node linkType: hard -"@strapi/icons@npm:^2.0.0-rc": +"@strapi/icons@npm:^2.0.0-rc, @strapi/icons@npm:^2.0.0-rc.14": version: 2.0.0-rc.21 resolution: "@strapi/icons@npm:2.0.0-rc.21" peerDependencies: