diff --git a/components/crystallize-import/src/contracts/form-submission.ts b/components/crystallize-import/src/contracts/form-submission.ts index 2124844..5cfd36b 100644 --- a/components/crystallize-import/src/contracts/form-submission.ts +++ b/components/crystallize-import/src/contracts/form-submission.ts @@ -3,6 +3,10 @@ export interface FormSubmission { folderPath: string; rows: Record[]; mapping: Record; + subFolderMapping: { + column: string; + shapeIdentifier: string; + }[]; groupProductsBy?: string; doPublish: boolean; channel?: string; diff --git a/components/crystallize-import/src/contracts/ui-types.ts b/components/crystallize-import/src/contracts/ui-types.ts index 2e761c0..deb60f6 100644 --- a/components/crystallize-import/src/contracts/ui-types.ts +++ b/components/crystallize-import/src/contracts/ui-types.ts @@ -23,6 +23,10 @@ export type Action = type: 'UPDATE_MAPPING'; mapping: State['mapping']; } + | { + type: 'UPDATE_SUB_FOLDER_MAPPING'; + mapping: State['subFolderMapping']; + } | { type: 'UPDATE_PRODUCT_VARIANT_ATTRIBUTES'; attributes: State['attributes']; @@ -49,6 +53,7 @@ export type Actions = { updateGroupProductsBy: (groupProductsBy: State['groupProductsBy']) => void; updateSpreadsheet: (headers: State['headers'], rows: State['rows']) => void; updateMapping: (mapping: State['mapping']) => void; + updateSubFolderMapping: (mapping: State['subFolderMapping']) => void; updateProductVariantAttributes: (attributes: State['attributes']) => void; updateDone: (done: State['done']) => void; updateLoading: (loading: State['loading']) => void; @@ -72,7 +77,13 @@ export type State = { headers: string[]; attributes: string[]; rows: Record[]; + // in the form of `path.to.field: "Spreadsheet Column Name"` mapping: Record; + // categorize/folderize, in the form of `path.to.field` + subFolderMapping: { + column: string; + shapeIdentifier: string; + }[]; groupProductsBy?: string; errors?: string[]; loading?: boolean; diff --git a/components/crystallize-import/src/core.server/spec-from-form-submission.server.ts b/components/crystallize-import/src/core.server/spec-from-form-submission.server.ts index cbd2c32..85de47b 100644 --- a/components/crystallize-import/src/core.server/spec-from-form-submission.server.ts +++ b/components/crystallize-import/src/core.server/spec-from-form-submission.server.ts @@ -1,9 +1,9 @@ import { + JSONItem, type JSONComponentContent, type JSONImage, type JSONProduct, type JSONProductVariant, - type JsonSpec, } from '@crystallize/import-utilities'; import { v4 as uuidv4 } from 'uuid'; import type { NumericComponentConfig, Shape } from '@crystallize/schema'; @@ -113,48 +113,93 @@ const mapVariant = (row: Record, mapping: Record, s }; export const specFromFormSubmission = async (submission: FormSubmission, shapes: Shape[]) => { - const { shapeIdentifier, folderPath, rows, mapping, groupProductsBy } = submission; - const spec: JsonSpec = {}; + const { shapeIdentifier, folderPath, rows, mapping, groupProductsBy, subFolderMapping } = submission; const shape = shapes.find((s) => s.identifier === shapeIdentifier); if (!shape) { throw new Error(`Shape ${shapeIdentifier} not found.`); } + const buildExternalReference = (name: string) => { + return folderPath.replace(/^\//, '-').replace(/\//g, '-') + '-' + name.replace(/\//g, '-').toLocaleLowerCase(); + }; - const variants = rows.map((row) => mapVariant(row, mapping, shape)); - const mapProduct = (obj: Record, row: Record, i: number) => { - const productName = row[mapping['item.name']]; - let product: JSONProduct = { - name: productName || variants[i].name, - shape: shape.identifier, - vatType: 'No Tax', - parentCataloguePath: folderPath, - variants: [variants[i]], - components: mapComponents(row, mapping, 'components', shape), - }; + const folders = subFolderMapping + ? rows.reduce((memo: JSONItem[], row) => { + const depth = subFolderMapping.length; + for (let d = 0; d < depth; d++) { + const column = subFolderMapping[d].column; + const name = row[column]; + const folder = { + name, + shape: subFolderMapping[d].shapeIdentifier, + externalReference: buildExternalReference(name), + ...(d === 0 + ? { parentCataloguePath: folderPath } + : { parentExternalReference: buildExternalReference(row[subFolderMapping[d - 1].column]) }), + }; + if (!memo.some((f) => f.externalReference === folder.externalReference)) { + memo.push(folder); + } + } + return memo; + }, []) + : []; + + // init spec with folder + const items: JSONItem[] = folders; - if (groupProductsBy) { - if (obj[row[groupProductsBy]]) { - product = obj[row[groupProductsBy]]; - product.variants = product.variants.concat(variants[i]); + const findParent = (row: Record) => { + if (subFolderMapping) { + const last = subFolderMapping.length - 1; + const folder = folders.find( + (f) => f.externalReference === buildExternalReference(row[subFolderMapping[last].column]), + ); + if (folder) { + return { + parentExternalReference: folder.externalReference, + }; } - obj[row[groupProductsBy]] = product; - } else { - obj[uuidv4()] = product; + return { parentCataloguePath: folderPath }; } - - return obj; }; if (shape.type === 'product') { - spec.items = Object.values(rows.reduce(mapProduct, {})); + const variants = rows.map((row) => mapVariant(row, mapping, shape)); + const mapProduct = (obj: Record, row: Record, i: number) => { + const productName = row[mapping['item.name']]; + let product: JSONProduct = { + name: productName || variants[i].name, + shape: shape.identifier, + vatType: 'No Tax', + variants: [variants[i]], + components: mapComponents(row, mapping, 'components', shape), + ...findParent(row), + }; + + if (groupProductsBy) { + if (obj[row[groupProductsBy]]) { + product = obj[row[groupProductsBy]]; + product.variants = product.variants.concat(variants[i]); + } + obj[row[groupProductsBy]] = product; + } else { + obj[uuidv4()] = product; + } + + return obj; + }; + items.push(...Object.values(rows.reduce(mapProduct, {}))); } else { - spec.items = rows.map((row) => ({ - name: row[mapping['item.name']], - shape: shape.identifier, - parentCataloguePath: folderPath, - components: mapComponents(row, mapping, 'components', shape), - })); + items.push( + ...rows.map((row) => ({ + name: row[mapping['item.name']], + shape: shape.identifier, + components: mapComponents(row, mapping, 'components', shape), + ...findParent(row), + })), + ); } - return spec; + return { + items, + }; }; diff --git a/components/crystallize-import/src/ui/import/components/DataForm.tsx b/components/crystallize-import/src/ui/import/components/DataForm.tsx index 6fb3c65..1515e16 100644 --- a/components/crystallize-import/src/ui/import/components/DataForm.tsx +++ b/components/crystallize-import/src/ui/import/components/DataForm.tsx @@ -62,6 +62,7 @@ export const DataMatchingForm = () => { folderPath: state.selectedFolder.tree?.path ?? '/', groupProductsBy: state.groupProductsBy, mapping: state.mapping, + subFolderMapping: state.subFolderMapping, channel: channelRef.current?.value, rows: state.rows.filter((row) => row._import), }; diff --git a/components/crystallize-import/src/ui/import/components/action-bar/Submit.tsx b/components/crystallize-import/src/ui/import/components/action-bar/Submit.tsx index b6d18f0..09c75c4 100644 --- a/components/crystallize-import/src/ui/import/components/action-bar/Submit.tsx +++ b/components/crystallize-import/src/ui/import/components/action-bar/Submit.tsx @@ -52,6 +52,7 @@ export const Submit = () => { mapping: state.mapping, rows: state.rows.filter((row) => row._import), doPublish: publishRef.current?.checked ?? false, + subFolderMapping: state.subFolderMapping, validFlowStage: validFlowRef.current?.value ?? undefined, invalidFlowStage: invalidFlowRef.current?.value ?? undefined, }; diff --git a/components/crystallize-import/src/ui/import/components/data-grid/ColumnHeader.tsx b/components/crystallize-import/src/ui/import/components/data-grid/ColumnHeader.tsx index ddb0926..6142212 100644 --- a/components/crystallize-import/src/ui/import/components/data-grid/ColumnHeader.tsx +++ b/components/crystallize-import/src/ui/import/components/data-grid/ColumnHeader.tsx @@ -92,6 +92,7 @@ export const ColumnHeader = ({ title }: ColumnHeaderProps) => { setIsPopoverOpen(!isPopoverOpen); }; + const subFolderMapped = state.subFolderMapping?.find((folderM) => folderM.column === title); return ( <> {title} @@ -101,6 +102,56 @@ export const ColumnHeader = ({ title }: ColumnHeaderProps) => { positions={['bottom']} content={
+ <> +

Categories

+

+ Create a subfolder from that column. +

+
    + {state.shapes + .filter((shape) => shape.type === 'folder') + .map((shape) => ( +
  • { + const newMapping = [ + // we remove the existing one and put it back at the end of the array if so + ...(state.subFolderMapping?.filter( + (folderM) => folderM.column !== title, + ) ?? []), + { + column: title, + shapeIdentifier: shape.identifier, + }, + ]; + dispatch.updateSubFolderMapping(newMapping); + setIsPopoverOpen(!isPopoverOpen); + }} + > + {shape.name} +
  • + ))} + {subFolderMapped && ( +
  • { + // just remove the existing one + const newMapping = state.subFolderMapping?.filter( + (folderM) => folderM.column !== title, + ); + dispatch.updateSubFolderMapping(newMapping); + setIsPopoverOpen(!isPopoverOpen); + }} + > + Clear +
  • + )} +
+ +
+

Field Mapping

+

One to one mapping.

{!!basicItemFields.length && ( { } >