diff --git a/upload-api/migration-sap-smartedit/libs/contentTypes.ts b/upload-api/migration-sap-smartedit/libs/contentTypes.ts index dc9fa0e2..a785dc59 100644 --- a/upload-api/migration-sap-smartedit/libs/contentTypes.ts +++ b/upload-api/migration-sap-smartedit/libs/contentTypes.ts @@ -3,7 +3,7 @@ import path from 'path'; import config from '../config/index.json'; import { CT, DataConfig, Field } from '../interface/interface'; import mapField, { baseField } from './schemaMapper'; -import { ensureDir, writeJson, parseImpexPath, ImpexColumnDef } from '../utils/helper'; +import { ensureDir, writeJson, parseImpexPath, ImpexColumnDef, ImpexTypeBlock } from '../utils/helper'; const { contentTypes: contentTypesConfig } = config.modules; const contentTypeFolderPath = path.resolve(config.data, contentTypesConfig.dirName); @@ -252,6 +252,31 @@ function ensureMandatoryFields(fieldMapping: Field[]): void { urlField.advanced = { ...urlField.advanced, mandatory: true }; } +/** + * The api writes the source id as the entry title when a row's label is blank or + * shared by several items (two CMSNavigationNodes both titled "Accessories"), so + * titles stay unique. That overwrote the only copy of the label: the SAP text was + * lost, and a storefront could not tell "untitled in SAP" from a real title. When + * any row would fall back like that, keep the label column as its own field too. + */ +function keepSourceLabel(fieldMapping: Field[], block: ImpexTypeBlock): void { + const titleField = fieldMapping.find((f) => f.contentstackFieldUid === 'title'); + const col = titleField?.otherCmsField; + if (!col || !block.columns.has(col)) return; // synthesized title: no label column to keep + const owners = new Map>(); + let blank = false; + for (const row of block.rows) { + const label = row[col]; + if (!label) { blank = true; continue; } + if (!owners.has(label)) owners.set(label, new Set()); + owners.get(label)!.add(row.uid ?? row.code ?? JSON.stringify(row)); + } + if (!blank && [...owners.values()].every((ids) => ids.size === 1)) return; + const field = baseField(`source_${col}`, 'string', 'single_line_text'); + field.otherCmsField = col; + fieldMapping.push(field); +} + /** * Parse the ImpEx export into Contentstack content-type schemas. * @@ -327,6 +352,7 @@ async function extractContentTypes( } ensureMandatoryFields(fieldMapping); + keepSourceLabel(fieldMapping, block); const contentType = { otherCmsTitle: type, diff --git a/upload-api/tests/fixtures/sap-smartedit-source-label/nav-node-label.impex b/upload-api/tests/fixtures/sap-smartedit-source-label/nav-node-label.impex new file mode 100644 index 00000000..691b058b --- /dev/null +++ b/upload-api/tests/fixtures/sap-smartedit-source-label/nav-node-label.impex @@ -0,0 +1,12 @@ +INSERT_UPDATE CMSNavigationNode; uid[unique=true]; title[lang=en]; parent(uid) + ; AccessoriesNavNode; Accessories; Root + ; AccessoriesCatNavNode; Accessories; Root + ; LeafNavNode; ; Root + ; BrandsNavNode; Brands; Root + +INSERT_UPDATE CMSParagraphComponent; uid[unique=true]; name; content + ; para1; First Paragraph;

a

+ ; para2; Second Paragraph;

b

+ +INSERT_UPDATE CMSNavigationEntry; uid[unique=true]; navigationNode(uid); item(uid) + ; entry1; LeafNavNode; para1 diff --git a/upload-api/tests/unit/migration-sap-smartedit/contentTypes.sourceLabel.test.ts b/upload-api/tests/unit/migration-sap-smartedit/contentTypes.sourceLabel.test.ts new file mode 100644 index 00000000..25105b63 --- /dev/null +++ b/upload-api/tests/unit/migration-sap-smartedit/contentTypes.sourceLabel.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { extractContentTypes } from '../../../migration-sap-smartedit/index'; + +/** + * The api titles an entry by its source id when the label is blank or shared, which + * overwrote the only copy of the SAP label (two nav nodes titled "Accessories" lost + * it). Types where that fallback can happen keep the label in its own field. + */ +const FIXTURE = path.join(__dirname, '../../fixtures/sap-smartedit-source-label/nav-node-label.impex'); + +let cts: any[] = []; +const fields = (type: string) => cts.find((c) => c.otherCmsTitle === type)?.fieldMapping ?? []; + +beforeAll(async () => { + cts = (await extractContentTypes('cs', FIXTURE, {} as any)) as any[]; +}); + +afterAll(() => { + fs.rmSync(path.join(process.cwd(), 'cmsMigrationData'), { recursive: true, force: true }); +}); + +describe('keepSourceLabel — the label survives a title that falls back to the id', () => { + it('adds source_title, read from the title column, when labels are shared or blank', () => { + const f = fields('CMSNavigationNode').find((x: any) => x.contentstackFieldUid === 'source_title'); + expect(f?.otherCmsField).toBe('title'); + expect(f?.contentstackFieldType).toBe('single_line_text'); + }); + + it('keeps title itself mapped to the same column', () => { + expect(fields('CMSNavigationNode').find((x: any) => x.contentstackFieldUid === 'title')?.otherCmsField).toBe('title'); + }); + + it('adds nothing when the type has no label column (title is synthesized)', () => { + expect(fields('CMSNavigationEntry').some((x: any) => x.contentstackFieldUid.startsWith('source_'))).toBe(false); + }); + + it('adds nothing when every label is present and unique', () => { + expect(fields('CMSParagraphComponent').some((x: any) => x.contentstackFieldUid.startsWith('source_'))).toBe(false); + }); +});