diff --git a/components/schema/package.json b/components/schema/package.json index 3d93ddfe..a0697d64 100644 --- a/components/schema/package.json +++ b/components/schema/package.json @@ -1,6 +1,6 @@ { "name": "@crystallize/schema", - "version": "6.16.0", + "version": "6.17.0", "license": "MIT", "repository": { "type": "git", diff --git a/components/schema/src/pim/components/shared.ts b/components/schema/src/pim/components/shared.ts index 0d966a62..f3a35e87 100644 --- a/components/schema/src/pim/components/shared.ts +++ b/components/schema/src/pim/components/shared.ts @@ -5,6 +5,17 @@ export const GenericComponentConfigSchema = z.object({ multilingual: z.boolean().optional(), required: z.boolean().optional(), discoverable: z.boolean().optional(), + /** + * Discovery indexing flags. Unset means `true` everywhere. + * + * They narrow the QUERY SURFACE only: a component with all four off is still stored and still + * returned in the Discovery payload. `discoverable: false` is the master switch that removes it + * from Discovery entirely. Changes take effect at the tenant's next re-ignition, in both directions. + */ + searchable: z.boolean().optional(), + filterable: z.boolean().optional(), + sortable: z.boolean().optional(), + facetable: z.boolean().optional(), }); export type GenericComponentConfig = z.infer; export const GenericComponentConfigInputSchema = GenericComponentConfigSchema; diff --git a/components/schema/tests/mass-operation/component-indexing-flags.test.ts b/components/schema/tests/mass-operation/component-indexing-flags.test.ts new file mode 100644 index 00000000..98dad82c --- /dev/null +++ b/components/schema/tests/mass-operation/component-indexing-flags.test.ts @@ -0,0 +1,261 @@ +import { OperationSchema } from '../../src/mass-operation/index'; +import { ShapeSchema } from '../../src/pim/index'; +import { describe, expect, it } from 'vitest'; + +/** + * The four Discovery indexing flags were added to the component base config in crystallize-backend + * (ab0c4e40b0, #940). The mass-operations worker runs a file through `OperationsSchema.safeParse` and + * hands the PARSED data to the converters, so a key this schema does not declare is silently stripped + * (zod strips unknown keys) and never reaches the API. These tests pin that the flags survive at every + * level a component config can appear, and that an unset flag stays unset: the backend reads + * `undefined` as "on", and `false` is the whole point. + */ +const INDEXING_FLAGS = ['searchable', 'filterable', 'sortable', 'facetable'] as const; + +const allOff = { searchable: false, filterable: false, sortable: false, facetable: false }; +const mixed = { searchable: false, filterable: true, sortable: false, facetable: true }; + +const expectRoundTrip = (operation: unknown) => { + const result = OperationSchema.safeParse(operation); + if (!result.success) { + console.dir({ error: result.error }, { depth: null }); + } + expect(result.success).toBe(true); + expect(result.data).toEqual(operation); +}; + +describe('Mass Operations - Component indexing flags', () => { + it('minimal: keeps the four flags on one shape component without altering the operation', () => { + expectRoundTrip({ + intent: 'shape/upsert', + type: 'product', + identifier: 'chair', + name: 'Chair', + components: [ + { + id: 'title', + name: 'Title', + type: 'singleLine', + config: { singleLine: { required: true, discoverable: true, ...mixed } }, + }, + ], + }); + }); + + it('keeps the flags on every component type, whichever way its config schema derives from the base', () => { + expectRoundTrip({ + intent: 'shape/create', + type: 'document', + identifier: 'kitchen-sink', + name: 'Kitchen sink', + components: [ + { id: 'boolean', name: 'Boolean', type: 'boolean', config: { boolean: { ...allOff } } }, + { id: 'datetime', name: 'Datetime', type: 'datetime', config: { datetime: { ...mixed } } }, + { id: 'location', name: 'Location', type: 'location', config: { location: { ...allOff } } }, + { + id: 'rich-text', + name: 'Rich text', + type: 'richText', + config: { richText: { min: 1, max: 3, ...mixed } }, + }, + { + id: 'single-line', + name: 'Single line', + type: 'singleLine', + config: { singleLine: { pattern: '^[a-z]+$', ...allOff } }, + }, + { id: 'images', name: 'Images', type: 'images', config: { images: { ...mixed } } }, + { id: 'videos', name: 'Videos', type: 'videos', config: { videos: { ...allOff } } }, + { + id: 'files', + name: 'Files', + type: 'files', + config: { files: { maxFileSize: { size: 10, unit: 'MiB' }, ...mixed } }, + }, + { id: 'grids', name: 'Grids', type: 'gridRelations', config: { gridRelations: { ...allOff } } }, + { + id: 'numeric', + name: 'Numeric', + type: 'numeric', + config: { numeric: { decimalPlaces: 2, units: ['cm'], ...mixed } }, + }, + { + id: 'specs', + name: 'Specs', + type: 'propertiesTable', + config: { propertiesTable: { sections: [{ title: 'Dimensions', keys: ['width'] }], ...allOff } }, + }, + { id: 'colors', name: 'Colors', type: 'colors', config: { colors: { requireLabel: true, ...mixed } } }, + { + id: 'material', + name: 'Material', + type: 'selection', + config: { selection: { options: [{ key: 'oak', value: 'Oak' }], ...allOff } }, + }, + { + id: 'related', + name: 'Related', + type: 'itemRelations', + config: { itemRelations: { acceptedShapeIdentifiers: ['chair'], ...mixed } }, + }, + { + id: 'story', + name: 'Story', + type: 'paragraphCollection', + config: { paragraphCollection: { multilingual: ['body'], ...allOff } }, + }, + { id: 'seo', name: 'SEO', type: 'piece', config: { piece: { identifier: 'seo', ...mixed } } }, + ], + }); + }); + + it('keeps the flags on structural components, on what they nest, and on variant components', () => { + expectRoundTrip({ + intent: 'shape/upsert', + type: 'product', + identifier: 'chair', + name: 'Chair', + components: [ + { + id: 'dimensions', + name: 'Dimensions', + type: 'contentChunk', + config: { + contentChunk: { + repeatable: true, + ...mixed, + components: [ + { id: 'width', name: 'Width', type: 'numeric', config: { numeric: { ...allOff } } }, + ], + }, + }, + }, + { + id: 'hero', + name: 'Hero', + type: 'componentChoice', + config: { + componentChoice: { + ...allOff, + choices: [ + { id: 'image', name: 'Image', type: 'images', config: { images: { ...mixed } } }, + { id: 'video', name: 'Video', type: 'videos', config: { videos: { ...allOff } } }, + ], + }, + }, + }, + { + id: 'blocks', + name: 'Blocks', + type: 'componentMultipleChoice', + config: { + componentMultipleChoice: { + allowDuplicates: false, + ...mixed, + choices: [ + { + id: 'banner', + name: 'Banner', + type: 'singleLine', + config: { singleLine: { ...allOff } }, + }, + { id: 'quote', name: 'Quote', type: 'richText', config: { richText: { ...mixed } } }, + ], + }, + }, + }, + ], + variantComponents: [ + { id: 'color', name: 'Color', type: 'colors', config: { colors: { ...mixed } } }, + { + id: 'size', + name: 'Size', + type: 'selection', + config: { selection: { options: [{ key: 's', value: 'S' }], ...allOff } }, + }, + ], + }); + }); + + it("keeps the flags on a piece operation's components", () => { + expectRoundTrip({ + intent: 'piece/upsert', + identifier: 'seo', + name: 'SEO', + components: [ + { + id: 'meta-title', + name: 'Meta title', + type: 'singleLine', + config: { singleLine: { required: true, ...mixed } }, + }, + ], + }); + }); + + it('leaves an unset flag unset: no default is injected, so the backend keeps reading it as "on"', () => { + const result = OperationSchema.safeParse({ + intent: 'shape/create', + type: 'document', + identifier: 'page', + name: 'Page', + components: [ + { id: 'title', name: 'Title', type: 'singleLine', config: { singleLine: { required: true } } }, + ], + }); + expect(result.success).toBe(true); + if (!result.success || result.data.intent !== 'shape/create') { + throw new Error('Expected a parsed shape/create operation'); + } + const config = result.data.components?.[0]?.config?.singleLine; + expect(config).toEqual({ required: true }); + for (const flag of INDEXING_FLAGS) { + expect(config).not.toHaveProperty(flag); + } + }); + + it('keeps false distinguishable from unset', () => { + const result = OperationSchema.safeParse({ + intent: 'shape/create', + type: 'document', + identifier: 'page', + name: 'Page', + components: [ + { id: 'title', name: 'Title', type: 'singleLine', config: { singleLine: { searchable: false } } }, + ], + }); + expect(result.success).toBe(true); + if (!result.success || result.data.intent !== 'shape/create') { + throw new Error('Expected a parsed shape/create operation'); + } + expect(result.data.components?.[0]?.config?.singleLine).toEqual({ searchable: false }); + }); + + it.each(INDEXING_FLAGS)('rejects a non-boolean %s, null included: null is not "unset"', (flag) => { + // null is rejected like it is for `required` and `discoverable`; omit the key to leave a flag unset. + for (const value of ['yes', 'false', 1, null]) { + const result = OperationSchema.safeParse({ + intent: 'shape/create', + type: 'document', + identifier: 'page', + name: 'Page', + components: [ + { id: 'title', name: 'Title', type: 'singleLine', config: { singleLine: { [flag]: value } } }, + ], + }); + expect(result.success).toBe(false); + } + }); + + it('is also kept by the read-side Shape schema (output types)', () => { + const shape = { + identifier: 'chair', + name: 'Chair', + type: 'product', + components: [{ id: 'title', name: 'Title', type: 'singleLine', config: { required: true, ...mixed } }], + }; + const result = ShapeSchema.safeParse(shape); + expect(result.success).toBe(true); + expect(result.data).toEqual(shape); + }); +}); diff --git a/components/schema/tests/mass-operation/valid-files/mass-ops-shape-indexing-flags.json b/components/schema/tests/mass-operation/valid-files/mass-ops-shape-indexing-flags.json new file mode 100644 index 00000000..f25f8f1a --- /dev/null +++ b/components/schema/tests/mass-operation/valid-files/mass-ops-shape-indexing-flags.json @@ -0,0 +1,379 @@ +{ + "version": "1.0.0", + "operations": [ + { + "intent": "piece/upsert", + "identifier": "seo", + "name": "SEO", + "components": [ + { + "id": "meta-title", + "name": "Meta title", + "type": "singleLine", + "config": { + "singleLine": { + "required": true, + "searchable": true, + "filterable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "meta-description", + "name": "Meta description", + "type": "richText", + "config": { + "richText": { + "searchable": false, + "filterable": false, + "sortable": false, + "facetable": false + } + } + } + ] + }, + { + "intent": "shape/upsert", + "type": "product", + "identifier": "chair", + "name": "Chair", + "components": [ + { + "id": "title", + "name": "Title", + "type": "singleLine", + "description": "Searchable only: unset means on", + "config": { + "singleLine": { + "required": true, + "filterable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "internal-notes", + "name": "Internal notes", + "type": "richText", + "description": "No indexing flags set, must round-trip unchanged; discoverable false is the master switch", + "config": { + "richText": { + "discoverable": false + } + } + }, + { + "id": "in-stock", + "name": "In stock", + "type": "boolean", + "config": { + "boolean": { + "searchable": false, + "sortable": false + } + } + }, + { + "id": "release-date", + "name": "Release date", + "type": "datetime", + "config": { + "datetime": { + "searchable": false, + "facetable": false + } + } + }, + { + "id": "showroom", + "name": "Showroom", + "type": "location", + "config": { + "location": { + "searchable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "manual", + "name": "Manual", + "type": "files", + "config": { + "files": { + "maxFileSize": { "size": 20, "unit": "MiB" }, + "searchable": false, + "filterable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "specs", + "name": "Specs", + "type": "propertiesTable", + "config": { + "propertiesTable": { + "sections": [{ "title": "Dimensions", "keys": ["width", "height"] }], + "searchable": false, + "sortable": false + } + } + }, + { + "id": "showcase", + "name": "Showcase", + "type": "gridRelations", + "config": { + "gridRelations": { + "sortable": false, + "facetable": false + } + } + }, + { + "id": "material", + "name": "Material", + "type": "selection", + "description": "Facet and filter, never free-text searched or sorted", + "config": { + "selection": { + "min": 1, + "max": 1, + "options": [ + { "key": "oak", "value": "Oak", "isPreselected": true }, + { "key": "walnut", "value": "Walnut" } + ], + "searchable": false, + "sortable": false + } + } + }, + { + "id": "story", + "name": "Story", + "type": "paragraphCollection", + "config": { + "paragraphCollection": { + "multilingual": ["title", "body"], + "filterable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "dimensions", + "name": "Dimensions", + "type": "contentChunk", + "config": { + "contentChunk": { + "repeatable": false, + "searchable": false, + "facetable": false, + "components": [ + { + "id": "width", + "name": "Width", + "type": "numeric", + "config": { + "numeric": { + "decimalPlaces": 1, + "units": ["cm"], + "searchable": false, + "facetable": false + } + } + }, + { + "id": "weight", + "name": "Weight", + "type": "numeric", + "config": { + "numeric": { + "facetable": false + } + } + } + ] + } + } + }, + { + "id": "hero", + "name": "Hero", + "type": "componentChoice", + "config": { + "componentChoice": { + "searchable": false, + "filterable": false, + "sortable": false, + "facetable": false, + "choices": [ + { + "id": "image", + "name": "Image", + "type": "images", + "config": { "images": { "max": 1, "searchable": false } } + }, + { + "id": "video", + "name": "Video", + "type": "videos", + "config": { "videos": { "max": 1, "searchable": false } } + } + ] + } + } + }, + { + "id": "blocks", + "name": "Blocks", + "type": "componentMultipleChoice", + "config": { + "componentMultipleChoice": { + "allowDuplicates": true, + "sortable": false, + "choices": [ + { + "id": "banner", + "name": "Banner", + "type": "singleLine", + "config": { + "singleLine": { "filterable": false, "sortable": false, "facetable": false } + } + }, + { + "id": "seo", + "name": "SEO", + "type": "piece", + "config": { "piece": { "identifier": "seo", "searchable": false } } + } + ] + } + } + }, + { + "id": "related", + "name": "Related products", + "type": "itemRelations", + "config": { + "itemRelations": { + "minItems": 0, + "maxItems": 10, + "searchable": false, + "sortable": false, + "facetable": false + } + } + } + ], + "variantComponents": [ + { + "id": "color", + "name": "Color", + "type": "colors", + "config": { + "colors": { + "requireLabel": true, + "searchable": false, + "sortable": false + } + } + }, + { + "id": "size", + "name": "Size", + "type": "selection", + "config": { + "selection": { + "options": [ + { "key": "s", "value": "Small" }, + { "key": "m", "value": "Medium" } + ], + "searchable": false, + "sortable": false + } + } + } + ] + }, + { + "intent": "shape/create", + "type": "document", + "identifier": "landing-page", + "name": "Landing page", + "components": [ + { + "id": "headline", + "name": "Headline", + "type": "singleLine", + "config": { + "singleLine": { + "filterable": false, + "sortable": false, + "facetable": false + } + } + } + ] + }, + { + "intent": "shape/update", + "identifier": "landing-page", + "name": "Landing page", + "components": [ + { + "id": "headline", + "name": "Headline", + "type": "singleLine", + "config": { + "singleLine": { + "sortable": false, + "facetable": false + } + } + } + ] + }, + { + "intent": "piece/update", + "identifier": "seo", + "name": "SEO", + "components": [ + { + "id": "meta-title", + "name": "Meta title", + "type": "singleLine", + "config": { + "singleLine": { + "required": true, + "filterable": false, + "sortable": false, + "facetable": false + } + } + }, + { + "id": "meta-description", + "name": "Meta description", + "type": "richText", + "config": { + "richText": { + "searchable": false, + "filterable": false, + "sortable": false, + "facetable": false + } + } + } + ] + } + ] +}