diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index c55a45a..e37d483 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -12,6 +12,12 @@ export interface ContentFieldMappingContext { assetMapper: AssetMapper; apiClient?: mgmtApi.ApiClient; targetGuid?: string; + // PROD-2431/PROD-2435: the source model for the content item being mapped. Content-typed + // dropdown fields declare a `LinkeContentDropdownValueField` setting naming a companion field + // that separately carries the raw selected content ID(s) — the model is the only place that + // name is recorded, and it varies per field (no fixed naming convention), so it must be read + // from here rather than guessed. + model?: mgmtApi.Model | { fields?: mgmtApi.ModelField[] | any[] }; } export interface ContentFieldMappingResult { @@ -54,6 +60,35 @@ export class ContentFieldMapper { } } + // PROD-2431/PROD-2435: a Content-typed dropdown field (single-select OR checkbox/multi-select) + // can store its actual selection in a companion field, separate from the field the designer + // sees — named by that field's own model setting, LinkeContentDropdownValueField. That + // companion is declared as plain text in the schema, so it's a bare string (or comma-separated + // string for multi-select) rather than the {contentid}/{sortids} object shape the passes above + // remap — it's invisible to isContentReferenceField() and passes through untouched, shipping + // raw SOURCE content ID(s) into the target instance. + // + // The companion's *name* has no fixed convention — sampling this codebase's own target schema, + // only 4 of 14 dropdown fields name it "_ValueField"; the rest (e.g. PlayslipSection's + // "linkedContentId", HotAndColdNumbersSection's "LinkedContentValue") don't. The model schema is + // the only reliable source for the real name, so remap by reading it from context.model instead + // of guessing a suffix. + for (const [mainFieldName, valueFieldName] of this.getContentDropdownValueFieldNames(context)) { + // Self-pointing setting (e.g. GameBanner's own field name): nothing separate to remap — + // the mainFieldName pass above (or the sortids/contentid handling) already covers it. + if (valueFieldName.toLowerCase() === mainFieldName.toLowerCase()) continue; + + const valueFieldKey = this.findFieldKey(fields, valueFieldName); + if (!valueFieldKey) continue; // e.g. a sentinel setting like "CREATENEW" that names no real field + + const rawValue = fields[valueFieldKey]; + if (typeof rawValue !== "string" || !rawValue.trim()) continue; + + const valueFieldResult = this.mapContentIdListString(rawValue, context); + mappedFields[valueFieldKey] = valueFieldResult.mappedValue; + validationWarnings += valueFieldResult.warnings; + } + return { mappedFields, validationWarnings, @@ -166,7 +201,16 @@ export class ContentFieldMapper { // Check for list reference patterns (referencename with fulllist) const hasReferencename = "referencename" in fieldValue || "referenceName" in fieldValue; const hasFulllist = fieldValue.fulllist === true || fieldValue.fullList === true; - return hasReferencename && hasFulllist; + // PROD-2442: a full-list "grid" Linked Content field carries referencename+fulllist:true + // exactly like a bare list-by-name reference, but it can ALSO have a populated sortids (a + // custom sort order, via the model's SortIDFieldName setting — the grid analogue of + // LinkeContentDropdownValueField). Treating every referencename+fulllist object as an inert + // list reference short-circuited mapSingleField() before mapContentReferenceField() ever ran, + // so that sortids shipped to the target with raw SOURCE content IDs. Only fields with no + // populated sortids are genuinely "reference by name only" — fall through to the + // content-reference path (which already knows how to remap sortids) otherwise. + const hasSortIds = typeof fieldValue.sortids === "string" && fieldValue.sortids.trim().length > 0; + return hasReferencename && hasFulllist && !hasSortIds; } private mapAssetAttachmentField( @@ -261,20 +305,85 @@ export class ContentFieldMapper { // Map sortids (comma-separated content IDs) if (fieldValue.sortids) { - const sourceIds = fieldValue.sortids - .toString() - .split(",") - .map((id) => parseInt(id.trim())); - const mappedIds = sourceIds.map((sourceId) => { - const mapping = context.referenceMapper.getContentItemMappingByContentID(sourceId, "source"); - return this.resolveTargetContentID(mapping) ?? sourceId; - }); - mappedValue.sortids = mappedIds.join(","); + const sortidsResult = this.mapContentIdListString(fieldValue.sortids.toString(), context); + mappedValue.sortids = sortidsResult.mappedValue; + warnings += sortidsResult.warnings; } return { mappedValue, warnings, errors }; } + /** + * PROD-2431/PROD-2435: read [mainFieldName, companionFieldName] pairs off the model schema's + * Content-typed fields. `settings.LinkeContentDropdownValueField` (Agility's own spelling) names + * the field that actually carries the raw content ID(s) for a linked-content dropdown. Only + * non-empty settings are returned; the caller still has to handle the value naming a field that + * doesn't exist in the payload (a sentinel like "CREATENEW") or naming itself (no separate + * companion to remap). + */ + private getContentDropdownValueFieldNames(context?: ContentFieldMappingContext): Array<[string, string]> { + const modelFields = context?.model?.fields; + if (!Array.isArray(modelFields)) return []; + + const pairs: Array<[string, string]> = []; + for (const field of modelFields) { + // PROD-2442: a "grid" (full-list) Linked Content field's companion selection column is named + // by SortIDFieldName instead of LinkeContentDropdownValueField — the same per-field, no-fixed- + // convention naming problem PROD-2431/2435 already solved for dropdown/checkbox, just under a + // different setting. Fall back to it so that companion also gets remapped. + const valueFieldName: string | undefined = + field?.settings?.LinkeContentDropdownValueField || field?.settings?.SortIDFieldName; + if (field?.name && valueFieldName) { + pairs.push([field.name, valueFieldName]); + } + } + return pairs; + } + + /** + * Look up a field by name in a fields object, case-insensitively — the model schema's field + * name and the payload's actual field key can differ in casing (e.g. schema "LinkedContentValue" + * vs. a payload key camelCased to "linkedContentValue"). + */ + private findFieldKey(fields: any, fieldName: string): string | undefined { + if (fieldName in fields) return fieldName; + const lowerTarget = fieldName.toLowerCase(); + return Object.keys(fields).find((key) => key.toLowerCase() === lowerTarget); + } + + /** + * PROD-2431: shared remap for a comma-separated list of SOURCE content IDs, used both for a + * content-reference field's "sortids" and for a linked-content dropdown's companion selection + * field (named by the model's LinkeContentDropdownValueField setting). Unresolvable IDs are left + * as-is and counted as warnings rather than dropped, matching the existing sortids behavior. + */ + private mapContentIdListString( + value: string, + context?: ContentFieldMappingContext + ): { mappedValue: string; warnings: number } { + if (!context?.referenceMapper) { + return { mappedValue: value, warnings: 1 }; + } + + let warnings = 0; + const mappedIds = value + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + .map((id) => { + const sourceId = parseInt(id, 10); + const mapping = context.referenceMapper.getContentItemMappingByContentID(sourceId, "source"); + const targetId = this.resolveTargetContentID(mapping); + if (targetId == null) { + warnings++; + return id; + } + return String(targetId); + }); + + return { mappedValue: mappedIds.join(","), warnings }; + } + /** * PROD-2341: read the target contentID off a content-item mapping record. The mapper returns * records whose target id is `targetContentID`; earlier code here read `.contentID`, which is diff --git a/src/lib/content/tests/content-field-mapper.test.ts b/src/lib/content/tests/content-field-mapper.test.ts index f326de0..6e1932a 100644 --- a/src/lib/content/tests/content-field-mapper.test.ts +++ b/src/lib/content/tests/content-field-mapper.test.ts @@ -308,6 +308,192 @@ describe("ContentFieldMapper.mapContentFields", () => { const result = mapper.mapContentFields(fields, context); expect(result.mappedFields.list.sortids).toBe("571,109"); }); + + // PROD-2442: a full-list "grid" Linked Content field looks exactly like a bare list-by-name + // reference (referencename + fulllist:true) but can ALSO carry a populated sortids — a custom + // sort order, via the model's SortIDFieldName setting. isListReferenceField() used to match on + // referencename+fulllist alone and short-circuit mapSingleField() before this sortids remap ever + // ran, shipping raw SOURCE content IDs to the target. Confirmed live against a Shared Grid field + // with a custom sort order on test instance a921a90f-us2. + it("PROD-2442: remaps sortids on a full-list grid field instead of treating it as an inert list reference", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { + const map: Record = { 13084: 14084, 13085: 14085, 13086: 14086, 13087: 14087, 13088: 14088 }; + return map[id] ? { targetContentID: map[id] } : null; + }), + }); + const context = { referenceMapper, assetMapper: makeAssetMapper() }; + const fields = { + sharedGridSorted: { + referencename: "linktesttargets", + containerID: 1667, + sortids: "13088,13087,13086,13085,13084", + fulllist: true, + }, + }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.sharedGridSorted.sortids).toBe("14088,14087,14086,14085,14084"); + // still a full-list link, unrelated properties untouched + expect(result.mappedFields.sharedGridSorted.fulllist).toBe(true); + expect(result.mappedFields.sharedGridSorted.referencename).toBe("linktesttargets"); + }); + + it("still treats a full-list field with no populated sortids as an inert list reference (no regression)", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockReturnValue({ targetContentID: 999 }), + }); + const context = { referenceMapper, assetMapper: makeAssetMapper() }; + const fields = { sharedGridPlain: { referencename: "linktesttargets", sortids: "", fulllist: true } }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.sharedGridPlain).toEqual({ referencename: "linktesttargets", sortids: "", fulllist: true }); + }); + }); + + // ─── schema-driven linked-content dropdown companion remap (PROD-2431/PROD-2435) ─────────── + // The companion field that actually carries the raw content ID(s) for a linked-content dropdown + // is named by that field's own model setting, LinkeContentDropdownValueField — not by any fixed + // suffix convention (confirmed against the target instance's own schema: only 4 of 14 dropdown + // fields use "_ValueField"; the rest, like PlayslipSection's "linkedContentId" or + // HotAndColdNumbersSection's "LinkedContentValue", don't). So the mapper reads it off context.model. + describe("linked-content dropdown companion remap (schema-driven)", () => { + function makeModel(fields: Array<{ name: string; valueField?: string }>) { + return { + fields: fields.map((f) => ({ + name: f.name, + settings: f.valueField ? { LinkeContentDropdownValueField: f.valueField } : {}, + })), + }; + } + + it("PROD-2431: remaps a checkbox/multi-select field's companion (suffix-named) alongside sortids", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { + const map: Record = { 12689: 14, 12690: 18, 12691: 17, 12693: 16 }; + return map[id] ? { targetContentID: map[id] } : null; + }), + }); + const model = makeModel([{ name: "levelConfig", valueField: "levelConfig_ValueField" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { + levelConfig: { referencename: "loyaltyassets", sortids: "18,17,16,14", fulllist: false }, + levelConfig_ValueField: "12689,12690,12691,12693", + }; + const result = mapper.mapContentFields(fields, context); + // sortids is already target-side order and passes through the existing content-reference path + expect(result.mappedFields.levelConfig.sortids).toBe("18,17,16,14"); + // the previously-untouched companion now carries the remapped target IDs, not the source IDs + expect(result.mappedFields.levelConfig_ValueField).toBe("14,18,17,16"); + expect(result.validationErrors).toBe(0); + }); + + it("PROD-2435: remaps a single-select dropdown's arbitrarily-named companion field", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { + return id === 11875 ? { targetContentID: 1034 } : null; + }), + }); + const model = makeModel([{ name: "linkedDrawGameAsset", valueField: "linkedContentId" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { + // main field already correctly scalarized by the existing PROD-2341 single-item path + linkedDrawGameAsset: "1034", + // companion is plain text in the schema — previously invisible to the mapper entirely + linkedContentId: "11875", + gameCode: "euro-jackpot", + }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.linkedContentId).toBe("1034"); + expect(result.validationErrors).toBe(0); + }); + + it("matches the companion field name case-insensitively (schema casing vs. payload casing)", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockReturnValue({ targetContentID: 1034 }), + }); + const model = makeModel([{ name: "linkedDrawGameAsset", valueField: "LinkedContentValue" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { linkedDrawGameAsset: "1034", linkedContentValue: "11875" }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.linkedContentValue).toBe("1034"); + }); + + it("leaves an unresolved ID in place in the companion string and adds a warning", () => { + const referenceMapper = makeReferenceMapper({ getContentItemMappingByContentID: jest.fn().mockReturnValue(null) }); + const model = makeModel([{ name: "featureListConfig", valueField: "featureListConfig_ValueField" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { + featureListConfig: { referencename: "features", sortids: "1,2", fulllist: false }, + featureListConfig_ValueField: "13113,13114", + }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.featureListConfig_ValueField).toBe("13113,13114"); + expect(result.validationWarnings).toBeGreaterThan(0); + }); + + it("does not double-process a self-pointing setting (e.g. GameBanner's field naming itself)", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockReturnValue({ targetContentID: 999 }), + }); + const model = makeModel([{ name: "drawGame", valueField: "drawGame" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { drawGame: { contentid: 11868, fulllist: false } }; + const result = mapper.mapContentFields(fields, context); + // handled once, by the ordinary single-item-selection path — scalarized, not left as an object + expect(result.mappedFields.drawGame).toBe("999"); + }); + + it("tolerates a sentinel setting that names no real field (e.g. PostsListing's 'CREATENEW')", () => { + const referenceMapper = makeReferenceMapper(); + const model = makeModel([{ name: "posts", valueField: "CREATENEW" }]); + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { posts: { referencename: "posts-list", fulllist: true } }; + expect(() => mapper.mapContentFields(fields, context)).not.toThrow(); + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.posts).toEqual({ referencename: "posts-list", fulllist: true }); + }); + + it("is a no-op when no model is supplied in context (back-compat)", () => { + const context = { referenceMapper: makeReferenceMapper(), assetMapper: makeAssetMapper() }; + const fields = { list: { sortids: "5,6" } }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields).toEqual({ list: { sortids: "5,6" } }); + }); + + it("is a no-op when the model has no field settings for LinkeContentDropdownValueField", () => { + const model = makeModel([{ name: "list" }]); + const context = { referenceMapper: makeReferenceMapper(), assetMapper: makeAssetMapper(), model }; + const fields = { list: { sortids: "5,6" } }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields).toEqual({ list: { sortids: "5,6" } }); + }); + + // PROD-2442: a "grid" (full-list) Linked Content field's companion selection column is named by + // SortIDFieldName instead of LinkeContentDropdownValueField — same per-field, no-fixed-convention + // naming problem PROD-2431/2435 solved for dropdown/checkbox, just under a different setting. + it("PROD-2442: remaps a grid field's SortIDFieldName companion alongside its own sortids", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { + const map: Record = { 13084: 14084, 13085: 14085, 13086: 14086, 13087: 14087, 13088: 14088 }; + return map[id] ? { targetContentID: map[id] } : null; + }), + }); + const model = { + fields: [{ name: "sharedGridSorted", settings: { SortIDFieldName: "sharedGridSorted_SortField" } }], + }; + const context = { referenceMapper, assetMapper: makeAssetMapper(), model }; + const fields = { + sharedGridSorted: { + referencename: "linktesttargets", + containerID: 1667, + sortids: "13088,13087,13086,13085,13084", + fulllist: true, + }, + sharedGridSorted_SortField: "13088,13087,13086,13085,13084", + }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.sharedGridSorted.sortids).toBe("14088,14087,14086,14085,14084"); + expect(result.mappedFields.sharedGridSorted_SortField).toBe("14088,14087,14086,14085,14084"); + }); }); describe("cdn URL string fields", () => { diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index e57c838..8ecd5a3 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -463,6 +463,9 @@ export class ContentBatchProcessor { assetMapper, apiClient: this.config.apiClient, targetGuid: this.config.targetGuid, + // PROD-2431/PROD-2435: sourceModel carries each field's LinkeContentDropdownValueField + // setting, needed to remap a linked-content dropdown's companion selection field. + model: sourceModel, }); // Only log field mapper issues if there are actual errors (not warnings)