From 38f063bcbd17fdb71be64b9bdf92b671f0a6c296 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Fri, 3 Jul 2026 18:13:27 +0530 Subject: [PATCH 1/6] fix(delta): CMG-1057 CMG-1058 language mapper and locale migration fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMG-1057: fix already-mapped locales appearing in dropdowns on restart, Add Language button stuck disabled after one addition, and rows locking prematurely before user advances past step 2. CMG-1058: route new-locale entries through localize path instead of creating duplicate entries; fix save button visibility and sticky footer in entry mapper; tighten step-1→step-3 skip guard to require all source locales mapped before bypassing step 2. --- .gitignore | 3 + api/src/utils/entry-update.utils.ts | 28 +- .../components/ContentMapper/entryMapper.tsx | 9 +- ui/src/components/ContentMapper/index.scss | 8 +- .../Actions/LoadLanguageMapper.tsx | 246 +++++++++++++----- ui/src/pages/Migration/index.tsx | 23 +- 6 files changed, 238 insertions(+), 79 deletions(-) diff --git a/.gitignore b/.gitignore index 838c8eabf..5a41297c2 100644 --- a/.gitignore +++ b/.gitignore @@ -372,3 +372,6 @@ app.json # Test coverage (global) coverage/ +export-stack/ +allien/ +aem_data_structure/ diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 3c871df30..79cf3a641 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -122,20 +122,21 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: for (const localeDir of localeDirs) { const localeCode = localeDir.name; - // Skip delta cleanup only when this is a restart AND the locale was never migrated - // before — newly-added locales have no prior state to diff against, so the regular - // full-import pipeline should pick them up untouched. On iteration 1 we always - // process (the function may be a no-op then, but tests/legacy code can still call it). - if ( + // True when this locale is brand-new in this delta run (wasn't migrated before). + // We still need to process its entry files: source entries that already exist in + // Contentstack (migrated in a prior iteration with a different locale mapping) must + // be LOCALIZED on their existing CS UID rather than imported as duplicate new entries. + // Entries with no prior CS UID are left in the import data to be created fresh. + const isNewLocaleInDelta = (projectData?.iteration ?? 1) > 1 && - isFullMigrationForLocale(projectData ?? {}, localeCode) - ) { + isFullMigrationForLocale(projectData ?? {}, localeCode); + + if (isNewLocaleInDelta) { writeLogEntry( - `Skipping delta cleanup for new locale "${localeCode}" — full import.`, + `New locale "${localeCode}" in delta run — routing existing entries through localize path instead of re-creating.`, "removeEntriesFromDatabase", loggerPath, ); - continue; } // entry_mapper rows are tagged with the SOURCE locale code (e.g. "en-IN"); // directories on disk use the DESTINATION code (e.g. "en-in"). Translate. @@ -195,7 +196,10 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: const row = (sourceLocale && rowByUidAndLang.get(`${key}::${sourceLocale}`)) || rowByUid.get(key); - if (row?.isUpdate) { + // For a new locale in a delta run, ALL entries with an existing CS UID + // must be localized (not re-created). For existing locales the user must + // explicitly mark entries with isUpdate to include them in the update pass. + if (row?.isUpdate || isNewLocaleInDelta) { const entryData = { ...data[key], __locale: localeCode, __csUid: csEntryUid }; delete entryData?.uid; @@ -203,8 +207,8 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: entriesToUpdate[contentTypeName] = {}; } entriesToUpdate[contentTypeName][`${csEntryUid}::${localeCode}`] = entryData; - writeLogEntry(`Collected update entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); - writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Collected ${isNewLocaleInDelta ? 'localize' : 'update'} entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been prepared for ${isNewLocaleInDelta ? 'localization' : 'update'} in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); } // Existing entry → remove from import data so it is NOT re-created. diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index fc8ba2cdc..684aa084a 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -649,7 +649,7 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { plural: `${totalCounts === 0 ? 'Count' : ''}` }} /> - {totalCounts > 0 && ( + {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && (
{/* Total Entries: {totalCounts} */} @@ -658,7 +658,12 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { className="saveButton" onClick={handleSaveContentType} version="v2" - disabled={newMigrationData?.project_current_step > 4} + // Lock the Save button only while an actual migration is in flight, not + // just because the user has already visited a later step once. Delta + // iterations legitimately need to revisit Map Entry and re-save the + // entry selection after progressing to Test Migration or Execute — + // the previous `project_current_step > 4` gate blocked that entirely. + disabled={!!newMigrationData?.migration_execution?.migrationStarted} isLoading={isLoadingSaveButton} > Save diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 66c2cd90b..04857373b 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -560,10 +560,12 @@ div .table-row { } // With pagination on, the venus pagination bar renders at the bottom of the table. - // Let the Total/Save footer flow below it (not sticky-overlapping) so the two bars - // don't collide and the Save button isn't clipped at the viewport edge. + // Pin the Total/Save footer to the viewport bottom so it stays reachable when the + // table + pagination combined exceed the available viewport height. Without this the + // Save button gets clipped off-screen on small windows (Map Entry step 4). .mapper-footer { - position: static; + position: sticky; + bottom: 0; } } diff --git a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx index bb56cdd07..aaefc5c81 100644 --- a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx @@ -127,23 +127,48 @@ const Mapper = ({ useEffect(() => { const formattedoptions = options?.filter( (item: { label: string; value: string }) => - !selectedCsOptions?.some((selected: string) => selected === item?.value) && !cmsLocaleOptions?.some((locale: {label: string, value: string}) => locale?.label === item?.value) + !selectedCsOptions?.some((selected: string) => selected === item?.value) && + !cmsLocaleOptions?.some( + (locale: { label: string; value: string }) => locale?.label === item?.value + ) ); + // Also exclude source locales already consumed by a saved mapping (delta-restart case): + // selectedSourceOption only tracks in-session picks, so on restart the previous run's + // source values (e.g. the master row's "en") don't appear there and would otherwise show + // up again as selectable in a newly-added row's source dropdown. + const mappedSourceValues = new Set( + Object.values(selectedMappings || {}).filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ) + ); const adjustedOptions = sourceOptions?.filter( (item: { label: string; value: string }) => - !selectedSourceOption?.some((selected: string) => selected === item?.label) + !selectedSourceOption?.some((selected: string) => selected === item?.label) && + !mappedSourceValues.has(item?.label) ); setcsOptions(formattedoptions); setsourceoptions(adjustedOptions); // sourceOptions must be in deps: on restart iteration the parent's sourceLocales arrives // asynchronously from Redux *after* mount. Without this, sourceoptions stays stale ([]) and // the Select language dropdown renders empty until Add Language forces unrelated re-renders. - }, [selectedCsOptions, selectedSourceOption, options, sourceOptions]); + // cmsLocaleOptions must be in deps too: on restart iteration it's rehydrated with the + // previous run's saved mappings *after* mount, so without it this filter never re-runs and + // already-mapped languages keep showing up as selectable options in a newly-added row. + // selectedMappings must be in deps so a source locale saved in Redux gets excluded from + // the source dropdown on restart (in-session picks alone can miss the rehydrated master row). + }, [ + selectedCsOptions, + selectedSourceOption, + options, + sourceOptions, + cmsLocaleOptions, + selectedMappings + ]); useEffect(() => { - const updatedExistingField = {...existingField}; - const updatedExistingLocale = {...existingLocale}; + const updatedExistingField = { ...existingField }; + const updatedExistingLocale = { ...existingLocale }; // const validLabels = cmsLocaleOptions?.map((item)=> item?.label); @@ -151,37 +176,39 @@ const Mapper = ({ key?.includes('-master_locale') ); - const recentMsterLocale = cmsLocaleOptions?.find((item) => item?.value === 'master_locale')?.label; + const recentMsterLocale = cmsLocaleOptions?.find( + (item) => item?.value === 'master_locale' + )?.label; const presentLocale = `${recentMsterLocale}-master_locale`; Object.keys(updatedExistingField || {})?.forEach((key) => { - if ((existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { delete updatedExistingField[key]; } }); Object.keys(updatedExistingLocale || {})?.forEach((key) => { - if ((existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { delete updatedExistingLocale[key]; } }); - if ( (existingMasterID !== presentLocale) || isStackChanged) { + if (existingMasterID !== presentLocale || isStackChanged) { setselectedCsOption([]); setselectedSourceOption([]); } setexistingLocale(updatedExistingLocale); - cmsLocaleOptions?.map((locale, index)=>{ + cmsLocaleOptions?.map((locale, index) => { const existingLabel = existingMasterID; const expectedLabel = `${locale?.label}-master_locale`; const isLabelMismatch = existingLabel && existingLabel?.localeCompare(expectedLabel) !== 0; - if(locale?.value === 'master_locale'){ + if (locale?.value === 'master_locale') { if (!updatedExistingField?.[index]) { updatedExistingField[index] = { label: `${locale?.label}`, - value: `${locale?.label}-master_locale`, + value: `${locale?.label}-master_locale` }; } // Reflect the saved master source locale in the row UI (the master row reads its @@ -191,11 +218,10 @@ const Mapper = ({ if (savedMasterSource && !updatedExistingLocale?.[locale?.label]) { updatedExistingLocale[locale?.label] = { label: savedMasterSource, - value: savedMasterSource, + value: savedMasterSource }; } - if (isLabelMismatch || isStackChanged) { setselectedCsOption([]); setselectedSourceOption([]); @@ -203,28 +229,56 @@ const Mapper = ({ setExistingField({}); // 🔧 FIX: Merge with existing mappings instead of replacing - setSelectedMappings(prev => ({ + setSelectedMappings((prev) => ({ ...prev, - [`${locale?.label}-master_locale`]: '', + [`${locale?.label}-master_locale`]: '' })); - - } - else if ( !isLabelMismatch && !isStackChanged ) { - const key = `${locale?.label}-master_locale` + } else if (!isLabelMismatch && !isStackChanged) { + const key = `${locale?.label}-master_locale`; // 🔧 FIX: Merge with existing mappings instead of replacing - setSelectedMappings(prev => ({ + setSelectedMappings((prev) => ({ ...prev, - [key]: prev?.[key] ? prev?.[key] : '', + [key]: prev?.[key] ? prev?.[key] : '' })); } - } - }) - + } + }); + setExistingField(updatedExistingField); - - - }, [cmsLocaleOptions]); - + }, [cmsLocaleOptions]); + + // On a delta-migration restart, `selectedMappings` can hydrate from Redux *after* the + // effect above has already run (which is what reflects the saved master-locale source into + // existingLocale). That effect only depends on cmsLocaleOptions, so it won't re-run when + // selectedMappings arrives later, leaving the master row's source dropdown blank on first + // load. Re-sync it here instead of adding selectedMappings to the effect above, since that + // effect also writes to selectedMappings and would loop. + // + // We also fall back to reading the saved master source directly from Redux's localeMapping + // when selectedMappings is empty for the master key. During the mount race on restart, the + // effect above can dispatch an empty master mapping to Redux (via its else-if branch when + // `prev[key]` is undefined at effect time) before the sync-from-Redux effect at the top of + // the component has caught up. Reading Redux directly here keeps the master source visible + // in that transient state. + const reduxLocaleMapping = newMigrationData?.destination_stack?.localeMapping; + useEffect(() => { + const masterLocale = cmsLocaleOptions?.find((item) => item?.value === 'master_locale'); + if (!masterLocale) return; + + const key = `${masterLocale.label}-master_locale`; + const savedMasterSource = + selectedMappings?.[key] || reduxLocaleMapping?.[key]; + if (!savedMasterSource) return; + + setexistingLocale((prev) => { + if (prev?.[masterLocale.label]?.label === savedMasterSource) return prev; + return { + ...prev, + [masterLocale.label]: { label: savedMasterSource, value: savedMasterSource } + }; + }); + }, [selectedMappings, cmsLocaleOptions, reduxLocaleMapping]); + // function for change select value const handleSelectedCsLocale = ( @@ -276,18 +330,34 @@ const Mapper = ({ //updatedMappings[""] = valueToKeep; } else if (type === 'csLocale' && selectedLocaleKey) { - + if(updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY] === existingLocale?.[index]?.label){ updatedMappings[selectedLocaleKey] = existingLocale?.[index]?.label; - delete updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY]; - }else{ - const oldlabel = Object?.keys?.(updatedMappings)?.[index - 1]; - - // Delete old key and assign to new key - delete updatedMappings?.[oldlabel]; - updatedMappings[selectedLocaleKey] = existingLocale?.[index]?.label - ? existingLocale?.[index]?.label - : ''; + delete updatedMappings?.[CS_ENTRIES?.UNMAPPED_LOCALE_KEY]; + } else { + // Look up the row's PRIOR CS destination via existingField (captured into + // `existingLabel` above), not by position. The previous logic used + // `Object.keys(mappings)[index - 1]`, which for index=1 grabbed the MASTER row's + // key and deleted it — corrupting localeMapping to a state with no `-master_locale` + // entry. That in turn crashed the CLI audit ("Master locale undefined ...") and + // wedged the Execute step in a "started, never completed" state on delta iterations. + const oldKey = existingLabel?.value; + // Never delete the master row's key from this handler: the master Select is + // disabled in the UI, but a defensive guard keeps a future refactor from re-opening + // the corruption path. + const isMasterKey = + typeof oldKey === 'string' && oldKey.endsWith('-master_locale'); + // Preserve any source already mapped to this row's previous destination so the + // user doesn't lose their pick when they change the CS destination. + const preservedSource = + oldKey && !isMasterKey ? updatedMappings?.[oldKey] : undefined; + if (oldKey && !isMasterKey && oldKey !== selectedLocaleKey) { + delete updatedMappings?.[oldKey]; + } + updatedMappings[selectedLocaleKey] = + (preservedSource && preservedSource.length > 0 + ? preservedSource + : existingLocale?.[index]?.label) || ''; } } @@ -431,11 +501,40 @@ const Mapper = ({ return ( <> {cmsLocaleOptions?.length > 0 ? ( - cmsLocaleOptions?.map((locale: {label:string, value: string}, index: number) => ( - + cmsLocaleOptions?.map((locale: {label:string, value: string}, index: number) => { + // Identify master rows defensively: usually the `value` marker is `'master_locale'`, + // but some rehydration paths in this file historically wrote the raw source string + // (e.g. `'en'`) into `value` for master keys, which dropped the row into the + // editable non-master render branch. Falling back to a label match against the + // stack's master locale keeps the master row locked even if that ever regresses. + const isMasterRow = + locale?.value === 'master_locale' || + (stack?.master_locale != null && + locale?.label === stack?.master_locale); + // Row is "saved" iff its mapping key exists in Redux `localeMapping` with a + // non-empty source value. This is a race-free complement to the parent + // `isDisabled` prop (which reads `project_current_step`/`iteration` and can lag + // during Step 1 → Step 2 navigation). Master and non-master rows use different + // key shapes. + // Non-restart added rows have a numeric index as label (e.g. '1', '2'). + // The actual CS locale code lives in existingField[index].value once the + // user has picked a destination. Restart-rebuilt rows already have the CS + // locale code as label, so the fallback covers both paths correctly. + const savedKey = isMasterRow + ? `${locale?.label}-master_locale` + : (existingField?.[index]?.value || locale?.label); + const savedValue = reduxLocaleMapping?.[savedKey]; + const isRowSaved = + typeof savedValue === 'string' && savedValue.length > 0; + // Lock rows only when the parent flag is set (step > 2 or iteration > 1) AND + // the row has a rebuilt value from a prior iteration. Rebuilt rows always have + // locale.value set; newly-added rows keep value='' so they stay editable until + // the user advances, letting them correct a wrong selection before saving. + const isRowLocked = isDisabled && !!locale?.value; + return (
- - {locale?.value === 'master_locale' ? ( + + {isMasterRow ? ( @@ -531,16 +630,16 @@ const Mapper = ({ version="v2" hideSelectedOptions={true} isClearable={true} - // Existing rows (have a `value` from the prior mapping) stay locked when the - // parent says disabled (e.g. restart iteration). Newly-added rows have an empty - // value and must remain editable so the user can pick their locales. - isDisabled={isDisabled && !!locale?.value} + // Row lock: rebuilt rows (have a `value` from the prior mapping) or rows whose + // mapping is already saved to Redux stay locked. Newly-added rows have an empty + // value and no Redux entry, so they remain editable for the user to fill in. + isDisabled={isRowLocked} //className="select-container" menuPlacement="auto" /> }
- {locale?.value !== 'master_locale' && (!isDisabled || !locale?.value) && ( + {!isMasterRow && !isRowLocked && (
- )) + ); + }) ) : ( { Object?.entries(newMigrationData?.destination_stack?.localeMapping || {})?.forEach( ([key, value]) => { setcmsLocaleOptions((prevList) => { + // Master-key entries (`-master_locale`) must rebuild with + // value='master_locale' — the marker string the render layer keys off to + // hardcode the CS Select as disabled and mark this row as master. Using + // the raw source value here (e.g. 'en') would drop the master row into the + // normal-row render branch, which is editable when the parent isDisabled + // prop isn't set (a race window during the Step 1 → Step 2 navigation on + // an iteration-1 revisit). Keep master rows locked by preserving the marker. + const isMasterKey = typeof key === 'string' && key.endsWith('-master_locale'); const labelKey = key?.replace(/-master_locale$/, ''); const exists = prevList?.some((item) => item?.label === labelKey); if (!exists) { @@ -702,7 +810,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { ...prevList, { label: labelKey, - value: String(value) + value: isMasterKey ? 'master_locale' : String(value) } ]; } @@ -794,13 +902,31 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { } // Restart iteration: button must remain available so users can add a new // destination locale before the delta run. Block when (a) there's already an - // empty in-progress row waiting to be filled (avoid stacking empties) OR (b) - // every available source locale is already mapped — otherwise clicking Add - // Language would surface a row whose source dropdown has no unmapped option + // in-progress row not yet backed by a completed mapping (avoid stacking empties) + // OR (b) every available source locale is already mapped — otherwise clicking + // Add Language would surface a row whose source dropdown has no unmapped option // to pick (e.g. a single-locale source where `en` is already in use). - const hasEmptyRow = cmsLocaleOptions?.some((o) => !o?.value); + // + // Row-completeness derives from Redux (`localeMapping`) rather than + // `cmsLocaleOptions[i].value`, because the row-object's `value` field is only + // populated on rebuild — the per-row handlers (handleSelectedCsLocale / + // handleSelectedSourceLocale) update `selectedMappings` → Redux but never write + // back into `cmsLocaleOptions`, so a freshly-filled row would otherwise still + // read as "empty" here and lock Add Language forever after one add. + // + // Treat the master row as always-filled: its value is auto-managed by the + // parent (stack master locale), and on a delta-restart race the source can be + // momentarily blank in Redux before the sync-from-Redux effect merges it back. + // Counting master as filled prevents that transient state from disabling the + // button on the very first render after restart. + const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; + const filledMappingCount = Object.entries(savedMapping).filter(([k, v]) => { + const isMasterKey = typeof k === 'string' && k.endsWith('-master_locale'); + return isMasterKey || (typeof v === 'string' && v.length > 0); + }).length; + const hasIncompleteRow = (cmsLocaleOptions?.length ?? 0) > filledMappingCount; const mappedSources = new Set( - Object.values(newMigrationData?.destination_stack?.localeMapping || {}).filter( + Object.values(savedMapping).filter( (v): v is string => typeof v === 'string' && v?.length > 0 ) ); @@ -811,7 +937,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { const totalSources = sourceLocales?.length ?? 0; const sourcesNotReady = totalSources === 0; const allSourcesMapped = totalSources > 0 && mappedSources.size >= totalSources; - return hasEmptyRow || sourcesNotReady || allSourcesMapped; + return hasIncompleteRow || sourcesNotReady || allSourcesMapped; })()} > Add Language diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 92e4581df..d3507006c 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -660,8 +660,27 @@ const Migration = () => { // On a restart (iteration > 1) we must NOT skip Step 2 — that's where the user // can review/adjust locale mapping (e.g. add a new locale before a delta run). const isRestart = (newMigrationData?.iteration ?? 1) > 1; - // Otherwise, if a stack is already chosen we can jump straight to Step 3. - if (!isRestart && newMigrationData?.destination_stack?.selectedStack?.value) { + // Also require every source locale to have a filled mapping before we skip Step 2. + // Step 2 is where BOTH the destination stack AND per-locale mappings get configured; + // a project where the stack is chosen but some source locales are still unmapped + // (e.g. multi-locale source with only master mapped so far) needs a Step 2 visit to + // finish the mapping — the earlier `selectedStack.value` alone was too permissive + // and could skip users past locale mapping they hadn't completed yet. + const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; + const mappedSourceCount = Object.values(savedMapping).filter( + (v): v is string => typeof v === 'string' && v.length > 0 + ).length; + const sourceCount = + newMigrationData?.destination_stack?.sourceLocale?.length ?? 0; + const allSourceLocalesMapped = + sourceCount > 0 && mappedSourceCount >= sourceCount; + // Otherwise, if a stack is already chosen AND every locale is mapped, we can jump + // straight to Step 3. + if ( + !isRestart && + newMigrationData?.destination_stack?.selectedStack?.value && + allSourceLocalesMapped + ) { const url = `/projects/${projectId}/migration/steps/3`; // Bump current_step a second time so backend lands on Step 3 (Content Mapping) // — we're skipping Step 2 because the destination stack is already configured. From 280b794de0a641d1bf14148c86dfc202454e7b5d Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Mon, 6 Jul 2026 10:29:23 +0530 Subject: [PATCH 2/6] fix(pr-review): address Copilot comments on CMG-1057/1058 PR - allSourceLocalesMapped: verify each source locale exists in mapped values instead of comparing raw counts (filters out placeholder keys like UNMAPPED_LOCALE_KEY and prevents duplicates from inflating count) - LoadLanguageMapper: remove dead savedKey/savedValue/isRowSaved vars - LoadLanguageMapper: fix recentMsterLocale typo -> recentMasterLocale --- .../Actions/LoadLanguageMapper.tsx | 19 ++----------------- ui/src/pages/Migration/index.tsx | 14 ++++++++------ 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx index aaefc5c81..87509116a 100644 --- a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx @@ -176,10 +176,10 @@ const Mapper = ({ key?.includes('-master_locale') ); - const recentMsterLocale = cmsLocaleOptions?.find( + const recentMasterLocale = cmsLocaleOptions?.find( (item) => item?.value === 'master_locale' )?.label; - const presentLocale = `${recentMsterLocale}-master_locale`; + const presentLocale = `${recentMasterLocale}-master_locale`; Object.keys(updatedExistingField || {})?.forEach((key) => { if (existingMasterID !== presentLocale || isStackChanged) { @@ -511,21 +511,6 @@ const Mapper = ({ locale?.value === 'master_locale' || (stack?.master_locale != null && locale?.label === stack?.master_locale); - // Row is "saved" iff its mapping key exists in Redux `localeMapping` with a - // non-empty source value. This is a race-free complement to the parent - // `isDisabled` prop (which reads `project_current_step`/`iteration` and can lag - // during Step 1 → Step 2 navigation). Master and non-master rows use different - // key shapes. - // Non-restart added rows have a numeric index as label (e.g. '1', '2'). - // The actual CS locale code lives in existingField[index].value once the - // user has picked a destination. Restart-rebuilt rows already have the CS - // locale code as label, so the fallback covers both paths correctly. - const savedKey = isMasterRow - ? `${locale?.label}-master_locale` - : (existingField?.[index]?.value || locale?.label); - const savedValue = reduxLocaleMapping?.[savedKey]; - const isRowSaved = - typeof savedValue === 'string' && savedValue.length > 0; // Lock rows only when the parent flag is set (step > 2 or iteration > 1) AND // the row has a rebuilt value from a prior iteration. Rebuilt rows always have // locale.value set; newly-added rows keep value='' so they stay editable until diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index d3507006c..36c9e7ae7 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -667,13 +667,15 @@ const Migration = () => { // finish the mapping — the earlier `selectedStack.value` alone was too permissive // and could skip users past locale mapping they hadn't completed yet. const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; - const mappedSourceCount = Object.values(savedMapping).filter( - (v): v is string => typeof v === 'string' && v.length > 0 - ).length; - const sourceCount = - newMigrationData?.destination_stack?.sourceLocale?.length ?? 0; + const sourceLocales: string[] = newMigrationData?.destination_stack?.sourceLocale ?? []; + const mappedSourceValues = new Set( + Object.entries(savedMapping) + .filter(([k]) => k && k.trim() && k !== CS_ENTRIES.UNMAPPED_LOCALE_KEY) + .map(([, v]) => v) + .filter((v): v is string => typeof v === 'string' && v.length > 0) + ); const allSourceLocalesMapped = - sourceCount > 0 && mappedSourceCount >= sourceCount; + sourceLocales.length > 0 && sourceLocales.every((loc: string) => mappedSourceValues.has(loc)); // Otherwise, if a stack is already chosen AND every locale is mapped, we can jump // straight to Step 3. if ( From 9206a1d78517716a631a2f9fc7fcfd398cb8d5a6 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Mon, 6 Jul 2026 11:06:19 +0530 Subject: [PATCH 3/6] fix: remove client folder names from .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5a41297c2..21e267287 100644 --- a/.gitignore +++ b/.gitignore @@ -373,5 +373,3 @@ app.json # Test coverage (global) coverage/ export-stack/ -allien/ -aem_data_structure/ From aba4678f77608c9ae62746eba3f5169ddac1a9a2 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Wed, 8 Jul 2026 12:59:23 +0530 Subject: [PATCH 4/6] fix(pr-review): address remaining Copilot comments - LoadLanguageMapper: skip persisting source locale when no CS locale is selected yet (prevents fallback key inflating filledMappingCount and re-enabling Add Language for an incomplete row) - entryMapper: gate Save disable on migrationStarted && !migrationCompleted so button unlocks on delta revisits after migration completes --- ui/src/components/ContentMapper/entryMapper.tsx | 13 +++++++------ .../Actions/LoadLanguageMapper.tsx | 14 ++++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index db5bac6b3..2dec101c1 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -670,12 +670,13 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { className="saveButton" onClick={handleSaveContentType} version="v2" - // Lock the Save button only while an actual migration is in flight, not - // just because the user has already visited a later step once. Delta - // iterations legitimately need to revisit Map Entry and re-save the - // entry selection after progressing to Test Migration or Execute — - // the previous `project_current_step > 4` gate blocked that entirely. - disabled={!!newMigrationData?.migration_execution?.migrationStarted} + // 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. + disabled={ + !!newMigrationData?.migration_execution?.migrationStarted && + !newMigrationData?.migration_execution?.migrationCompleted + } isLoading={isLoadingSaveButton} > Save diff --git a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx index 87509116a..7fb352199 100644 --- a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx @@ -414,12 +414,14 @@ const Mapper = ({ updatedMappings[existingLabel?.value] = '' } else if (selectedLocaleKey) { - // 🔧 FIX: Use the actual Contentstack locale code, or source locale in lowercase as fallback - const mappingKey = existingLabel?.value || existingLabel?.label || selectedValue?.label?.toLowerCase(); - - updatedMappings[mappingKey] = selectedValue?.label - ? selectedValue?.label - : ''; + // Only persist if a CS locale has already been selected for this row. + // If the user picks source before CS, existingLabel is unset and writing + // to a fallback key (source locale lowercased) would inflate filledMappingCount + // and incorrectly re-enable Add Language for an incomplete row. + const mappingKey = existingLabel?.value || existingLabel?.label; + if (mappingKey) { + updatedMappings[mappingKey] = selectedValue?.label ? selectedValue?.label : ''; + } } return updatedMappings; From 7150f54c3a39e050ae231c63ef8c908613212eb0 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Wed, 8 Jul 2026 13:03:03 +0530 Subject: [PATCH 5/6] fix(pr-review): exclude source locale keys from filledMappingCount --- .../DestinationStack/Actions/LoadLanguageMapper.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx index 7fb352199..1aedfe088 100644 --- a/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadLanguageMapper.tsx @@ -907,9 +907,18 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { // Counting master as filled prevents that transient state from disabling the // button on the very first render after restart. const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; + // Exclude keys that are source locale codes — handleSelectedSourceLocale + // previously wrote under a fallback key (source label lowercased) when the + // user picked source before CS, inflating the count and re-enabling Add Language + // for an incomplete row. Filter those out so only proper CS locale keys count. + const sourceLocaleLabels = new Set( + sourceLocales?.map((l: { label: string }) => l.label) ?? [] + ); const filledMappingCount = Object.entries(savedMapping).filter(([k, v]) => { const isMasterKey = typeof k === 'string' && k.endsWith('-master_locale'); - return isMasterKey || (typeof v === 'string' && v.length > 0); + if (isMasterKey) return true; + if (sourceLocaleLabels.has(k)) return false; + return typeof v === 'string' && v.length > 0; }).length; const hasIncompleteRow = (cmsLocaleOptions?.length ?? 0) > filledMappingCount; const mappedSources = new Set( From c6942ed4df0132cd4552b80c6b65252fffc6f0d4 Mon Sep 17 00:00:00 2001 From: chetan-contentstack Date: Wed, 8 Jul 2026 17:29:19 +0530 Subject: [PATCH 6/6] fix(pr-review): exclude source locale keys from allSourceLocalesMapped check --- ui/src/pages/Migration/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 36c9e7ae7..5b2a13d8c 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -668,9 +668,10 @@ const Migration = () => { // and could skip users past locale mapping they hadn't completed yet. const savedMapping = newMigrationData?.destination_stack?.localeMapping || {}; const sourceLocales: string[] = newMigrationData?.destination_stack?.sourceLocale ?? []; + const sourceLocaleSet = new Set(sourceLocales); const mappedSourceValues = new Set( Object.entries(savedMapping) - .filter(([k]) => k && k.trim() && k !== CS_ENTRIES.UNMAPPED_LOCALE_KEY) + .filter(([k]) => k && k.trim() && k !== CS_ENTRIES.UNMAPPED_LOCALE_KEY && !sourceLocaleSet.has(k)) .map(([, v]) => v) .filter((v): v is string => typeof v === 'string' && v.length > 0) );