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/.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/.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/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/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) => { ... }) -// 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/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..57063b6f --- /dev/null +++ b/packages/addons/redirects/admin/index.cy.jsx @@ -0,0 +1,42 @@ +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.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(); + 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 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} 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'); - }); -}); 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 00000000..c79130e1 Binary files /dev/null and b/packages/docs/static/img/assets/addons/redirects/admin.png differ 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"), ), 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/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; 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"