mirror of
https://github.com/strapi/strapi.git
synced 2025-08-31 12:23:05 +00:00
feat: preview endpoint (#21574)
* feat: base files for static preview * feat: preview config * Update packages/core/content-manager/server/src/preview/routes/index.ts Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com> * chore: empty handler * chore: comment controllers type * fix: remove is enabled check from load * feat: test preview config * chore: refactor type * feat: preview endpoint * feat: preview test * fix: tests * fix: api test * chore: comment --------- Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>
This commit is contained in:
parent
52742fc75d
commit
002fc78b3c
@ -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;
|
||||
};
|
||||
|
@ -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<HandlerParams> => {
|
||||
// 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;
|
||||
};
|
@ -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'],
|
||||
},
|
||||
|
@ -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 };
|
||||
|
@ -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';
|
||||
|
31
packages/core/content-manager/shared/contracts/preview.ts
Normal file
31
packages/core/content-manager/shared/contracts/preview.ts
Normal file
@ -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;
|
||||
};
|
||||
}
|
133
tests/api/core/content-manager/preview/preview.test.api.ts
Normal file
133
tests/api/core/content-manager/preview/preview.test.api.ts
Normal file
@ -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`
|
||||
);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user