diff --git a/admin/src/pages/HomePage/components/NavigationItemForm/components/AdditionalFields/AdditionalFieldInput/index.tsx b/admin/src/pages/HomePage/components/NavigationItemForm/components/AdditionalFields/AdditionalFieldInput/index.tsx index e2ae858f..b977d117 100644 --- a/admin/src/pages/HomePage/components/NavigationItemForm/components/AdditionalFields/AdditionalFieldInput/index.tsx +++ b/admin/src/pages/HomePage/components/NavigationItemForm/components/AdditionalFields/AdditionalFieldInput/index.tsx @@ -13,6 +13,7 @@ import { useIntl } from 'react-intl'; import { Toggle } from '@strapi/design-system'; import { NavigationItemCustomField } from '../../../../../../../schemas'; import { getTrad } from '../../../../../../../translations'; +import { Textarea } from '@strapi/design-system'; export type AdditionalFieldInputProps = { name?: string; @@ -93,7 +94,7 @@ export const AdditionalFieldInput: React.FC = ({ ); case 'string': return ( - | string, value?: any) => onChangeEnhancer(eventOrPath, value, onChange) diff --git a/package.json b/package.json index d1a3087a..39e2c2fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "strapi-plugin-navigation", - "version": "3.2.6", + "version": "3.2.7", "description": "Strapi - Navigation plugin", "strapi": { "name": "navigation", diff --git a/server/src/controllers/client.ts b/server/src/controllers/client.ts index 6f40e4bd..5561494c 100644 --- a/server/src/controllers/client.ts +++ b/server/src/controllers/client.ts @@ -2,9 +2,7 @@ import { Core } from '@strapi/strapi'; import { Context as KoaContext } from 'koa'; import * as z from 'zod'; import { getPluginService } from '../utils'; -import { sanitizePopulateField } from './utils'; import { - populateSchema, readAllQuerySchema, renderChildQueryParams, renderQuerySchema, @@ -53,17 +51,7 @@ export default function clientController(context: { strapi: Core.Strapi }) { menuOnly: menuOnly === 'true', rootPath, locale, - populate: sanitizePopulateField( - populateSchema.parse( - populate === 'true' - ? true - : populate === 'false' - ? false - : Array.isArray(populate) - ? populate.map((x) => (x === 'true' ? true : x === 'false' ? false : populate)) - : populate - ) - ), + populate, status, }); }, diff --git a/server/src/controllers/utils.ts b/server/src/controllers/utils.ts index 2556f114..de7f4ad9 100644 --- a/server/src/controllers/utils.ts +++ b/server/src/controllers/utils.ts @@ -1,24 +1,6 @@ import { z } from 'zod'; import { idSchema } from './validators'; -type Populate = string | boolean | string[] | undefined; - -export const sanitizePopulateField = (populate: Populate): Populate => { - if (!populate || populate === true || populate === '*') { - return undefined; - } - - if ('object' === typeof populate) { - return undefined; - } - - if (Array.isArray(populate)) { - return populate; - } - - return populate; -}; - export const parseId = (id: string) => { return Number.isNaN(parseInt(id)) ? z.string().parse(id) : idSchema.parse(parseInt(id)); }; diff --git a/server/src/controllers/validators.ts b/server/src/controllers/validators.ts index 0f6ef791..857d3d6b 100644 --- a/server/src/controllers/validators.ts +++ b/server/src/controllers/validators.ts @@ -12,9 +12,47 @@ export const readAllQuerySchema = z.object({ export const renderTypeSchema = z.enum(['FLAT', 'TREE', 'RFR']); -export const statusSchema = z.enum(['draft', 'published']); +export const statusSchema = z + .string() + .transform((v) => v === 'published' ? 'published' : 'draft') + .pipe(z.enum(['draft', 'published'])); -export const populateSchema = z.union([z.boolean(), z.string(), z.string().array(), z.undefined()]); +// TODO in the zod v3 we can't use z.lazy and recursive types without creating a custom type. Let's align on this when Strapi will use zod v4 +// in the zod v4 there's also z.stringbool that should simplify this logic +type PopulatePrimitive = boolean | string | string[] | undefined; + +export interface PopulateObject { + [key: string]: Populate; +} + +type Populate = PopulatePrimitive | PopulateObject; + +const sanitizePopulateField = (populate: unknown) => { + if (typeof populate === 'string') { + if (populate === 'true') { + return true; + } + if (populate === 'false') { + return false; + } + } + return populate; +}; + +export const populateSchema: z.ZodType = z.lazy(() => + z.preprocess( + sanitizePopulateField, + z.union([ + z.boolean(), + z.string(), + z.string().array(), + z.undefined(), + z.record(populateSchema) + ]), + ), +); + +export type PopulateQueryParam = z.infer; export const renderQuerySchema = z.object({ type: renderTypeSchema.optional(), diff --git a/server/src/services/client/types.ts b/server/src/services/client/types.ts index 6f8cc666..e02ec1b9 100644 --- a/server/src/services/client/types.ts +++ b/server/src/services/client/types.ts @@ -1,9 +1,8 @@ import { NavigationItemDTO, RFRNavigationItemDTO } from '../../dtos'; +import { PopulateQueryParam } from '../../controllers/validators'; export type RenderType = 'FLAT' | 'TREE' | 'RFR'; -export type PopulateQueryParam = string | boolean | string[]; - export type NestedPath = { id?: number; documentId?: string; diff --git a/server/src/services/common/common.ts b/server/src/services/common/common.ts index df12201c..84a75e7c 100644 --- a/server/src/services/common/common.ts +++ b/server/src/services/common/common.ts @@ -79,7 +79,7 @@ const commonService = (context: { strapi: Core.Strapi }) => ({ return item; } - const fieldsToPopulate = config.contentTypesPopulate[item.related.__type]; + const fieldsToPopulate = populate ?? config.contentTypesPopulate[item.related.__type]; const repository = getGenericRepository({ strapi }, item.related.__type as UID.ContentType); diff --git a/server/tests/controllers/client.test.ts b/server/tests/controllers/client.test.ts index fa2cd2bb..2c0194e3 100644 --- a/server/tests/controllers/client.test.ts +++ b/server/tests/controllers/client.test.ts @@ -195,6 +195,95 @@ describe('Navigation', () => { ); }).rejects.toThrow(); }); + + it('should fallback to status=draft if status differs from draft or published', async () => { + // Given + const navigation = getMockNavigation(); + const render = jest.fn(); + const mockClientService = asProxy({ render }); + const idOrSlug = faker.string.uuid(); + const type = faker.helpers.arrayElement(['FLAT', 'TREE', 'RFR']); + const menuOnly = faker.datatype.boolean(); + + render.mockResolvedValue(navigation); + + (getPluginService as jest.Mock).mockReturnValue(mockClientService); + + const clientController = buildClientController({ strapi }); + // When + const resultDraft = await clientController.render( + asProxy({ + params: { idOrSlug }, + query: { + type, + menuOnly, + status: faker.string.sample(), + }, + }) + ); + + // Then + expect(resultDraft).toEqual(navigation); + // When + const resultPublished = await clientController.render( + asProxy({ + params: { idOrSlug }, + query: { + type, + menuOnly, + status: 'published', + }, + }) + ); + + // Then + expect(resultPublished).toEqual(navigation); + }); + + it('should sanitize populate query param', async () => { + // Given + const navigation = getMockNavigation(); + const render = jest.fn(); + const mockClientService = asProxy({ render }); + const idOrSlug = faker.string.uuid(); + const type = faker.helpers.arrayElement(['FLAT', 'TREE', 'RFR']); + const menuOnly = faker.datatype.boolean(); + + render.mockResolvedValue(navigation); + + (getPluginService as jest.Mock).mockReturnValue(mockClientService); + + const clientController = buildClientController({ strapi }); + // When + const resultTrue = await clientController.render( + asProxy({ + params: { idOrSlug }, + query: { + type, + menuOnly, + populate: { foo: { populate: 'true' }} + }, + }) + ); + + // Then + expect(resultTrue).toEqual(navigation); + + // When + const resultFalse = await clientController.render( + asProxy({ + params: { idOrSlug }, + query: { + type, + menuOnly, + populate: { foo: { populate: 'false' }} + }, + }) + ); + + // Then + expect(resultFalse).toEqual(navigation); + }); }); describe('renderChild()', () => {