From 637f2d852e64a529b014fa42031b8f6b5f0631f9 Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Wed, 22 Jul 2026 15:56:04 +0530 Subject: [PATCH 1/2] fix: always create content type on test migration, disable Save when no mappable entries --- api/src/services/migration.service.ts | 1 + api/src/utils/field-attacher.utils.ts | 10 +++++++--- ui/src/components/ContentMapper/entryMapper.tsx | 7 +++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 7bdbc533a..c7821a8b5 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -450,6 +450,7 @@ const startTestMigration = async (req: Request): Promise => { region, user_id, is_sso, + isTest: true }); await marketPlaceAppService?.createAppManifest({ diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index 236ed65b1..ad961a736 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -6,7 +6,7 @@ import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js"; import { sanitizeProjectId, sanitizeStackId } from "./sanitize-path.utils.js"; import customLogger from "./custom-logger.utils.js"; -export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => { +export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso, isTest = false}: any) => { const safeProjectId = sanitizeProjectId(projectId); if (!safeProjectId) { throw new Error("Invalid project identifier"); @@ -45,9 +45,13 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi }) } - if (iteration === 1) { + // Test migration: always create the content type into the test stack, + // regardless of whether it already exists. The iteration-based skip logic + if (isTest) { + await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + } + else if (iteration === 1) { await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) - } else { const shouldSkip = await shouldSkipContentTypeCreation(safeProjectId, contentType?.otherCmsUid, iteration); diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index 5a83a480d..c9d170f35 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -690,9 +690,12 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { // Lock the Save button only while a migration is actively in flight. // Using migrationStarted alone would permanently lock revisits on delta // iterations since migrationStarted stays true after completion. + // Also lock when this content type has no selectable entry (no row is + // mapped to a Contentstack uid), since there's nothing to save. disabled={ - !!newMigrationData?.migration_execution?.migrationStarted && - !newMigrationData?.migration_execution?.migrationCompleted + (!!newMigrationData?.migration_execution?.migrationStarted && + !newMigrationData?.migration_execution?.migrationCompleted) || + !tableData?.some((row) => row?._canSelect) } isLoading={isLoadingSaveButton} > From 331b97f559ea7a279a2c959dfad04a943d2e4fad Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Wed, 22 Jul 2026 20:25:39 +0530 Subject: [PATCH 2/2] comments resolved: Save selection gate, content-type upsert, collapse duplicate if --- api/src/utils/content-type-creator.utils.ts | 15 +++++++++++++-- api/src/utils/field-attacher.utils.ts | 10 ++++------ ui/src/components/ContentMapper/entryMapper.tsx | 10 +++++++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/api/src/utils/content-type-creator.utils.ts b/api/src/utils/content-type-creator.utils.ts index fe39aea35..72a021894 100644 --- a/api/src/utils/content-type-creator.utils.ts +++ b/api/src/utils/content-type-creator.utils.ts @@ -1089,8 +1089,19 @@ const saveContent = async (ct: any, contentSave: string) => { throw readError; // rethrow if it's not a "file not found" error } } - // Append new content to schemaData - schemaData.push(ct); + // Upsert by uid: replace an existing entry for this content type instead of + // blindly appending. schema.json is not cleared between runs (clearStaleEntries + // only wipes the entries/ subtree), so re-running against the same stack — e.g. + // a re-run test migration or a delta iteration — would otherwise accumulate + // duplicate content-type entries. + const existingIndex = Array.isArray(schemaData) + ? schemaData.findIndex((existing: any) => existing?.uid === ct?.uid) + : -1; + if (existingIndex > -1) { + schemaData[existingIndex] = ct; + } else { + schemaData.push(ct); + } // Write the updated schemaData back to schema.json await fs.promises.writeFile(schemaFilePath, JSON.stringify(schemaData, null, 2)); diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index ad961a736..ddf9cb8f6 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -45,12 +45,10 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi }) } - // Test migration: always create the content type into the test stack, - // regardless of whether it already exists. The iteration-based skip logic - if (isTest) { - await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) - } - else if (iteration === 1) { + // Always create the content type on a test migration (skip the iteration-based + // dedupe entirely) or on the first real iteration. The skip logic below only + // applies to delta iterations of a real migration. + if (isTest || iteration === 1) { await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index c9d170f35..e331f079c 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -690,12 +690,16 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { // Lock the Save button only while a migration is actively in flight. // Using migrationStarted alone would permanently lock revisits on delta // iterations since migrationStarted stays true after completion. - // Also lock when this content type has no selectable entry (no row is - // mapped to a Contentstack uid), since there's nothing to save. + // Also lock when there's nothing to save: no pending selection AND the + // current page has no mappable row. tableData is only the current + // server-paginated page, so we must fall back to rowIds (persisted + + // pending selection) — otherwise a content type whose mappable rows sit + // on page 2+ would wrongly disable Save. disabled={ (!!newMigrationData?.migration_execution?.migrationStarted && !newMigrationData?.migration_execution?.migrationCompleted) || - !tableData?.some((row) => row?._canSelect) + (Object.keys(rowIds ?? {}).length === 0 && + !tableData?.some((row) => row?._canSelect)) } isLoading={isLoadingSaveButton} >