From 64eab79f2b6e19bfa8b6202554e4dee0cf2d8f17 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 26 Aug 2026 12:56:52 -0400 Subject: [PATCH 1/3] PROD-2431: remap checkbox/multi-select Linked Content _ValueField IDs during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkbox/multi-select Linked Content fields store their actual selection as a comma-separated content-ID string in a sibling "_ValueField" field (named by the model schema's LinkeContentDropdownValueField property). The main field object's own "sortids" is only display order and was already remapped correctly, but the sibling _ValueField is a bare string, not an object, so isContentReferenceField() never recognized it as a content reference — it passed through unchanged and shipped raw SOURCE-instance content IDs into the target on sync. This produced two symptoms on the target after sync: the field appearing empty/no-container-bound, and — once the container was re-selected — the source instance's raw IDs rendering as orphaned "checked" items alongside the real (unchecked) target options. Fix: after the existing per-field mapping pass, walk fields a second time for any object with a "sortids" key and remap its sibling "_ValueField" comma-ID string through the same content-item mapper used for sortids. Extracted the shared split/map/join logic into mapContentIdListString() and reused it for both sortids and the new _ValueField pass. --- src/lib/content/content-field-mapper.ts | 64 ++++++++++++++++--- .../tests/content-field-mapper.test.ts | 59 +++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index c55a45a..526b7cd 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -54,6 +54,25 @@ export class ContentFieldMapper { } } + // PROD-2431: a checkbox/multi-select Linked Content field stores its actual selection as a + // comma-separated content-ID string in a sibling "_ValueField" field (named by the + // model schema's LinkeContentDropdownValueField property). The main field's own "sortids" is + // only display order, which mapSingleField already remaps correctly above — but this sibling + // field is a bare string, not an object, so isContentReferenceField() never recognizes it and + // it passes through unchanged, shipping raw SOURCE content IDs into the target instance. + for (const [fieldName, fieldValue] of Object.entries(fields)) { + if (!fieldValue || typeof fieldValue !== "object" || Array.isArray(fieldValue) || !("sortids" in fieldValue)) { + continue; + } + const valueFieldKey = `${fieldName}_ValueField`; + const valueFieldValue = mappedFields[valueFieldKey]; + if (typeof valueFieldValue !== "string" || !valueFieldValue.trim()) continue; + + const valueFieldResult = this.mapContentIdListString(valueFieldValue, context); + mappedFields[valueFieldKey] = valueFieldResult.mappedValue; + validationWarnings += valueFieldResult.warnings; + } + return { mappedFields, validationWarnings, @@ -261,20 +280,47 @@ 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: shared remap for a comma-separated list of SOURCE content IDs, used both for a + * content-reference field's "sortids" and for a checkbox/multi-select field's companion + * "_ValueField" selection string. 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..31339bd 100644 --- a/src/lib/content/tests/content-field-mapper.test.ts +++ b/src/lib/content/tests/content-field-mapper.test.ts @@ -310,6 +310,65 @@ describe("ContentFieldMapper.mapContentFields", () => { }); }); + // ─── checkbox / multi-select "_ValueField" companion (PROD-2431) ──────────── + describe("checkbox/multi-select _ValueField companion remapping", () => { + it("remaps the sibling _ValueField comma-ID string 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 context = { referenceMapper, assetMapper: makeAssetMapper() }; + 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 sibling 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("leaves an unresolved ID in place in the _ValueField string and adds a warning", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockReturnValue(null), + }); + const context = { referenceMapper, assetMapper: makeAssetMapper() }; + 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 touch a _ValueField sibling when the base field has no sortids (e.g. single-select)", () => { + const referenceMapper = makeReferenceMapper({ + getContentItemMappingByContentID: jest.fn().mockReturnValue({ targetContentID: 999 }), + }); + const context = { referenceMapper, assetMapper: makeAssetMapper() }; + const fields = { + featuredPost: { contentid: 113, fulllist: false }, + featuredPost_ValueField: "113", + }; + const result = mapper.mapContentFields(fields, context); + // single-item selection scalarizes featuredPost itself; the untouched sibling string is left as-is + expect(result.mappedFields.featuredPost).toBe("999"); + expect(result.mappedFields.featuredPost_ValueField).toBe("113"); + }); + + it("is a no-op when no matching _ValueField sibling exists", () => { + 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" } }); + }); + }); + describe("cdn URL string fields", () => { it("increments validationErrors for a cdn.aglty.io string field when no context is given (mapAssetUrl throws)", () => { // mapAssetUrl unconditionally accesses context.assetMapper, so passing no context throws, From 8839844702d69f7e282071cb90a39d3d6745742f Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 26 Aug 2026 13:20:46 -0400 Subject: [PATCH 2/3] PROD-2435: replace _ValueField suffix heuristic with schema-driven companion remap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the previous commit's _ValueField-suffix heuristic for finding a linked-content dropdown's companion selection field. Investigating PROD-2435 (LinkedContentDropdown values not remapped during sync) showed the companion field has no fixed naming convention: PlayslipSection/WinningNumbersSection name theirs "linkedContentId", HotAndColdNumbersSection uses "LinkedContentValue" — neither matches "_ValueField". Sweeping the target instance's own ContentDefinitions.xmlSchema confirms only 4 of 14 dropdown fields actually follow that suffix; the rest are arbitrarily named. The real, authoritative name is already recorded on the model: each Content-typed field's settings.LinkeContentDropdownValueField (Agility's own spelling) names its companion. mapContentFields() now reads that directly off a new `model` field on ContentFieldMappingContext, instead of guessing a suffix, and content-batch-processor.ts passes the already in-scope sourceModel through at the one call site. Confirmed against the target Batches table (batch 240, item 1326): the payload already carried the bug baked in — "linkedDrawGameAsset": "1034" (main field, correctly remapped via the existing PROD-2341 single-item-selection path) alongside "linkedContentId": "11875" (the companion, still the raw SOURCE-instance id — 11875 doesn't exist on target, whose whole id range tops out at 1730). Two edge cases the schema-driven lookup has to tolerate: - Self-pointing settings (GameBanner's field names itself as its own companion) — skip re-processing, the ordinary field pass already covers it. - Sentinel values that aren't real field names (PostsListing's LinkeContentDropdownValueField is the literal string "CREATENEW") — tolerate the lookup miss rather than throwing. Also matches the companion field name case-insensitively, since schema casing and the payload's actual field-key casing can differ (observed: schema "LinkedContentValue" vs. a batch payload key "linkedContentValue"). Replaced the 4 old suffix-only tests with 8 covering: PROD-2431's checkbox/multi-select case via schema instead of suffix, PROD-2435's arbitrarily-named single-select case, case-insensitive matching, an unresolved-id warning, the two edge cases above, and two no-op/back- compat cases (no model in context; model with no matching setting). --- src/lib/content/content-field-mapper.ts | 85 ++++++++++++---- .../tests/content-field-mapper.test.ts | 96 +++++++++++++++---- .../content-pusher/content-batch-processor.ts | 3 + 3 files changed, 148 insertions(+), 36 deletions(-) diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index 526b7cd..1805d1e 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,21 +60,31 @@ export class ContentFieldMapper { } } - // PROD-2431: a checkbox/multi-select Linked Content field stores its actual selection as a - // comma-separated content-ID string in a sibling "_ValueField" field (named by the - // model schema's LinkeContentDropdownValueField property). The main field's own "sortids" is - // only display order, which mapSingleField already remaps correctly above — but this sibling - // field is a bare string, not an object, so isContentReferenceField() never recognizes it and - // it passes through unchanged, shipping raw SOURCE content IDs into the target instance. - for (const [fieldName, fieldValue] of Object.entries(fields)) { - if (!fieldValue || typeof fieldValue !== "object" || Array.isArray(fieldValue) || !("sortids" in fieldValue)) { - continue; - } - const valueFieldKey = `${fieldName}_ValueField`; - const valueFieldValue = mappedFields[valueFieldKey]; - if (typeof valueFieldValue !== "string" || !valueFieldValue.trim()) continue; - - const valueFieldResult = this.mapContentIdListString(valueFieldValue, context); + // 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; } @@ -288,11 +304,44 @@ export class ContentFieldMapper { 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) { + const valueFieldName: string | undefined = field?.settings?.LinkeContentDropdownValueField; + 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 checkbox/multi-select field's companion - * "_ValueField" selection string. Unresolvable IDs are left as-is and counted as warnings - * rather than dropped, matching the existing sortids behavior. + * 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, diff --git a/src/lib/content/tests/content-field-mapper.test.ts b/src/lib/content/tests/content-field-mapper.test.ts index 31339bd..00135a3 100644 --- a/src/lib/content/tests/content-field-mapper.test.ts +++ b/src/lib/content/tests/content-field-mapper.test.ts @@ -310,16 +310,31 @@ describe("ContentFieldMapper.mapContentFields", () => { }); }); - // ─── checkbox / multi-select "_ValueField" companion (PROD-2431) ──────────── - describe("checkbox/multi-select _ValueField companion remapping", () => { - it("remaps the sibling _ValueField comma-ID string alongside sortids", () => { + // ─── 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 context = { referenceMapper, assetMapper: makeAssetMapper() }; + 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", @@ -327,16 +342,46 @@ describe("ContentFieldMapper.mapContentFields", () => { 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 sibling now carries the remapped target IDs, not the source IDs + // 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("leaves an unresolved ID in place in the _ValueField string and adds a warning", () => { + it("PROD-2435: remaps a single-select dropdown's arbitrarily-named companion field", () => { const referenceMapper = makeReferenceMapper({ - getContentItemMappingByContentID: jest.fn().mockReturnValue(null), + getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { + return id === 11875 ? { targetContentID: 1034 } : null; + }), }); - const context = { referenceMapper, assetMapper: makeAssetMapper() }; + 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", @@ -346,27 +391,42 @@ describe("ContentFieldMapper.mapContentFields", () => { expect(result.validationWarnings).toBeGreaterThan(0); }); - it("does not touch a _ValueField sibling when the base field has no sortids (e.g. single-select)", () => { + 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 context = { referenceMapper, assetMapper: makeAssetMapper() }; - const fields = { - featuredPost: { contentid: 113, fulllist: false }, - featuredPost_ValueField: "113", - }; + 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); - // single-item selection scalarizes featuredPost itself; the untouched sibling string is left as-is - expect(result.mappedFields.featuredPost).toBe("999"); - expect(result.mappedFields.featuredPost_ValueField).toBe("113"); + expect(result.mappedFields.posts).toEqual({ referencename: "posts-list", fulllist: true }); }); - it("is a no-op when no matching _ValueField sibling exists", () => { + 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" } }); + }); }); 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) From 69bd121db95ee0c7b15d028271136e98a1b7d1d3 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 26 Aug 2026 15:59:44 -0400 Subject: [PATCH 3/3] PROD-2442: remap sortids on full-list grid fields with a custom sort order isListReferenceField() matched any object with referencename+fulllist:true and returned it unchanged from mapSingleField() before mapContentReferenceField() ever ran - including a full-list "grid" Linked Content field that also carries a populated sortids (a custom sort order, via the model's SortIDFieldName setting - the grid analogue of LinkeContentDropdownValueField). That shipped raw SOURCE content IDs to the target on every sync for a grid field with a configured sort order. Fix: isListReferenceField() now also requires an empty/absent sortids, so a populated one falls through to the existing content-reference remap path instead of short-circuiting. getContentDropdownValueFieldNames() now also reads SortIDFieldName as a fallback, so the field's own SortIDFieldName companion column (structurally identical to the _ValueField companion PROD-2431/2435 already fixed) gets remapped too. Found and verified live on test instance a921a90f-us2 while building a full Linked Content permutation matrix (dropdown/checkbox/searchlistbox/shared grid/shared link/nested) to validate PROD-2431/2435's fix - confirmed those render styles work correctly; this grid case was the one gap. Added 3 new test cases: grid field with populated sortids gets remapped (previously silently untouched), a full-list field with empty sortids stays an inert list reference (no regression), and the SortIDFieldName companion gets remapped alongside it. Full suite: 105 suites / 1914 tests passing. npm run build compiles clean. Co-Authored-By: Claude Sonnet 5 --- src/lib/content/content-field-mapper.ts | 18 ++++- .../tests/content-field-mapper.test.ts | 67 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index 1805d1e..e37d483 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -201,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( @@ -318,7 +327,12 @@ export class ContentFieldMapper { const pairs: Array<[string, string]> = []; for (const field of modelFields) { - const valueFieldName: string | undefined = field?.settings?.LinkeContentDropdownValueField; + // 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]); } diff --git a/src/lib/content/tests/content-field-mapper.test.ts b/src/lib/content/tests/content-field-mapper.test.ts index 00135a3..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,45 @@ 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) ─────────── @@ -427,6 +466,34 @@ describe("ContentFieldMapper.mapContentFields", () => { 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", () => {