diff --git a/.gitignore b/.gitignore index 838c8eab..21e26728 100644 --- a/.gitignore +++ b/.gitignore @@ -372,3 +372,4 @@ app.json # Test coverage (global) coverage/ +export-stack/ diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 3c871df3..79cf3a64 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 62d833c4..2dec101c 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -661,7 +661,7 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { plural: `${totalCounts === 0 ? 'Count' : ''}` }} /> - {totalCounts > 0 && ( + {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && (
{/* Total Entries: {totalCounts} */} @@ -670,7 +670,13 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { className="saveButton" onClick={handleSaveContentType} version="v2" - disabled={newMigrationData?.project_current_step > 4} + // 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/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 067bda8a..6a9f3712 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 bb56cdd0..1aedfe08 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 presentLocale = `${recentMsterLocale}-master_locale`; + const recentMasterLocale = cmsLocaleOptions?.find( + (item) => item?.value === 'master_locale' + )?.label; + const presentLocale = `${recentMasterLocale}-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) || ''; } } @@ -344,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; @@ -431,11 +503,25 @@ 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); + // 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 +617,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 +797,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => { ...prevList, { label: labelKey, - value: String(value) + value: isMasterKey ? 'master_locale' : String(value) } ]; } @@ -794,13 +889,40 @@ 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 || {}; + // 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'); + 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( - Object.values(newMigrationData?.destination_stack?.localeMapping || {}).filter( + Object.values(savedMapping).filter( (v): v is string => typeof v === 'string' && v?.length > 0 ) ); @@ -811,7 +933,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 92e4581d..5b2a13d8 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -660,8 +660,30 @@ 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 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 && !sourceLocaleSet.has(k)) + .map(([, v]) => v) + .filter((v): v is string => typeof v === 'string' && v.length > 0) + ); + const allSourceLocalesMapped = + 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 ( + !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.