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) && (
-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.