Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 119 additions & 10 deletions src/lib/content/content-field-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 "<field>_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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
186 changes: 186 additions & 0 deletions src/lib/content/tests/content-field-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number> = { 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 "<field>_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<number, number> = { 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<number, number> = { 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", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/pushers/content-pusher/content-batch-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading