Skip to content

Commit f99d79a

Browse files
Merge pull request #828 from contentstack/feat/custom-app
Implement UID sanitization and enhance schema building in content-typ…
2 parents 4a4d22c + 0bb95a7 commit f99d79a

File tree

1 file changed

+66
-101
lines changed

1 file changed

+66
-101
lines changed

api/src/utils/content-type-creator.utils.ts

Lines changed: 66 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ interface ContentType {
3737
schema: any[]; // Replace `any` with the specific type if known
3838
}
3939

40+
const RESERVED_UIDS = new Set(['locale', 'publish_details', 'tags']);
41+
42+
function sanitizeUid(uid?: string) {
43+
if (!uid) return uid;
44+
let out = uid?.replace?.(/[^a-zA-Z0-9_]/g, '_').replace?.(/^_+/, '');
45+
if (!/^[a-zA-Z]/.test(out)) out = `field_${out}`;
46+
if (RESERVED_UIDS.has(out)) out = `cm_${out}`; // avoid reserved values
47+
return out.toLowerCase();
48+
}
49+
4050
function extractFieldName(input: string): string {
4151
// Extract text inside parentheses (e.g., "JSON Editor-App")
4252
const match = input.match(/\(([^)]+)\)/);
@@ -208,6 +218,11 @@ function getLastSegmentNew(str: string, separator: string): string {
208218
}
209219

210220
export function buildSchemaTree(fields: any[], parentUid = '', parentType = ''): any[] {
221+
222+
if (!Array.isArray(fields)) {
223+
console.warn('buildSchemaTree called with invalid fields:', fields);
224+
return [];
225+
}
211226
// Build a lookup map for O(1) access
212227
const fieldMap = new Map<string, any>();
213228
fields.forEach(f => {
@@ -315,8 +330,8 @@ const saveAppMapper = async ({ marketPlacePath, data, fileName }: any) => {
315330

316331
const convertToSchemaFormate = ({ field, advanced = false, marketPlacePath, keyMapper }: any) => {
317332
// Clean up field UID by removing ALL leading underscores
318-
const cleanedUid = field?.uid?.replace(/^_+/, '') || field?.uid;
319-
333+
const rawUid = field?.uid;
334+
const cleanedUid = sanitizeUid(rawUid);
320335
switch (field?.contentstackFieldType) {
321336
case 'single_line_text': {
322337
return {
@@ -416,18 +431,18 @@ const convertToSchemaFormate = ({ field, advanced = false, marketPlacePath, keyM
416431

417432
case 'dropdown': {
418433
// 🔧 CONDITIONAL LOGIC: Check if choices have key-value pairs or just values
419-
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
420-
? field?.advanced?.options
434+
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
435+
? field?.advanced?.options
421436
: [{ value: "NF" }];
422-
437+
423438
// Filter out null/undefined choices and ensure they are valid objects
424-
const choices = Array.isArray(rawChoices)
439+
const choices = Array.isArray(rawChoices)
425440
? rawChoices.filter((choice: any) => choice != null && typeof choice === 'object')
426441
: [{ value: "NF" }];
427-
428-
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
442+
443+
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
429444
choices.some((choice: any) => choice != null && typeof choice === 'object' && choice.key !== undefined && choice.key !== null);
430-
445+
431446
const data = {
432447
"data_type": ['dropdownNumber', 'radioNumber', 'ratingNumber'].includes(field.otherCmsType) ? 'number' : "text",
433448
"display_name": field?.title,
@@ -456,18 +471,18 @@ const convertToSchemaFormate = ({ field, advanced = false, marketPlacePath, keyM
456471
}
457472
case 'radio': {
458473
// 🔧 CONDITIONAL LOGIC: Check if choices have key-value pairs or just values
459-
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
460-
? field?.advanced?.options
474+
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
475+
? field?.advanced?.options
461476
: [{ value: "NF" }];
462-
477+
463478
// Filter out null/undefined choices and ensure they are valid objects
464-
const choices = Array.isArray(rawChoices)
479+
const choices = Array.isArray(rawChoices)
465480
? rawChoices.filter((choice: any) => choice != null && typeof choice === 'object')
466481
: [{ value: "NF" }];
467-
468-
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
482+
483+
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
469484
choices.some((choice: any) => choice != null && typeof choice === 'object' && choice.key !== undefined && choice.key !== null);
470-
485+
471486
const data = {
472487
"data_type": ['dropdownNumber', 'radioNumber', 'ratingNumber'].includes(field.otherCmsType) ? 'number' : "text",
473488
"display_name": field?.title,
@@ -495,18 +510,18 @@ const convertToSchemaFormate = ({ field, advanced = false, marketPlacePath, keyM
495510
}
496511
case 'checkbox': {
497512
// 🔧 CONDITIONAL LOGIC: Check if choices have key-value pairs or just values
498-
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
499-
? field?.advanced?.options
513+
const rawChoices = Array.isArray(field?.advanced?.options) && field?.advanced?.options?.length > 0
514+
? field?.advanced?.options
500515
: [{ value: "NF" }];
501-
516+
502517
// Filter out null/undefined choices and ensure they are valid objects
503-
const choices = Array.isArray(rawChoices)
518+
const choices = Array.isArray(rawChoices)
504519
? rawChoices.filter((choice: any) => choice != null && typeof choice === 'object')
505520
: [{ value: "NF" }];
506-
507-
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
521+
522+
const hasKeyValuePairs = Array.isArray(choices) && choices.length > 0 &&
508523
choices.some((choice: any) => choice != null && typeof choice === 'object' && choice.key !== undefined && choice.key !== null);
509-
524+
510525
const data = {
511526
"data_type": "text",
512527
"display_name": field?.title,
@@ -803,7 +818,7 @@ const convertToSchemaFormate = ({ field, advanced = false, marketPlacePath, keyM
803818
"non_localizable": field.advanced?.nonLocalizable ?? false,
804819
}
805820
} else {
806-
console.info('Contnet Type Filed', field?.contentstackField)
821+
console.info('Content Type Field', field?.contentstackField)
807822
}
808823
}
809824
}
@@ -866,22 +881,22 @@ const writeGlobalField = async (schema: any, globalSave: string) => {
866881
return;
867882
}
868883
}
869-
884+
870885
// 🔧 FIX: Check for duplicates before adding
871886
if (!schema || typeof schema !== 'object') {
872887
console.error("🚀 ~ writeGlobalField ~ Invalid schema provided");
873888
return;
874889
}
875-
890+
876891
if (!schema.uid) {
877892
console.error("🚀 ~ writeGlobalField ~ Schema missing uid");
878893
return;
879894
}
880-
895+
881896
if (!Array.isArray(globalfields)) {
882897
globalfields = [];
883898
}
884-
899+
885900
const existingIndex = globalfields.findIndex((gf: any) => gf != null && gf.uid === schema.uid);
886901
if (existingIndex !== -1 && existingIndex < globalfields.length) {
887902
// Replace existing global field instead of duplicating
@@ -896,7 +911,7 @@ const writeGlobalField = async (schema: any, globalSave: string) => {
896911
console.error("🚀 ~ writeGlobalField ~ Cannot push schema: invalid schema or globalfields array");
897912
}
898913
}
899-
914+
900915
try {
901916
await fs.promises.writeFile(filePath, JSON.stringify(globalfields, null, 2));
902917
} catch (writeErr) {
@@ -971,90 +986,40 @@ const mergeTwoCts = async (ct: any, mergeCts: any) => {
971986
export const contenTypeMaker = async ({ contentType, destinationStackId, projectId, newStack, keyMapper, region, user_id }: any) => {
972987
const marketPlacePath = path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, destinationStackId);
973988
const srcFunc = 'contenTypeMaker';
989+
974990
let ct: ContentType = {
975991
title: contentType?.contentstackTitle,
976992
uid: contentType?.contentstackUid,
977993
schema: []
978-
}
994+
};
995+
979996
let currentCt: any = {};
980997
if (Object?.keys?.(keyMapper)?.length &&
981998
keyMapper?.[contentType?.contentstackUid] !== "" &&
982999
keyMapper?.[contentType?.contentstackUid] !== undefined) {
9831000
currentCt = await existingCtMapper({ keyMapper, contentTypeUid: contentType?.contentstackUid, projectId, region, user_id });
9841001
}
985-
const ctData: any = buildSchemaTree(contentType?.fieldMapping);
986-
ctData?.forEach((item: any) => {
987-
if (item?.contentstackFieldType === 'group') {
988-
const group: Group = {
989-
"data_type": "group",
990-
"display_name": item?.contentstackField,
991-
"field_metadata": {},
992-
"schema": [],
993-
"uid": item?.contentstackFieldUid,
994-
"multiple": false,
995-
"mandatory": false,
996-
"unique": false
997-
}
998-
// 🔧 FIX: Track processed fields to prevent duplicates within groups
999-
const processedFieldUIDs = new Set();
1000-
1001-
item?.schema?.forEach((element: any) => {
1002-
const fieldUID = extractValue(element?.contentstackFieldUid, item?.contentstackFieldUid, '.');
1003-
1004-
// Skip if this field UID was already processed in this group
1005-
if (processedFieldUIDs.has(fieldUID)) {
1006-
return;
1007-
}
1008-
processedFieldUIDs.add(fieldUID);
1009-
1010-
const field: any = {
1011-
...element,
1012-
uid: fieldUID,
1013-
title: extractValue(element?.contentstackField, item?.contentstackField, ' >')?.trim(),
1014-
}
1015-
const schema: any = convertToSchemaFormate({
1016-
field,
1017-
advanced: element?.advanced ?? false, // 🔧 FIX: Pass advanced from element data
1018-
marketPlacePath,
1019-
keyMapper
1020-
});
1021-
if (typeof schema === 'object' && Array.isArray(group?.schema) && element?.isDeleted === false) {
1022-
group.schema.push(schema);
1023-
}
1024-
})
1025-
1026-
// 🔧 FIX: Only add group if it has schema and doesn't already exist
1027-
if (group?.schema && Array.isArray(group.schema) && group.schema.length > 0 && group?.uid) {
1028-
if (!ct?.schema || !Array.isArray(ct.schema)) {
1029-
ct.schema = [];
1030-
}
1031-
const existingGroupIndex = ct.schema.findIndex((g: any) => g != null && g.uid === group.uid);
1032-
if (existingGroupIndex !== -1 && existingGroupIndex < ct.schema.length) {
1033-
ct.schema[existingGroupIndex] = group;
1034-
} else {
1035-
ct.schema.push(group);
1036-
}
1037-
}
1038-
} else {
1039-
const dt: any = convertToSchemaFormate({
1040-
field: {
1041-
...item,
1042-
title: item?.contentstackField,
1043-
uid: item?.contentstackFieldUid
1044-
},
1045-
advanced: item?.advanced ?? false, // 🔧 FIX: Pass advanced from item data
1046-
marketPlacePath,
1047-
keyMapper
1048-
});
1049-
if (dt && item?.isDeleted === false) {
1050-
ct?.schema?.push(dt);
1051-
}
1002+
1003+
// Safe: ensures we never pass undefined to the builder
1004+
const ctData: any[] = buildSchemaTree(contentType?.fieldMapping || []);
1005+
1006+
// Use the deep converter that properly handles groups & modular blocks
1007+
for (const item of ctData) {
1008+
if (item?.isDeleted === true) continue;
1009+
1010+
const fieldSchema = buildFieldSchema(item, marketPlacePath, '');
1011+
if (fieldSchema) {
1012+
ct?.schema.push(fieldSchema);
10521013
}
1053-
})
1014+
}
1015+
1016+
// dedupe by uid to avoid dup nodes after merges
1017+
ct.schema = removeDuplicateFields(ct.schema || []);
1018+
10541019
if (currentCt?.uid) {
10551020
ct = await mergeTwoCts(ct, currentCt);
10561021
}
1057-
if (ct?.uid && ct?.schema?.length) {
1022+
if (ct?.uid && Array.isArray(ct?.schema) && ct?.schema.length) {
10581023
if (contentType?.type === 'global_field') {
10591024
const globalSave = path.join(MIGRATION_DATA_CONFIG.DATA, destinationStackId, GLOBAL_FIELDS_DIR_NAME);
10601025
const message = getLogMessage(srcFunc, `Global Field ${ct?.uid} has been successfully Transformed.`, {});
@@ -1067,6 +1032,6 @@ export const contenTypeMaker = async ({ contentType, destinationStackId, project
10671032
await saveContent(ct, contentSave);
10681033
}
10691034
} else {
1070-
console.info(contentType?.contentstackUid, 'missing')
1035+
console.info(contentType?.contentstackUid, 'missing');
10711036
}
1072-
}
1037+
};

0 commit comments

Comments
 (0)