diff --git a/packages/core/content-manager/server/src/preview/controllers/preview.ts b/packages/core/content-manager/server/src/preview/controllers/preview.ts index f0f94562b9..884dae46a3 100644 --- a/packages/core/content-manager/server/src/preview/controllers/preview.ts +++ b/packages/core/content-manager/server/src/preview/controllers/preview.ts @@ -1,14 +1,37 @@ -import type { Core } from '@strapi/types'; +import type { Core, UID } from '@strapi/types'; + +import { Preview } from '../../../../shared/contracts'; + +import { getService } from '../utils'; +import { validatePreviewUrl } from './validation/preview'; const createPreviewController = () => { return { - async getPreviewURL(ctx) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const request = ctx.request; + /** + * Transforms an entry into a preview URL, so that it can be previewed + * in the Content Manager. + */ + async getPreviewUrl(ctx) { + const uid = ctx.params.contentType as UID.ContentType; + const query = ctx.request.query as Preview.GetPreviewUrl.Request['query']; + + // Validate the request parameters + const params = await validatePreviewUrl(strapi, uid, query); + + // TODO: Permissions to preview content + + // Get the preview URL by using the user-defined config handler + const previewService = getService(strapi, 'preview'); + const url = await previewService.getPreviewUrl(uid, params); + + // If no url is found, set status to 204 + if (!url) { + ctx.status = 204; + } return { - data: { url: '' }, - }; + data: { url }, + } satisfies Preview.GetPreviewUrl.Response; }, } satisfies Core.Controller; }; diff --git a/packages/core/content-manager/server/src/preview/controllers/validation/preview.ts b/packages/core/content-manager/server/src/preview/controllers/validation/preview.ts new file mode 100644 index 0000000000..051f1b4967 --- /dev/null +++ b/packages/core/content-manager/server/src/preview/controllers/validation/preview.ts @@ -0,0 +1,54 @@ +import * as yup from 'yup'; +import { pick } from 'lodash/fp'; + +import type { Core, UID } from '@strapi/types'; +import { validateYupSchema, errors } from '@strapi/utils'; + +import { Preview } from '../../../../../shared/contracts'; +import type { HandlerParams } from '../../services/preview-config'; + +const getPreviewUrlSchema = yup + .object() + .shape({ + // Will be undefined for single types + documentId: yup.string(), + locale: yup.string().nullable(), + status: yup.string(), + }) + .required(); + +export const validatePreviewUrl = async ( + strapi: Core.Strapi, + uid: UID.ContentType, + params: Preview.GetPreviewUrl.Request['query'] +): Promise => { + // Validate the request parameters format + await validateYupSchema(getPreviewUrlSchema)(params); + + const newParams = pick(['documentId', 'locale', 'status'], params) as HandlerParams; + const model = strapi.getModel(uid); + + // If it's not a collection type or single type + if (!model || model.modelType !== 'contentType') { + throw new errors.ValidationError('Invalid content type'); + } + + // Document id is not required for single types + const isSingleType = model?.kind === 'singleType'; + if (!isSingleType && !params.documentId) { + throw new errors.ValidationError('documentId is required for Collection Types'); + } + + // Fill the documentId if it's a single type + if (isSingleType) { + const doc = await strapi.documents(uid).findFirst(); + + if (!doc) { + throw new errors.NotFoundError('Document not found'); + } + + newParams.documentId = doc?.documentId; + } + + return newParams; +}; diff --git a/packages/core/content-manager/server/src/preview/routes/preview.ts b/packages/core/content-manager/server/src/preview/routes/preview.ts index c9506995d7..edb3a390c0 100644 --- a/packages/core/content-manager/server/src/preview/routes/preview.ts +++ b/packages/core/content-manager/server/src/preview/routes/preview.ts @@ -9,7 +9,7 @@ const previewRouter: Plugin.LoadedPlugin['routes'][string] = { method: 'GET', info, path: '/preview/url/:contentType', - handler: 'preview.getPreviewURL', + handler: 'preview.getPreviewUrl', config: { policies: ['admin::isAuthenticatedAdmin'], }, diff --git a/packages/core/content-manager/server/src/preview/services/preview.ts b/packages/core/content-manager/server/src/preview/services/preview.ts index 4d507b2788..31f7fa82f9 100644 --- a/packages/core/content-manager/server/src/preview/services/preview.ts +++ b/packages/core/content-manager/server/src/preview/services/preview.ts @@ -1,6 +1,31 @@ +import type { Core, UID } from '@strapi/types'; +import { errors } from '@strapi/utils'; + +import { getService } from '../utils'; +import type { HandlerParams } from './preview-config'; + /** * Responsible of routing an entry to a preview URL. */ -const createPreviewService = () => {}; +const createPreviewService = ({ strapi }: { strapi: Core.Strapi }) => { + const config = getService(strapi, 'preview-config'); + + return { + async getPreviewUrl(uid: UID.ContentType, params: HandlerParams) { + const handler = config.getPreviewHandler(); + + try { + // Try to get the preview URL from the user-defined handler + return handler(uid, params); + } catch (error) { + // Log the error and throw a generic error + strapi.log.error(`Failed to get preview URL: ${error}`); + throw new errors.ApplicationError('Failed to get preview URL'); + } + + return; + }, + }; +}; export { createPreviewService }; diff --git a/packages/core/content-manager/shared/contracts/index.ts b/packages/core/content-manager/shared/contracts/index.ts index 361ff04b17..e13a163718 100644 --- a/packages/core/content-manager/shared/contracts/index.ts +++ b/packages/core/content-manager/shared/contracts/index.ts @@ -7,3 +7,4 @@ export * as SingleTypes from './single-types'; export * as UID from './uid'; export * as ReviewWorkflows from './review-workflows'; export * as HistoryVersions from './history-versions'; +export * as Preview from './preview'; diff --git a/packages/core/content-manager/shared/contracts/preview.ts b/packages/core/content-manager/shared/contracts/preview.ts new file mode 100644 index 0000000000..5a78ff2a79 --- /dev/null +++ b/packages/core/content-manager/shared/contracts/preview.ts @@ -0,0 +1,31 @@ +import type { Data, UID } from '@strapi/types'; +import { type errors } from '@strapi/utils'; + +/** + * GET /content-manager/preview/url/:uid + */ +export declare namespace GetPreviewUrl { + export interface Request { + params: { + contentType: UID.ContentType; + }; + query: { + documentId?: Data.DocumentID; + locale?: string; + status?: 'published' | 'draft'; + }; + } + + // NOTE: Response status will be 204 if no URL is found + export type Response = + | { + data: { + url: string | undefined; + }; + error?: never; + } + | { + data?: never; + error: errors.ApplicationError | errors.ValidationError | errors.NotFoundError; + }; +} diff --git a/tests/api/core/content-manager/preview/preview.test.api.ts b/tests/api/core/content-manager/preview/preview.test.api.ts new file mode 100644 index 0000000000..b24306c6be --- /dev/null +++ b/tests/api/core/content-manager/preview/preview.test.api.ts @@ -0,0 +1,133 @@ +import { createStrapiInstance } from 'api-tests/strapi'; +import { createAuthRequest } from 'api-tests/request'; +import { describeOnCondition } from 'api-tests/utils'; +import { createTestBuilder } from 'api-tests/builder'; + +const collectionTypeUid = 'api::product.product'; +const collectionTypeModel = { + singularName: 'product', + pluralName: 'products', + displayName: 'Product', + kind: 'collectionType', + draftAndPublish: true, + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + name: { + type: 'string', + }, + }, +}; + +const singleTypeUid = 'api::homepage.homepage'; +const singleTypeModel = { + singularName: 'homepage', + pluralName: 'homepages', + displayName: 'Homepage', + kind: 'singleType', + draftAndPublish: true, + pluginOptions: { + i18n: { + localized: true, + }, + }, + attributes: { + title: { + type: 'string', + }, + }, +}; + +const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; + +// TODO: Remove skip when future flag is removed +// describeOnCondition(edition === 'EE')('Preview', () => { +describeOnCondition(false)('Preview', () => { + const builder = createTestBuilder(); + let strapi; + let rq; + let singleTypeEntry; + + const updateEntry = async ({ uid, documentId, data, locale }) => { + const type = documentId ? 'collection-types' : 'single-types'; + const params = documentId ? `${type}/${uid}/${documentId}` : `${type}/${uid}`; + + const { body } = await rq({ + method: 'PUT', + url: `/content-manager/${params}`, + body: data, + qs: { locale }, + }); + + return body.data; + }; + + const getPreviewUrl = async ({ uid, documentId, locale, status }) => { + return rq({ + method: 'GET', + url: `/content-manager/preview/url/${uid}`, + qs: { documentId, locale, status }, + }); + }; + + beforeAll(async () => { + await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build(); + + strapi = await createStrapiInstance(); + rq = await createAuthRequest({ strapi }); + + // Update the single type to create an initial history version + singleTypeEntry = await updateEntry({ + uid: singleTypeUid, + documentId: undefined, + locale: 'en', + data: { + title: 'Welcome', + }, + }); + + // Configure the preview URL handler + strapi.config.set('admin.preview', { + enabled: true, + config: { + handler: (uid, { documentId, locale, status }) => { + return `/preview/${uid}/${documentId}?locale=${locale}&status=${status}`; + }, + }, + }); + }); + + afterAll(async () => { + await strapi.destroy(); + await builder.cleanup(); + }); + + test('Get preview URL for collection type', async () => { + const { body, statusCode } = await getPreviewUrl({ + uid: collectionTypeUid, + documentId: '1', + locale: 'en', + status: 'draft', + }); + + expect(statusCode).toBe(200); + expect(body.data.url).toEqual(`/preview/${collectionTypeUid}/1?locale=en&status=draft`); + }); + + test('Get preview URL for single type', async () => { + const { body, statusCode } = await getPreviewUrl({ + uid: singleTypeUid, + documentId: undefined, + locale: 'en', + status: 'draft', + }); + + expect(statusCode).toBe(200); + expect(body.data.url).toEqual( + `/preview/${singleTypeUid}/${singleTypeEntry.documentId}?locale=en&status=draft` + ); + }); +});