From 6b8a8533f7dfc5130ac8d249a85d80c63628f6a2 Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Fri, 12 Jun 2026 16:23:58 +0530 Subject: [PATCH 1/3] feat: Implement delta migration support for content mapping and entry mapping steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added logic to handle delta iterations in the ContentMapper component, allowing for different behaviors based on the iteration number. - Updated the MigrationFlowHeader to adjust step IDs and button states based on the current iteration. - Enhanced the HorizontalStepper to maintain step completion status across iterations. - Introduced new constants for empty state messages in the content mapper. - Modified API calls to differentiate between new and old content types based on the iteration. - Map Entry step lists only content types that have entry mappings, with status icons derived from saved isUpdate state. - Made backend current_step iteration-aware (Map Entry inserts an extra step from iteration 2+). - Fixed entry-update: only remove entries that already exist in Contentstack (have a contentstackEntryUid); update them when marked isUpdate, otherwise create fresh — prevents data loss of never-migrated entries. - Updated the LoadUploadFile component to support MySQL connection details editing. - Adjusted the Migration page to conditionally render steps based on the iteration. - Improved the handling of empty states in the UI for better user experience during migration. --- api/src/constants/index.ts | 18 + api/src/services/contentMapper.service.ts | 72 ++ api/src/services/migration.service.ts | 6 +- api/src/services/projects.service.ts | 39 +- api/src/services/runCli.service.ts | 6 +- api/src/utils/entry-update-script.cjs | 1 - api/src/utils/entry-update.utils.ts | 28 +- ui/src/cmsData/migrationSteps.json | 21 +- .../components/ContentMapper/entryMapper.tsx | 718 ++++++++++++------ ui/src/components/ContentMapper/index.scss | 37 +- ui/src/components/ContentMapper/index.tsx | 74 +- .../LegacyCms/Actions/LoadUploadFile.tsx | 4 +- .../components/MigrationFlowHeader/index.tsx | 53 +- .../HorizontalStepper/HorizontalStepper.tsx | 41 +- ui/src/context/app/app.interface.ts | 3 + ui/src/pages/Migration/index.tsx | 74 +- ui/src/services/api/migration.service.ts | 9 +- ui/src/services/api/service.interface.ts | 1 + ui/src/utilities/constants.ts | 12 + 19 files changed, 859 insertions(+), 358 deletions(-) diff --git a/api/src/constants/index.ts b/api/src/constants/index.ts index ee8ab2aec..785fc58db 100644 --- a/api/src/constants/index.ts +++ b/api/src/constants/index.ts @@ -179,6 +179,24 @@ export const STEPPER_STEPS: any = { TESTING: 4, MIGRATION: 5, }; + +// Delta migration (iteration > 1) inserts a "Map Entry" step after Content Mapping, shifting +// Testing and Migration down by one. Iteration 1 keeps the original 5-step numbering. +export const DELTA_STEPPER_STEPS: any = { + LEGACY_CMS: 1, + DESTINATION_STACK: 2, + CONTENT_MAPPING: 3, + MAP_ENTRY: 4, + TESTING: 5, + MIGRATION: 6, +}; + +/** + * Returns the step-number map for a given iteration: the 6-step delta layout (with Map Entry) + * from iteration 2 onwards, or the original 5-step layout for iteration 1. + */ +export const getStepperSteps = (iteration?: number) => + (iteration ?? 1) > 1 ? DELTA_STEPPER_STEPS : STEPPER_STEPS; export const PREDEFINED_STATUS = [ 'Draft', 'Ready', diff --git a/api/src/services/contentMapper.service.ts b/api/src/services/contentMapper.service.ts index bc46345d4..3ecdf71d0 100644 --- a/api/src/services/contentMapper.service.ts +++ b/api/src/services/contentMapper.service.ts @@ -325,6 +325,39 @@ const putTestData = async (req: Request) => { } }; +/** + * Splits the current iteration's content types into "new" vs "old" relative to the + * previous iteration, by comparing on `otherCmsUid`. + * - mode 'new': content types NOT present in the previous iteration (mapped for the first time). + * - mode 'old': content types already present in the previous iteration (already migrated) AND + * that have at least one entry mapping — Step 4 (Map Entry) can only map entries for content + * types that actually have entries, so types with an empty entryMapping are excluded. + * Used on delta migrations (iteration > 1) so Step 3 (Map Content Fields) only re-maps new + * types and Step 4 (Map Entry) only maps entries of already-migrated types that have entries. + * @param currentCts - Content types of the current iteration. + * @param prevCts - Content types of the previous iteration. + * @param mode - 'new' or 'old'. + * @returns The filtered subset of `currentCts`. + */ +const filterContentTypesByIteration = ( + currentCts: ContentTypesMapper[], + prevCts: ContentTypesMapper[], + mode: 'new' | 'old', +): ContentTypesMapper[] => { + const prevUids = new Set( + (prevCts ?? []) + .map((ct) => ct?.otherCmsUid) + .filter((uid): uid is string => Boolean(uid)), + ); + return (currentCts ?? []).filter((ct) => { + if (mode === 'old') { + const hasEntries = Array.isArray(ct?.entryMapping) && ct.entryMapping.length > 0; + return prevUids.has(ct?.otherCmsUid) && hasEntries; + } + return !prevUids.has(ct?.otherCmsUid); + }); +}; + /** * Retrieves the content types based on the provided request parameters. * @param req - The request object containing the parameters. @@ -336,6 +369,9 @@ const getContentTypes = async (req: Request) => { const skip: any = req?.params?.skip; const limit: any = req?.params?.limit; const search: string = req?.params?.searchText?.toLowerCase(); + // Delta migration: 'new' (default) → first-time content types for Step 3 (field mapping); + // 'old' → already-migrated content types for Step 4 (entry mapping). Only applied when iteration > 1. + const filter: 'new' | 'old' = req?.query?.filter === 'old' ? 'old' : 'new'; let result: any = []; let totalCount = 0; @@ -386,6 +422,42 @@ const getContentTypes = async (req: Request) => { `📦 [getContentTypes] Found ${content_mapper.length} content types`, ); + // Delta migration: from iteration 2 onwards, split content types into new vs old + // relative to the previous iteration so Step 3 (field mapping) shows only new types + // and Step 4 (entry mapping) shows only already-migrated types. Iteration 1 is untouched. + if (iteration > 1) { + const PrevContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration - 1); + await PrevContentTypesMapperModelLowdb.read(); + const prevContentMapper = + PrevContentTypesMapperModelLowdb.chain.get('ContentTypesMappers').value() ?? []; + + const filtered = filterContentTypesByIteration(content_mapper, prevContentMapper, filter); + content_mapper.length = 0; + content_mapper.push(...filtered); + + // For Step 4 (Map Entry), derive each content type's status from the entry mapper so the + // list icon reflects persisted state on load: 'Updated' (2/green) when the content type has + // at least one entry marked isUpdate, otherwise 'Mapped' (1/blue). Without this the UI would + // show every type as blue after a reload until the user opens it. + if (filter === 'old') { + const EntryMapperModelLowdb = getEntryMapperDb(projectId, iteration); + await EntryMapperModelLowdb.read(); + const entryMapperItems = EntryMapperModelLowdb.chain.get('entry_mapper').value() ?? []; + const updatedContentTypeIds = new Set( + entryMapperItems + .filter((entry: any) => entry?.isUpdate) + .map((entry: any) => entry?.contentTypeId), + ); + content_mapper.forEach((ct: any) => { + ct.status = updatedContentTypeIds.has(ct?.id) ? 2 : 1; + }); + } + + logger.info( + `📦 [getContentTypes] iteration ${iteration}, filter '${filter}' → ${content_mapper.length} content types`, + ); + } + if (!isEmpty(content_mapper)) { if (search) { const filteredResult = content_mapper diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 784e4dacd..f90205c06 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -14,7 +14,7 @@ import { HTTP_TEXTS, HTTP_CODES, LOCALE_MAPPER, - STEPPER_STEPS, + getStepperSteps, CMS, GET_AUDIT_DATA, MIGRATION_DATA_CONFIG, @@ -151,7 +151,9 @@ const createTestStack = async (req: Request): Promise => { ProjectModelLowdb.update((data: any) => { - data.projects[index].current_step = STEPPER_STEPS['TESTING']; + // Delta migration: Testing is step 5 on iteration 2+ (4 on iteration 1). + data.projects[index].current_step = + getStepperSteps(data.projects[index]?.iteration)['TESTING']; data.projects[index].current_test_stack_id = res?.data?.stack?.api_key; data.projects[index].test_stacks.push({ stackUid: res?.data?.stack?.api_key, diff --git a/api/src/services/projects.service.ts b/api/src/services/projects.service.ts index 8e5524022..9dd2fade0 100644 --- a/api/src/services/projects.service.ts +++ b/api/src/services/projects.service.ts @@ -15,6 +15,7 @@ import { HTTP_CODES, STEPPER_STEPS, NEW_PROJECT_STATUS, + getStepperSteps, } from "../constants/index.js"; import { config } from "../config/index.js"; import { getLogMessage, isEmpty, safePromise } from "../utils/index.js"; @@ -934,6 +935,11 @@ const updateCurrentStep = async (req: Request) => { const isStepCompleted = project?.legacy_cms?.cms && project?.legacy_cms?.file_format; + // Delta migration: from iteration 2 onwards the flow has an extra "Map Entry" step (step 4), + // shifting Testing → 5 and Migration → 6. Resolve the step-number map for this project's + // iteration so the state machine progresses through the correct steps. + const steps = getStepperSteps(project?.iteration); + switch (project.current_step) { case STEPPER_STEPS.LEGACY_CMS: { if (project.status !== NEW_PROJECT_STATUS[0] || !isStepCompleted) { @@ -998,7 +1004,7 @@ const updateCurrentStep = async (req: Request) => { }); break; } - case STEPPER_STEPS.CONTENT_MAPPING: { + case steps.CONTENT_MAPPING: { if ( project.status === NEW_PROJECT_STATUS[0] || !isStepCompleted || @@ -1023,13 +1029,31 @@ const updateCurrentStep = async (req: Request) => { ) { throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND); } - data.projects[projectIndex].current_step = STEPPER_STEPS.TESTING; + // Delta iteration: Content Mapping → Map Entry (step 4). Iteration 1: → Testing (step 4). + data.projects[projectIndex].current_step = + steps.MAP_ENTRY ?? steps.TESTING; + data.projects[projectIndex].status = NEW_PROJECT_STATUS[4]; + data.projects[projectIndex].updated_at = new Date().toISOString(); + }); + break; + } + // Map Entry → Testing. Only reachable on delta iterations (step exists from iteration 2). + case steps.MAP_ENTRY: { + await ProjectModelLowdb.update((data: any) => { + if ( + !data?.projects || + !Array.isArray(data.projects) || + !data.projects[projectIndex] + ) { + throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND); + } + data.projects[projectIndex].current_step = steps.TESTING; data.projects[projectIndex].status = NEW_PROJECT_STATUS[4]; data.projects[projectIndex].updated_at = new Date().toISOString(); }); break; } - case STEPPER_STEPS.TESTING: { + case steps.TESTING: { if ( project.status === NEW_PROJECT_STATUS[0] || !isStepCompleted || @@ -1056,13 +1080,13 @@ const updateCurrentStep = async (req: Request) => { ) { throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND); } - data.projects[projectIndex].current_step = STEPPER_STEPS.MIGRATION; + data.projects[projectIndex].current_step = steps.MIGRATION; data.projects[projectIndex].status = NEW_PROJECT_STATUS[4]; data.projects[projectIndex].updated_at = new Date().toISOString(); }); break; } - case STEPPER_STEPS.MIGRATION: { + case steps.MIGRATION: { if ( project.status === NEW_PROJECT_STATUS[0] || !isStepCompleted || @@ -1089,7 +1113,7 @@ const updateCurrentStep = async (req: Request) => { ) { throw new NotFoundError(HTTP_TEXTS.PROJECT_NOT_FOUND); } - data.projects[projectIndex].current_step = STEPPER_STEPS.MIGRATION; + data.projects[projectIndex].current_step = steps.MIGRATION; data.projects[projectIndex].status = NEW_PROJECT_STATUS[5]; data.projects[projectIndex].updated_at = new Date().toISOString(); }); @@ -1575,7 +1599,8 @@ const getMigratedStacks = async (req: Request) => { (project: any) => project != null && project?.status === 5 && - project?.current_step === 5 && + // Project is on its final Execute step (6 on delta iterations, 5 otherwise). + project?.current_step === getStepperSteps(project?.iteration).MIGRATION && project?.destination_stack_id ) .map((project: any) => project.destination_stack_id) diff --git a/api/src/services/runCli.service.ts b/api/src/services/runCli.service.ts index c814117f8..c4d09e0a1 100644 --- a/api/src/services/runCli.service.ts +++ b/api/src/services/runCli.service.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import { spawn } from 'child_process'; import { v4 } from 'uuid'; import { copyDirectory, createDirectoryAndFile } from '../utils/index.js'; -import { CS_REGIONS, MIGRATION_DATA_CONFIG, DATABASE_FILES } from '../constants/index.js'; +import { CS_REGIONS, MIGRATION_DATA_CONFIG, DATABASE_FILES, getStepperSteps } from '../constants/index.js'; import ProjectModelLowdb from '../models/project-lowdb.js'; import AuthenticationModel from '../models/authentication.js'; // import watchLogs from '../utils/watch.utils.js'; @@ -317,7 +317,9 @@ export const runCli = async ( true; ProjectModelLowdb.data.projects[projectIndex].isMigrationStarted = false; - ProjectModelLowdb.data.projects[projectIndex].current_step = 5; + // Migration completed → land on the final Execute step (6 on delta iterations, 5 otherwise). + ProjectModelLowdb.data.projects[projectIndex].current_step = + getStepperSteps(ProjectModelLowdb.data.projects[projectIndex]?.iteration).MIGRATION; ProjectModelLowdb.data.projects[projectIndex].status = 5; await ProjectModelLowdb.write(); } diff --git a/api/src/utils/entry-update-script.cjs b/api/src/utils/entry-update-script.cjs index 29f667772..fa4d36e3d 100644 --- a/api/src/utils/entry-update-script.cjs +++ b/api/src/utils/entry-update-script.cjs @@ -153,7 +153,6 @@ module.exports = async ({ if (updateData && entry) { for (const field of Object.keys(updateData)) { if (isAssetField(updateData[field])) { - console.info('field is asset field'); updateData[field] = resolveAssetField( field, entryUid, diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 489fc4c1b..7b5aecfcc 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -75,10 +75,18 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: entryMapperItems.map((item: { otherCmsEntryUid: string }) => item?.otherCmsEntryUid) ); + // Entries that already exist in Contentstack (have a contentstackEntryUid). These are removed + // from the import data so they are NOT re-created — regardless of isUpdate. + const csUidMap = new Map(); + // Subset of the above marked isUpdate → collected into the update config so they get updated + // in Contentstack. Existing entries that are NOT isUpdate are simply left untouched. const updateUidMap = new Map(); for (const item of entryMapperItems) { - if (item.isUpdate) { - updateUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid); + if (item?.contentstackEntryUid) { + csUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid); + if (item?.isUpdate) { + updateUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid); + } } } @@ -144,8 +152,17 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: let modified = false; for (const key of Object?.keys(data)) { if (sitecoreUids.has(key)) { - const csEntryUid = updateUidMap.get(key); - if (csEntryUid) { + const csEntryUid = csUidMap.get(key); + // No Contentstack entry uid → this entry was never migrated, so leave it in + // the import data to be CREATED this iteration. Deleting it would silently + // drop the entry (data loss). + if (!csEntryUid) { + continue; + } + + // Entry already exists in Contentstack. If it's marked isUpdate, collect it + // into the update config so it gets updated; otherwise it's left as-is. + if (updateUidMap.has(key)) { const entryData = { ...data[key] }; delete entryData?.uid; @@ -157,10 +174,11 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}"`, "removeEntriesFromDatabase", loggerPath); } + // Existing entry → remove from import data so it is NOT re-created. delete data[key]; modified = true; writeLogEntry(`Removed entry "${key}" from ${filePath}`, "removeEntriesFromDatabase", loggerPath); - writeLogEntry(`Entry "${key}" has been removed from migration data (will be updated instead of created)`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been removed from migration data (exists in Contentstack)`, "removeEntriesFromDatabase", loggerPath); } } diff --git a/ui/src/cmsData/migrationSteps.json b/ui/src/cmsData/migrationSteps.json index c8cab2826..af4b852b0 100644 --- a/ui/src/cmsData/migrationSteps.json +++ b/ui/src/cmsData/migrationSteps.json @@ -172,17 +172,23 @@ } } }, + { + "map_entry": { + "step_title": "Map Entry", + "step_number": 4 + } + }, { "test_migration": { "step_title": "Test Migration", - "step_number": 4 + "step_number": 5 } }, { "migration_execution": { "step_title": "Migration Execution", "_metadata": { "uid": "csd1b57ee4b93373fb" }, - "step_number": 5, + "step_number": 6, "migration_execution": [ { "uid": "blt875421bb12019625", "_content_type_uid": "migration_execution" } ] @@ -224,8 +230,15 @@ "group_name": "Layout" }, { - "flow_id": "testMigration", + "flow_id": "mapEntry", "name": 4, + "title": "Map Entry", + "description": "Map entries of already-migrated content types", + "group_name": "Layout" + }, + { + "flow_id": "testMigration", + "name": 5, "title": "Test Migration", "description": "Preview the migration process", "group_name": "Layout" @@ -233,7 +246,7 @@ { "flow_id": "migrationExecution", "_metadata": { "uid": "csb100b7a598d9c333" }, - "name": 5, + "name": 6, "title": "Migration Execution", "description": "Wait for the execution for migration complete.", "group_name": "SeeDetails" diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index aa447d3ef..d60c55c0d 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -1,86 +1,120 @@ // Libraries -import { useEffect, useState} from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { Button, + Search, + Icon, + Tooltip, InfiniteScrollTable, Notification, - + cbModal, + CircularLoader, + EmptyState, } from '@contentstack/venus-components'; // Services import { getCMSDataFromFile } from '../../cmsData/cmsSelector'; import { getContentTypes, + getFieldMapping, getEntryMapping, updateEntryMapper, } from '../../services/api/migration.service'; // Redux import { RootState } from '../../store'; -import { updateMigrationData } from '../../store/slice/migrationDataSlice'; +import { updateMigrationData, updateNewMigrationData } from '../../store/slice/migrationDataSlice'; // Utilities -import { CS_ENTRIES } from '../../utilities/constants'; -import useBlockNavigation from '../../hooks/userNavigation'; +import { CS_ENTRIES, CONTENT_MAPPING_STATUS, STATUS_ICON_Mapping } from '../../utilities/constants'; +import { validateArray } from '../../utilities/functions'; // Interface -import { DEFAULT_CONTENT_MAPPING_DATA } from '../../context/app/app.interface'; +import { DEFAULT_CONTENT_MAPPING_DATA, INewMigration } from '../../context/app/app.interface'; import { ContentType, TableTypes, - UidMap, - EntryMapperType + UidMap, + EntryMapperType, + MouseOrKeyboardEvent, } from './contentMapper.interface'; import { ItemStatusMapProp } from '@contentstack/venus-components/build/components/Table/types'; +import { ModalObj } from '../Modal/modal.interface'; +// Components +import SchemaModal from '../SchemaModal'; // Styles and Assets import './index.scss'; +import { NoDataFound, SCHEMA_PREVIEW } from '../../common/assets'; -const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange}: {selectedContentTypeId: ContentType | null, tableHeight: number, onEntrySelectionChange?: (contentTypeId: string, hasSelection: boolean) => void}) => { - // Redux State - const dispatch = useDispatch(); - - const { projectId = '' } = useParams<{ projectId: string }>(); +interface entryMapperProps { + handleStepChange: (currentStep: number) => void; +} +/** + * Step 4 — Map Entry (delta migration). + * Standalone step: left content-type list (only ALREADY-MIGRATED content types, fetched with + * filter='old') + right entry-mapping table. Maps source entries to destination Contentstack + * entries for content types that were migrated in a previous iteration. + */ +const EntryMapper = ({ handleStepChange }: entryMapperProps) => { + /** ALL CONTEXT HERE */ + const migrationData = useSelector((state: RootState) => state?.migration?.migrationData); const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const selectedOrganisation = useSelector((state: RootState) => state?.authentication?.selectedOrganisation); - // Component State + const { + contentMappingData: { + content_types_heading: contentTypesHeading, + search_placeholder: searchPlaceholder, + } = {} + } = migrationData; + + const dispatch = useDispatch(); + + /** ALL STATE HERE */ const [tableData, setTableData] = useState([]); const [loading, setLoading] = useState(false); - const [totalCounts, setTotalCounts] = useState(tableData?.length); - const [searchText, setSearchText] = useState(''); - const [selectedContentType, setSelectedContentType] = useState(selectedContentTypeId); - const [contentTypes, setContentTypes] = useState([]); + const [isLoading, setIsLoading] = useState(newMigrationData?.isprojectMapped); + const [totalCounts, setTotalCounts] = useState(0); const [itemStatusMap, setItemStatusMap] = useState({}); + const [searchText, setSearchText] = useState(''); + const [searchContentType, setSearchContentType] = useState(''); + const [contentTypes, setContentTypes] = useState([]); + const [filteredContentTypes, setFilteredContentTypes] = useState([]); + const [count, setCount] = useState(0); const [otherCmsTitle, setOtherCmsTitle] = useState(''); - const [contentTypeUid, setContentTypeUid] = useState(selectedContentTypeId?.id || ''); + const [otherCmsUid, setOtherCmsUid] = useState(''); + const [contentTypeUid, setContentTypeUid] = useState(''); + + const [active, setActive] = useState(0); + const [showFilter, setShowFilter] = useState(false); + const [activeFilter, setActiveFilter] = useState(''); - const [isContentType, setIsContentType] = useState(true); - - const [otherCmsUid, setOtherCmsUid] = useState(contentTypes?.[0]?.otherCmsUid); const [rowIds, setRowIds] = useState>({}); const [persistedRowIds, setPersistedRowIds] = useState>({}); const [isLoadingSaveButton, setisLoadingSaveButton] = useState(false); const [initialRowSelectedData, setInitialRowSelectedData] = useState([]); + /** ALL HOOKS HERE */ + const { projectId = '' } = useParams<{ projectId: string }>(); + const navigate = useNavigate(); + const filterRef = useRef(null); + const tableWrapperRef = useRef(null); - - /********** ALL USEEFFECT HERE *************/ + /********** ALL USEEFFECT HERE *************/ useEffect(() => { - //check if offline CMS data field is set to true, if then read data from cms data file. + // check if offline CMS data field is set to true, if then read data from cms data file. getCMSDataFromFile(CS_ENTRIES.CONTENT_MAPPING) .then((data) => { - //Check for null if (!data) { dispatch(updateMigrationData({ contentMappingData: DEFAULT_CONTENT_MAPPING_DATA })); return; } - dispatch(updateMigrationData({ contentMappingData: data })); }) .catch((err) => { @@ -90,14 +124,13 @@ const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange fetchContentTypes(searchText || ''); }, []); + // Close filter panel when clicking outside useEffect(() => { - if (selectedContentTypeId) { - fetchEntries(selectedContentTypeId?.id || '', searchText); - setOtherCmsTitle(selectedContentTypeId?.otherCmsTitle); - } - - },[selectedContentTypeId]); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + /********** HELPERS *************/ const buildSelectedRowIds = (entries: EntryMapperType[]) => { return (entries ?? []).reduce((acc, item) => { if (item?._canSelect && item?.isUpdate) { @@ -120,45 +153,132 @@ const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange }); }; - const fetchContentTypes = async (searchText: string) => { - //setIsLoading(true); - + /********** CONTENT TYPE LIST (left panel) *************/ + // Fetch ALREADY-MIGRATED content types only (filter='old') — these are the ones whose entries + // exist and can be mapped in this delta iteration. + const fetchContentTypes = async (searchVal: string) => { + setIsLoading(true); try { - const { data } = await getContentTypes(projectId || '', 0, 5000, ''); //org id will always present - + const { data } = await getContentTypes(projectId || '', 0, 5000, searchContentType || '', 'old'); + + setIsLoading(false); + setContentTypes(data?.contentTypes ?? []); + setFilteredContentTypes(data?.contentTypes ?? []); + setCount(data?.contentTypes?.length ?? 0); + setOtherCmsTitle(data?.contentTypes?.[0]?.otherCmsTitle ?? ''); + setContentTypeUid(data?.contentTypes?.[0]?.id ?? ''); + setOtherCmsUid(data?.contentTypes?.[0]?.otherCmsUid ?? ''); + if (data?.contentTypes?.[0]?.id) { + fetchEntries(data?.contentTypes?.[0]?.id, searchVal ?? ''); + } + } catch (error) { + setIsLoading(false); + console.error(error); + return error; + } + }; - setContentTypes(data?.contentTypes); - setSelectedContentType(data?.contentTypes?.[0]); - setOtherCmsTitle(data?.contentTypes?.[0]?.otherCmsTitle); - setContentTypeUid(data?.contentTypes?.[0]?.id); - fetchEntries(data?.contentTypes?.[0]?.id, searchText ?? ''); - setOtherCmsUid(data?.contentTypes?.[0]?.otherCmsUid); - setIsContentType(data?.contentTypes?.[0]?.type === "content_type"); + // Search content types in the left list + const handleSearch = async (searchCT: string) => { + setSearchContentType(searchCT); + try { + const { data } = await getContentTypes(projectId, 0, 1000, searchCT || '', 'old'); + setContentTypes(data?.contentTypes ?? []); + setFilteredContentTypes(data?.contentTypes ?? []); + setCount(data?.contentTypes?.length ?? 0); } catch (error) { console.error(error); return error; } }; - // Method to get fieldmapping - const fetchEntries = async (contentTypeId: string, searchText: string) => { + const handleOpenContentType = (i = 0) => { + // Reset scroll position to top when switching content types + if (tableWrapperRef?.current) { + const elements = tableWrapperRef.current?.querySelectorAll('.Table__body'); + elements?.forEach((el) => { + if (el instanceof HTMLElement) { + el.scrollTop = 0; + } + }); + } + + setActive(i); + const ct = filteredContentTypes?.[i]; + setOtherCmsTitle(ct?.otherCmsTitle ?? ''); + setContentTypeUid(ct?.id ?? ''); + setOtherCmsUid(ct?.otherCmsUid ?? ''); + if (ct?.id) { + fetchEntries(ct.id, searchText || ''); + } + }; + + const handleSchemaPreview = async (title: string, ctId: string) => { try { - const itemStatusMap: ItemStatusMapProp = {}; + const { data } = await getFieldMapping(ctId ?? '', 0, 1000, searchText ?? '', projectId); + return cbModal({ + component: (props: ModalObj) => ( + + ), + modalProps: { + shouldCloseOnOverlayClick: true + } + }); + } catch (err) { + console.error(err); + return err; + } + }; + + // Toggle filter panel + const handleFilter = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowFilter(!showFilter); + }; + + // Filter content types by status + const handleContentTypeFilter = (value: string, e: MouseOrKeyboardEvent) => { + setActiveFilter(value); + const li_list = document.querySelectorAll('.filter-wrapper li'); + li_list?.forEach((ele) => ele?.classList?.remove('active-filter')); + (e?.target as HTMLElement)?.closest('li')?.classList?.add('active-filter'); + + const filteredCT = contentTypes?.filter((ct) => CONTENT_MAPPING_STATUS[ct?.status] === value); + if (value !== 'All') { + setFilteredContentTypes(filteredCT); + setCount(filteredCT?.length); + const selectedIndex = filteredCT.findIndex((ct) => ct?.otherCmsUid === otherCmsUid); + setActive(selectedIndex >= 0 ? selectedIndex : null); + } else { + setFilteredContentTypes(contentTypes); + setCount(contentTypes?.length); + setActive(contentTypes?.findIndex((ct) => ct?.otherCmsUid === otherCmsUid)); + } + setShowFilter(false); + }; + const handleClickOutside = (evt: MouseEvent) => { + if (!filterRef.current?.contains(evt.target as Node)) { + setShowFilter(false); + } + }; + + /********** ENTRY TABLE (right panel) *************/ + const fetchEntries = async (ctId: string, searchVal: string) => { + try { + const itemStatusMapLocal: ItemStatusMapProp = {}; for (let index = 0; index <= 1000; index++) { - itemStatusMap[index] = 'loading'; + itemStatusMapLocal[index] = 'loading'; } - - setItemStatusMap(itemStatusMap); + setItemStatusMap(itemStatusMapLocal); setLoading(true); - - const { data } = await getEntryMapping(contentTypeId || '', 0, 1000, searchText, projectId); - + + const { data } = await getEntryMapping(ctId || '', 0, 1000, searchVal, projectId); + for (let index = 0; index <= 1000; index++) { - itemStatusMap[index] = 'loaded'; + itemStatusMapLocal[index] = 'loaded'; } - - setItemStatusMap({ ...itemStatusMap }); + setItemStatusMap({ ...itemStatusMapLocal }); setLoading(false); const validTableData: EntryMapperType[] = (data?.entryMapping ?? []).map((entry: EntryMapperType) => ({ @@ -166,52 +286,42 @@ const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange _canSelect: !!entry?.contentstackEntryUid, })); - //setIsAllCheck(true); const initialSelected = buildSelectedRowIds(validTableData ?? []); setTableData(validTableData ?? []); setRowIds(initialSelected); setPersistedRowIds(initialSelected); setTotalCounts(validTableData?.length); - setInitialRowSelectedData(validTableData?.filter((item: EntryMapperType) => !item?.isUpdate)) - - // Reflect any pre-existing entry selections on the content type icon (green when present) - const ctId = contentTypeId || selectedContentTypeId?.id; - if (ctId) { - onEntrySelectionChange?.(ctId, Object.keys(initialSelected ?? {}).length > 0); - } - + setInitialRowSelectedData(validTableData?.filter((item: EntryMapperType) => !item?.isUpdate)); + // Reflect any pre-existing entry selections on the content type icon (green when present). + updateContentTypeStatus(ctId, Object.keys(initialSelected ?? {}).length > 0); } catch (error) { - console.error('fetchData -> error', error); + console.error('fetchEntries -> error', error); } }; - // Fetch table data - const fetchData = async ({ searchText }: TableTypes) => { - setSearchText(searchText) - selectedContentTypeId?.id && fetchEntries(selectedContentTypeId?.id, searchText); + // Fetch table data on search + const fetchData = async ({ searchText: search }: TableTypes) => { + setSearchText(search); + contentTypeUid && fetchEntries(contentTypeUid, search); }; - // Method for Load more table data - const loadMoreItems = async ({ searchText, skip, limit, startIndex, stopIndex }: TableTypes) => { + // Load more table data + const loadMoreItems = async ({ searchText: search, skip, limit, startIndex, stopIndex }: TableTypes) => { try { const itemStatusMapCopy: ItemStatusMapProp = { ...itemStatusMap }; - for (let index = startIndex; index <= stopIndex; index++) { itemStatusMapCopy[index] = 'loading'; } - setItemStatusMap({ ...itemStatusMapCopy }); setLoading(true); - const { data } = await getEntryMapping(contentTypeUid || '', skip, limit, searchText || '', projectId); - - const updateditemStatusMapCopy: ItemStatusMapProp = { ...itemStatusMap }; + const { data } = await getEntryMapping(contentTypeUid || '', skip, limit, search || '', projectId); + const updated: ItemStatusMapProp = { ...itemStatusMap }; for (let index = startIndex; index <= stopIndex; index++) { - updateditemStatusMapCopy[index] = 'loaded'; + updated[index] = 'loaded'; } - - setItemStatusMap({ ...updateditemStatusMapCopy }); + setItemStatusMap({ ...updated }); setLoading(false); const validTableData: EntryMapperType[] = (data?.entryMapping ?? []).map((entry: EntryMapperType) => ({ @@ -219,151 +329,114 @@ const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange _canSelect: !!entry?.contentstackEntryUid, })); - // eslint-disable-next-line no-unsafe-optional-chaining setTableData(applySelectionToEntries(validTableData ?? [], rowIds)); - } catch (error) { console.error('loadMoreItems -> error', error); } }; - /** - * Handle the selected entries - * @param singleSelectedRowIds - The single selected row IDs - * @returns void - */ - const handleSelectedEntries = (singleSelectedRowIds: string[]) => { - const selectedObj: UidMap = {}; - singleSelectedRowIds?.forEach((uid: string) => { - selectedObj[uid] = true; - }); + /** + * Reflect entry-update selection on the content type's status icon: + * has selected entries → 'Updated' (status '2', green); none → 'Mapped' (status '1', blue). + */ + const updateContentTypeStatus = (contentTypeId: string, hasSelection: boolean) => { + if (!contentTypeId) return; + const nextStatus = hasSelection ? '2' : '1'; + const applyStatus = (list: ContentType[]) => + list?.map?.((ct) => (ct?.id === contentTypeId ? { ...ct, status: nextStatus } : ct)); + setContentTypes((prev) => applyStatus(prev)); + setFilteredContentTypes((prev) => applyStatus(prev)); + }; - setRowIds(selectedObj); - setTableData((prev) => applySelectionToEntries(prev ?? [], selectedObj)); - }; - - const handleSaveContentType = async () => { - console.info("handleSaveContentType", rowIds); - setisLoadingSaveButton(true); - const allKeys = new Set([ - ...Object.keys(rowIds ?? {}), - ...Object.keys(persistedRowIds ?? {}), - ]); - const changedUids = Array.from(allKeys).filter( - (uid) => !!rowIds?.[uid] !== !!persistedRowIds?.[uid], - ); - const orgId = selectedOrganisation?.uid; - // const projectID = projectId; - - if (orgId && contentTypeUid) { - const dataCs = { - ids: changedUids - }; + // Handle selected entries + const handleSelectedEntries = (singleSelectedRowIds: string[]) => { + const selectedObj: UidMap = {}; + singleSelectedRowIds?.forEach((uid: string) => { + selectedObj[uid] = true; + }); + setRowIds(selectedObj); + setTableData((prev) => applySelectionToEntries(prev ?? [], selectedObj)); + }; + + const handleSaveContentType = async () => { + setisLoadingSaveButton(true); + const allKeys = new Set([ + ...Object.keys(rowIds ?? {}), + ...Object.keys(persistedRowIds ?? {}), + ]); + const changedUids = Array.from(allKeys).filter( + (uid) => !!rowIds?.[uid] !== !!persistedRowIds?.[uid], + ); + const orgId = selectedOrganisation?.uid; + + if (orgId && contentTypeUid) { + const dataCs = { ids: changedUids }; try { if (changedUids.length === 0) { setisLoadingSaveButton(false); return Notification({ notificationContent: { text: 'No changes to save' }, - notificationProps: { - position: 'bottom-center', - hideProgressBar: true - }, + notificationProps: { position: 'bottom-center', hideProgressBar: true }, type: 'info' }); } - const {data, status} = await updateEntryMapper(projectId, dataCs); - console.info("status", status, typeof status, data); - - setisLoadingSaveButton(false); + const { status } = await updateEntryMapper(projectId, dataCs); + setisLoadingSaveButton(false); if (status === 200) { setPersistedRowIds({ ...(rowIds ?? {}) }); setLoading(false); - - // Reflect the saved update on the content type icon: green (Updated) when entries - // remain selected after save, blue (Mapped) when all selections were cleared. - const ctId = selectedContentTypeId?.id || contentTypeUid; - if (ctId) { - const hasSelection = Object.values(rowIds ?? {}).some(Boolean); - onEntrySelectionChange?.(ctId, hasSelection); - } - + // Reflect the saved state on the content type icon: green (Updated) when entries remain + // selected after save, blue (Mapped) when all selections were cleared. + updateContentTypeStatus(contentTypeUid, Object.values(rowIds ?? {}).some(Boolean)); return Notification({ notificationContent: { text: 'Entries saved successfully' }, - notificationProps: { - position: 'bottom-center', - hideProgressBar: true - }, + notificationProps: { position: 'bottom-center', hideProgressBar: true }, type: 'success' }); } - else{ - setisLoadingSaveButton(false); - return Notification({ - notificationContent: { text: 'Failed to save entries' }, - notificationProps: { - position: 'bottom-center', - hideProgressBar: true - }, - type: 'error' - }); - } + return Notification({ + notificationContent: { text: 'Failed to save entries' }, + notificationProps: { position: 'bottom-center', hideProgressBar: true }, + type: 'error' + }); } catch (error) { console.error(error); setisLoadingSaveButton(false); return error; } - - } } - const accessorCall = (data: EntryMapperType) => { - // Clean field name (remove parent hierarchy) - const cleanFieldName = data?.entryName - return ( -
-
-
- {cleanFieldName} -
-
-
- ); }; - const accessorContentstackCall = (data: EntryMapperType) => { - // Clean field name (remove parent hierarchy) - const cleanFieldName = data?.contentstackEntryUid - return ( -
-
-
- {cleanFieldName ? cleanFieldName : '-'} -
-
-
- - ); - }; + /********** TABLE COLUMNS *************/ + const accessorCall = (data: EntryMapperType) => ( +
+
+
{data?.entryName}
+
+
+ ); - const accessorForCMSUid = (data: EntryMapperType) => { - const cleanFieldName = data?.otherCmsEntryUid - return ( -
-
-
- {cleanFieldName ? cleanFieldName : '-'} -
-
+ const accessorForCMSUid = (data: EntryMapperType) => ( +
+
+
{data?.otherCmsEntryUid ? data?.otherCmsEntryUid : '-'}
- ); - } +
+ ); + + const accessorContentstackCall = (data: EntryMapperType) => ( +
+
+
{data?.contentstackEntryUid ? data?.contentstackEntryUid : '-'}
+
+
+ ); - const columns = [ + const columns = [ { disableSortBy: true, Header: ( - - {`${newMigrationData?.legacy_cms?.selectedCms?.title}: ${otherCmsTitle}`} - + {`${newMigrationData?.legacy_cms?.selectedCms?.title}: ${otherCmsTitle}`} ), accessor: accessorCall, id: 'uuid', @@ -371,72 +444,227 @@ const EntryMapper = ({selectedContentTypeId, tableHeight, onEntrySelectionChange }, { disableSortBy: true, - Header: ( - - {`${newMigrationData?.legacy_cms?.selectedCms?.title} UIDs:`} - - ), + Header: {`${newMigrationData?.legacy_cms?.selectedCms?.title} UIDs:`}, accessor: accessorForCMSUid, id: '1' }, { disableSortBy: true, - Header: ( - - {'Contentstack UIDs:'} - - ), - accessor: accessorContentstackCall, + Header: {'Contentstack UIDs:'}, + accessor: accessorContentstackCall, id: '2' } ]; + const calcHeight = () => window.innerHeight - 361; + const tableHeight = calcHeight(); + + const modalProps = { + body: 'There is something error occured while generating content mapper. Please go to Legacy Cms step and validate the file again.', + }; + return ( -
- - {totalCounts > 0 && ( -
-
Total Entries: {totalCounts}
-
+ : +
+ {(contentTypes?.length > 0 || tableData?.length > 0) ? +
+ {/* Content Types List */} +
+
+ {contentTypesHeading &&

{`${contentTypesHeading} (${contentTypes && count})`}

} +
+ +
+
+ handleSearch(search)} + onClear={true} + value={searchContentType} + debounceSearch={true} + /> + + + {showFilter && ( +
+
    + {Object.keys(CONTENT_MAPPING_STATUS)?.map?.((key, keyInd) => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ + {filteredContentTypes && validateArray(filteredContentTypes) + ?
+
    + {filteredContentTypes?.map?.((content: ContentType, index: number) => { + const icon = STATUS_ICON_Mapping[content?.status] || ''; + const format = (str: string) => { + const frags = str?.split('_'); + for (let i = 0; i < frags?.length; i++) { + frags[i] = frags?.[i]?.charAt?.(0)?.toUpperCase() + frags?.[i]?.slice(1); + } + return frags?.join?.(' '); + }; + return ( +
  • + +
    + + {icon && ( + + + + )} + + + + + + +
    +
  • + ); + })} +
+
+ :
No Content Types Found.
+ } +
+ + {/* Entry Mapping Table */} +
+
+
+ + {totalCounts > 0 && ( +
+
Total Entries: {totalCounts}
+ +
+ )} +
+
+
+
: + No Content Types available
} + description={ +
+ {modalProps?.body} +
+ } + className="mapper-emptystate" + img={NoDataFound} + actions={ + + } version="v2" - disabled={newMigrationData?.project_current_step > 4} - //isLoading={isLoadingSaveButton} - > - Save - + testId="no-results-found-page" + />}
- )} + ); +}; - -
- - ) -} export default EntryMapper; \ No newline at end of file diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 478de8b1c..db598688c 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -152,8 +152,8 @@ border-left: 0 none; min-height: 24.25rem; .Table__body__row { - .Table-select-body { - >.checkbox-wrapper { + .Table-select-body { + > .checkbox-wrapper { align-items: flex-start; } } @@ -313,6 +313,15 @@ div .table-row { .mapper-emptystate { padding: 10px 10px; } +// Delta iteration empty state has no "Go to Legacy CMS" action button, so center it vertically +// in the available space instead of sitting top-heavy. +.mapper-emptystate--centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; +} .Checkbox .Checkbox__tick svg { display: block; } @@ -323,11 +332,11 @@ div .table-row { font-size: $size-font-large; font-weight: $font-weight-semi-bold; } -.filterButton-color{ +.filterButton-color { color: $color-brand-primary-base; font-weight: $font-weight-bold; } -.icon-padding{ +.icon-padding { padding: 10px 10px; margin-left: 6px; } @@ -337,23 +346,23 @@ div .table-row { font-weight: 600; // margin-left: 16px; } - + &.child-row { margin-left: 26px; } - + &.child-row-level-2 { border-left: 1px solid $color-brand-secondary-lightest; margin-left: 36px; padding-left: 16px; } - + &.child-row-level-3 { border-left: 1px solid $color-brand-secondary-lightest; margin-left: 62px; padding-left: 16px; } - + &.child-row-level-4 { border-left: 1px solid $color-brand-secondary-lightest; margin-left: 98px; @@ -432,14 +441,14 @@ div .table-row { .table-row { display: flex; align-items: center; - gap: 8px; + gap: 8px; } .table-row .select { - flex: 1; + flex: 1; } .advanced-setting-button, .table-row button { - flex-shrink: 0; + flex-shrink: 0; } // Prevent header wrapping @@ -478,7 +487,7 @@ div .table-row { overflow-y: auto !important; } -// Safety for small screens +// Safety for small screens @media (max-height: 800px) { .mapper-footer { padding: 10px; @@ -487,7 +496,7 @@ div .table-row { .select { .tippy-wrapper { - display: inline; + display: inline; } } @@ -547,4 +556,4 @@ div .table-row { border-right: none !important; } } -} \ No newline at end of file +} diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 61ab8b41d..7753bd414 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -41,7 +41,7 @@ import { RootState } from '../../store'; import { updateMigrationData, updateNewMigrationData } from '../../store/slice/migrationDataSlice'; // Utilities -import { CS_ENTRIES, CONTENT_MAPPING_STATUS, STATUS_ICON_Mapping } from '../../utilities/constants'; +import { CS_ENTRIES, CONTENT_MAPPING_STATUS, STATUS_ICON_Mapping, CONTENT_MAPPER_EMPTY_STATE } from '../../utilities/constants'; import { isEmptyString, validateArray } from '../../utilities/functions'; import useBlockNavigation from '../../hooks/userNavigation'; @@ -85,7 +85,6 @@ import { // Styles and Assets import './index.scss'; import { NoDataFound, SCHEMA_PREVIEW } from '../../common/assets'; -import EntryMapper from './entryMapper'; const FIELD_MAP_MENU_VIEW_MARGIN = 8; const FIELD_MAP_MENU_HYSTERESIS = 36; @@ -494,9 +493,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const migrationData = useSelector((state: RootState) => state?.migration?.migrationData); const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const selectedOrganisation = useSelector((state: RootState) => state?.authentication?.selectedOrganisation); - const iteration = useSelector( - (state: RootState) => state?.migration?.newMigrationData?.iteration - ); // When setting contentModels from Redux, ensure it's cloned const reduxContentTypes = newMigrationData?.content_mapping?.existingCT; // Assume this gets your Redux state const reduxGlobalFields = newMigrationData?.content_mapping?.existingGlobal @@ -1018,7 +1014,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: setIsLoading(true); try { - const { data } = await getContentTypes(projectId || '', 0, 5000, searchContentType || ''); //org id will always present + const { data } = await getContentTypes(projectId || '', 0, 5000, searchContentType || '', 'new'); //org id will always present setIsLoading(false); setContentTypes(data?.contentTypes); @@ -1031,6 +1027,10 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: fetchFields(data?.contentTypes?.[0]?.id, searchText || ''); setOtherCmsUid(data?.contentTypes?.[0]?.otherCmsUid); setIsContentType(data?.contentTypes?.[0]?.type === "content_type"); + + // Report whether step 3 has any content types so the header can gate the Continue button: + // empty on iteration 1 = error (disable), empty on iteration 2+ = no new types (allow continue). + dispatch(updateNewMigrationData({ hasNoContentTypes: !(data?.contentTypes?.length > 0) })); } catch (error) { console.error(error); return error; @@ -1042,7 +1042,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: setSearchContentType(searchCT); try { - const { data } = await getContentTypes(projectId, 0, 1000, searchCT || ''); //org id will always present + const { data } = await getContentTypes(projectId, 0, 1000, searchCT || '', 'new'); //org id will always present setContentTypes(data?.contentTypes); setFilteredContentTypes(data?.contentTypes); @@ -1128,19 +1128,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: } }; - // Method to change the content type - /** - * Reflect entry-update selection on the content type's status icon: - * has selected entries → 'Updated' (status '2', green); none → 'Mapped' (status '1', blue). - */ - const handleEntrySelectionStatusChange = (contentTypeId: string, hasSelection: boolean) => { - const nextStatus = hasSelection ? '2' : '1'; - const applyStatus = (list: ContentType[]) => - list?.map?.((ct) => (ct?.id === contentTypeId ? { ...ct, status: nextStatus } : ct)); - setContentTypes((prev) => applyStatus(prev)); - setFilteredContentTypes((prev) => applyStatus(prev)); - }; - const handleOpenContentType = (i = 0) => { if (isDropDownChanged) { setIsModalOpen(true); @@ -1678,7 +1665,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: !(data?.contentstackFieldType === 'single_line_text' || data?.contentstackFieldType === 'multi_line_text' || data?.contentstackFieldType === 'html' || data?.contentstackFieldType === 'json') || data?.otherCmsType === undefined || - newMigrationData?.project_current_step > 4 + newMigrationData?.project_current_step > 3 } /> @@ -1697,12 +1684,12 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: disabled={ data?.otherCmsField === 'title' || data?.otherCmsField === 'url' || - newMigrationData?.project_current_step > 4 + newMigrationData?.project_current_step > 3 } > @@ -2589,7 +2576,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: maxWidth="290px" isClearable={isTypeMatch && selectedOptions?.includes?.(existingField?.[data?.backupFieldUid]?.label ?? '')} options={adjustedOptions} - isDisabled={OptionValue?.isDisabled || newMigrationData?.project_current_step > 4} + isDisabled={OptionValue?.isDisabled || newMigrationData?.project_current_step > 3} /> @@ -2610,7 +2597,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: > - } + ) + })} version="v2" testId="no-results-found-page" />} diff --git a/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx b/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx index 4e21d0393..441c86cc2 100644 --- a/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx +++ b/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx @@ -185,7 +185,7 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => )}
- +
) @@ -209,7 +209,7 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => )}
- +
) : ( diff --git a/ui/src/components/MigrationFlowHeader/index.tsx b/ui/src/components/MigrationFlowHeader/index.tsx index f715fb2f4..c8da1dcc2 100644 --- a/ui/src/components/MigrationFlowHeader/index.tsx +++ b/ui/src/components/MigrationFlowHeader/index.tsx @@ -47,6 +47,20 @@ const MigrationFlowHeader = ({ const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const dispatch = useDispatch(); + // Delta migration: the "Map Entry" step only exists from iteration 2 onwards, which shifts the + // step numbers for Test Migration and Execute Migration. Resolve the semantic step ids by + // iteration so all stepId checks stay correct for both the 5-step (iter 1) and 6-step flows. + const iteration = newMigrationData?.iteration ?? 1; + const isDeltaIteration = iteration > 1; + const TEST_MIGRATION_STEP = isDeltaIteration ? '5' : '4'; + const EXECUTE_MIGRATION_STEP = isDeltaIteration ? '6' : '5'; + // Mapping steps that show a plain "Continue" CTA: Map Content Fields (3) always, plus + // Map Entry (4) and Test Migration on delta iterations. + const isMappingContinueStep = + params?.stepId === '3' || + (isDeltaIteration && (params?.stepId === '4' || params?.stepId === '5')) || + (!isDeltaIteration && params?.stepId === '4'); + useEffect(() => { fetchProject(); }, [selectedOrganisation?.value, params?.projectId]); @@ -67,12 +81,18 @@ const MigrationFlowHeader = ({ useEffect(() => { let newStepValue; - // Check conditions in priority order - if (newMigrationData?.legacy_cms?.projectStatus === 5 && newMigrationData?.migration_execution?.migrationCompleted) { + // Check conditions in priority order. + // "Restart Migration" only applies on the final Execute step once a migration has completed — + // not while navigating back through earlier (completed) steps in a delta iteration. + if ( + params?.stepId === EXECUTE_MIGRATION_STEP && + newMigrationData?.legacy_cms?.projectStatus === 5 && + newMigrationData?.migration_execution?.migrationCompleted + ) { newStepValue = 'Restart Migration'; - } else if (params?.stepId === '5') { + } else if (params?.stepId === EXECUTE_MIGRATION_STEP) { newStepValue = 'Start Migration'; - } else if (params?.stepId === '3' || params?.stepId === '4') { + } else if (isMappingContinueStep) { newStepValue = 'Continue'; } else { newStepValue = 'Save and Continue'; @@ -85,7 +105,7 @@ const MigrationFlowHeader = ({ }, [params?.stepId, newMigrationData?.legacy_cms?.projectStatus, newMigrationData?.migration_execution?.migrationCompleted, newMigrationData?.stepValue, dispatch]); const isStep4AndNotMigrated = - params?.stepId === '4' && + params?.stepId === TEST_MIGRATION_STEP && !newMigrationData?.testStacks?.some( (stack) => stack?.stackUid === newMigrationData?.test_migration?.stack_api_key && stack?.isMigrated @@ -115,13 +135,32 @@ const MigrationFlowHeader = ({ (finalExecutionStarted || newMigrationData?.migration_execution?.migrationStarted) && !newMigrationData?.migration_execution?.migrationCompleted; + // Disable the Start Migration button while the start request is in flight / after a successful + // start (driven by the local finalExecutionStarted flag from the click handler, NOT by the + // migration-completed flag — so the live logs still show while migration runs). + const isStartMigrationDisabled = + params?.stepId === EXECUTE_MIGRATION_STEP && + !!finalExecutionStarted && + newMigrationData?.stepValue !== 'Restart Migration'; + const destinationStackMigrated = - params?.stepId === '5' && + params?.stepId === EXECUTE_MIGRATION_STEP && newMigrationData?.destination_stack?.migratedStacks?.includes( newMigrationData?.destination_stack?.selectedStack?.value ); const isFileValidated = newMigrationData?.isContentMapperGenerated ? true : newMigrationData?.legacy_cms?.uploadedFile?.reValidate; + // Map Content Fields (step 3) empty-state handling: + // - Iteration 1 with no content types = genuine error → keep Continue disabled. + // - Iteration 2+ with no NEW content types = valid (nothing new to map) → Continue stays enabled. + // ContentMapper reports emptiness via hasNoContentTypes (its local fetch result), which is more + // accurate than isContentMapperGenerated (the project's mapper-id array can be non-empty while the + // resolved content-type list is empty). + const isContentMapperEmptyOnFirstIteration = + params?.stepId === '3' && + !isDeltaIteration && + newMigrationData?.hasNoContentTypes === true; + return (
@@ -141,6 +180,8 @@ const MigrationFlowHeader = ({ isLoading={isLoading || newMigrationData?.isprojectMapped} disabled={ isMigrationInProgress || + isStartMigrationDisabled || + isContentMapperEmptyOnFirstIteration || (isProjectStatusThreeAndMapperNotGenerated ? isFileValidated : isStep4AndNotMigrated || diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index d7a3ec90e..f87baa281 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -105,28 +105,39 @@ const HorizontalStepper = forwardRef( if (!Number.isNaN(stepIndex) && stepIndex >= 0 && stepIndex < steps?.length) { !newMigrationDataRef?.current?.isprojectMapped && setShowStep(stepIndex); setStepsCompleted((prev) => { - // Drop any completed steps at or beyond the current step so a restart - // (navigating back to an earlier step) un-fills the connectors ahead of it. - // Steps before the current one remain completed. - const updatedStepsCompleted = prev?.filter((i) => i < stepIndex); + // Completion is driven by the project's persisted current step, NOT by which step is + // currently being viewed. This lets the user navigate back to review an earlier step + // without un-filling the connectors for steps they've already completed. + // current_step is 1-based, so steps with index < (current_step - 1) are completed. + const currentStepNumber = + newMigrationData?.project_current_step ?? props?.projectData?.current_step ?? 1; + + // Restart: when the project resets to step 1, all prior progress is cleared, so the + // accumulated completed-steps must reset too (don't carry the previous run's filled bar). + const previousCompleted = currentStepNumber <= 1 ? [] : (prev ?? []); + + // Furthest progress = max of the persisted current step and the step being viewed + // (so navigating forward also fills as expected). + const completedThrough = Math.max(currentStepNumber - 1, stepIndex); + const completed: number[] = []; + for (let i = 0; i < completedThrough; i++) { + completed.push(i); + } + + // Last step (Execute Migration) gets marked complete only once migration finishes. + const lastStepIndex = (steps?.length ?? 1) - 1; if ( - stepIndex === 4 && + stepIndex === lastStepIndex && (props?.projectData?.isMigrationCompleted || newMigrationData?.migration_execution?.migrationCompleted) ) { - if (!updatedStepsCompleted?.includes(4)) { - updatedStepsCompleted.push(4); - } + completed.push(lastStepIndex); } - for (let i = 0; i < stepIndex; i++) { - if (!updatedStepsCompleted?.includes(i)) { - updatedStepsCompleted?.push(i); - } - } - return updatedStepsCompleted; + + return Array.from(new Set([...previousCompleted, ...completed])); }); } - }, [stepId, newMigrationData?.migration_execution?.migrationCompleted]); + }, [stepId, newMigrationData?.migration_execution?.migrationCompleted, newMigrationData?.project_current_step]); useImperativeHandle(ref, () => ({ handleStepChange: (currentStep: number) => { diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index dc34b0c00..9015a2cdd 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -218,6 +218,9 @@ export interface INewMigration { settings:ISetting; iteration: number; stepValue?: string; + // True when the Map Content Fields step (step 3) has loaded but returned zero content types. + // Used to gate the step-3 Continue button: disabled on iteration 1 (error), enabled on iteration 2+. + hasNoContentTypes?: boolean; } export interface TestStacks { diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 35ba132ff..649b25e21 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -58,6 +58,7 @@ import HorizontalStepper from '../../components/Stepper/HorizontalStepper/Horizo import LegacyCms from '../../components/LegacyCms'; import DestinationStackComponent from '../../components/DestinationStack'; import ContentMapper from '../../components/ContentMapper'; +import EntryMapper from '../../components/ContentMapper/entryMapper'; import TestMigration from '../../components/TestMigration'; import MigrationExecution from '../../components/MigrationExecution'; import SaveChangesModal from '../../components/Common/SaveChangesModal'; @@ -209,14 +210,27 @@ const Migration = () => { return; } + // Delta migration: the "Map Entry" flow step only exists from iteration 2 onwards. + // migrationSteps.json statically lists the 6-step (delta) layout, so on iteration 1 we + // drop the Map Entry step and renumber the steps after it so the side-nav names stay in + // sync with the 5-step createStepper() flow. + const iteration = newMigrationData?.iteration ?? 1; + const allFlowSteps: IFlowStep[] = validateArray(data?.all_steps) + ? iteration > 1 + ? data?.all_steps + : data?.all_steps + ?.filter((step: IFlowStep) => step?.flow_id !== 'mapEntry') + ?.map((step: IFlowStep, index: number) => ({ ...step, name: `${index + 1}` })) + : data?.all_steps; + //get Flow Steps and update it in APP Context - const currentFlowStep = validateArray(data?.all_steps) - ? data?.all_steps?.find((step: IFlowStep) => `${step.name}` === params?.stepId) + const currentFlowStep = validateArray(allFlowSteps) + ? allFlowSteps?.find((step: IFlowStep) => `${step.name}` === params?.stepId) : DEFAULT_IFLOWSTEP; dispatch( updateMigrationData({ - allFlowSteps: data?.all_steps, + allFlowSteps: allFlowSteps, currentFlowStep: currentFlowStep, migration_steps_heading: data?.migration_steps_heading, settings: data?.settings @@ -224,7 +238,7 @@ const Migration = () => { ); await fetchProjectData(); - const stepIndex = data?.all_steps?.findIndex( + const stepIndex = allFlowSteps?.findIndex( (step: IFlowStep) => `${step?.name}` === params?.stepId ); setCurrentStepIndex(stepIndex !== -1 ? stepIndex : 0); @@ -485,6 +499,11 @@ const Migration = () => { projectData: MigrationResponse, handleStepChange: (currentStep: number) => void ) => { + // Delta migration: the "Map Entry" step (and the 6-step flow) only exists from iteration 2 + // onwards. Iteration 1 is the original 5-step flow with no entry mapping. + const iteration = projectData?.iteration ?? newMigrationData?.iteration ?? 1; + const isDeltaIteration = iteration > 1; + const steps = [ { data: ( @@ -514,14 +533,24 @@ const Migration = () => { id: '3', title: 'Map Content Fields' }, + // Map Entry only from iteration 2+ + ...(isDeltaIteration + ? [ + { + data: , + id: '4', + title: 'Map Entry' + } + ] + : []), { data: , - id: '4', + id: isDeltaIteration ? '5' : '4', title: 'Run Test Migration' }, { data: , - id: '5', + id: isDeltaIteration ? '6' : '5', title: 'Execute Migration' } ]; @@ -787,7 +816,21 @@ const Migration = () => { }; /** - * Calls when click Continue button on Test Migration step and handles to proceed to Migration Execution + * Calls when click Continue button on Map Entry step (delta iteration only) and handles to + * proceed to Test Migration. Map Entry is step 4 (index 3), Test Migration is step 5 (index 4). + */ + const handleOnClickMapEntry = async () => { + setIsLoading(false); + await updateCurrentStepData(selectedOrganisation.value, projectId); + handleStepChange(4); + const url = `/projects/${projectId}/migration/steps/5`; + navigate(url, { replace: true }); + }; + + /** + * Calls when click Continue button on Test Migration step and handles to proceed to Migration Execution. + * Delta migration: Test Migration is step 5 / index 4 on iteration 2+ (Execute is step 6 / index 5), + * but step 4 / index 3 on iteration 1 (Execute is step 5 / index 4). */ const handleOnClickTestMigration = async () => { setIsLoading(false); @@ -796,8 +839,10 @@ const Migration = () => { const res = await updateCurrentStepData(selectedOrganisation.value, projectId); //if (res?.status === 200) { - handleStepChange(4); - const url = `/projects/${projectId}/migration/steps/5`; + const isDeltaIteration = (newMigrationData?.iteration ?? 1) > 1; + const executeStepIndex = isDeltaIteration ? 5 : 4; + handleStepChange(executeStepIndex); + const url = `/projects/${projectId}/migration/steps/${executeStepIndex + 1}`; navigate(url, { replace: true }); //} }; @@ -809,6 +854,9 @@ const Migration = () => { setIsLoading(true); if (newMigrationData?.stepValue !== 'Restart Migration') { + // Disable the Start Migration button immediately on click; keep it disabled on success + // (migration is now running) and only re-enable it if the API call fails. + setDisableMigration(true); try { const migrationRes = await startMigration( newMigrationData?.destination_stack?.selectedOrg?.value, @@ -816,7 +864,6 @@ const Migration = () => { ); if (migrationRes?.status === 200) { - setDisableMigration(true); const newMigrationDataObj: INewMigration = { ...newMigrationData, migration_execution: { @@ -835,6 +882,7 @@ const Migration = () => { type: 'message' }); } else { + setDisableMigration(false); Notification({ notificationContent: { text: migrationRes?.data?.error?.message || 'Failed to start migration' @@ -844,6 +892,7 @@ const Migration = () => { } } catch (error) { console.error(error); + setDisableMigration(false); Notification({ notificationContent: { text: 'Failed to start migration' }, type: 'error' @@ -913,10 +962,15 @@ const Migration = () => { dispatch(updateNewMigrationData(newMigrationDataObj)); }; + // CTA handlers indexed by step position. The Map Entry step (and its handler) only exists from + // iteration 2 onwards, so it is inserted between Content Mapper and Test Migration only then — + // keeping this array aligned with the iteration-aware createStepper() flow. + const isDeltaIteration = (newMigrationData?.iteration ?? 1) > 1; const handleOnClickFunctions = [ handleOnClickLegacyCms, handleOnClickDestinationStack, handleOnClickContentMapper, + ...(isDeltaIteration ? [handleOnClickMapEntry] : []), handleOnClickTestMigration, handleOnClickMigrationExecution ]; diff --git a/ui/src/services/api/migration.service.ts b/ui/src/services/api/migration.service.ts index 27d662265..2e50ca9d0 100644 --- a/ui/src/services/api/migration.service.ts +++ b/ui/src/services/api/migration.service.ts @@ -113,12 +113,17 @@ export const getContentTypes = ( projectId: string, skip: number, limit: number, - searchText: string + searchText: string, + filter?: 'new' | 'old' ) => { try { const encodedSearchText = encodeURIComponent(searchText); + // Delta migration (iteration > 1): 'new' → first-time content types for Step 3 (field + // mapping), 'old' → already-migrated content types for Step 4 (entry mapping). Ignored on + // iteration 1 by the backend. + const filterQuery = filter ? `&filter=${filter}` : ''; return getCall( - `${API_VERSION}/mapper/contentTypes/${projectId}/${skip}/${limit}/${encodedSearchText}?`, + `${API_VERSION}/mapper/contentTypes/${projectId}/${skip}/${limit}/${encodedSearchText}?${filterQuery}`, options() ); } catch (error) { diff --git a/ui/src/services/api/service.interface.ts b/ui/src/services/api/service.interface.ts index f615b4b36..6db750b29 100644 --- a/ui/src/services/api/service.interface.ts +++ b/ui/src/services/api/service.interface.ts @@ -37,6 +37,7 @@ export interface MigrationResponse { isMigrationStarted: boolean; isMigrationCompleted: boolean; migration_execution: boolean; + iteration?: number; } export interface LegacyCms { diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 04050d38d..b2460f6de 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -197,3 +197,15 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } + +// Content Mapper (Map Content Fields step) empty-state text. +// Delta migration (iteration > 1): an empty list means there are no NEW content types — valid, +// the user can continue. Iteration 1: an empty list means content-mapper generation failed. +export const CONTENT_MAPPER_EMPTY_STATE = { + NO_NEW_CONTENT_TYPES_HEADING: 'No new content types', + NO_NEW_CONTENT_TYPES_DESCRIPTION: + 'There are no new content types in this file. You can still continue.', + NO_CONTENT_TYPES_HEADING: 'No Content Types available', + NO_CONTENT_TYPES_DESCRIPTION: + 'There is something error occured while generating content mapper. Please go to Legacy Cms step and validate the file again.' +} From 2a17eb79bded736a7c40bca7c0b4abaf8049b558 Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Sat, 13 Jun 2026 11:25:59 +0530 Subject: [PATCH 2/3] feat: Add unit tests for updateEntryCli service with mocked dependencies --- .../services/updateEntryCli.service.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 api/tests/unit/services/updateEntryCli.service.test.ts diff --git a/api/tests/unit/services/updateEntryCli.service.test.ts b/api/tests/unit/services/updateEntryCli.service.test.ts new file mode 100644 index 000000000..f1acefc6d --- /dev/null +++ b/api/tests/unit/services/updateEntryCli.service.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +const { + mockSpawn, + mockAppendFileSync, + mockAuthRead, + mockAuthChainGet, + mockSetLogFilePath, + mockSetOAuthConfig, + mockSetBasicAuthConfig, +} = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockAppendFileSync: vi.fn(), + mockAuthRead: vi.fn(), + mockAuthChainGet: vi.fn(), + mockSetLogFilePath: vi.fn(), + mockSetOAuthConfig: vi.fn(), + mockSetBasicAuthConfig: vi.fn(), +})); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), +})); + +vi.mock('fs', () => ({ + default: { appendFileSync: mockAppendFileSync }, + appendFileSync: mockAppendFileSync, +})); + +vi.mock('../../../src/models/authentication.js', () => ({ + default: { + read: mockAuthRead, + chain: { get: mockAuthChainGet }, + }, +})); + +vi.mock('../../../src/server.js', () => ({ + setLogFilePath: mockSetLogFilePath, +})); + +vi.mock('../../../src/utils/config-handler.util.js', () => ({ + setOAuthConfig: mockSetOAuthConfig, + setBasicAuthConfig: mockSetBasicAuthConfig, +})); + +import { updateEntryCli, utilsUpdateCli } from '../../../src/services/updateEntryCli.service.js'; + +/** + * Builds a fake child process whose close event fires with the given exit code + * on the next tick, optionally emitting stdout/stderr data first. + */ +const makeChild = (exitCode = 0, stdout = '', stderr = '') => { + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + queueMicrotask(() => { + if (stdout) child.stdout.emit('data', Buffer.from(stdout)); + if (stderr) child.stderr.emit('data', Buffer.from(stderr)); + child.emit('close', exitCode); + }); + return child; +}; + +const setUser = (user: any) => { + mockAuthChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(user) }), + }); +}; + +describe('updateEntryCli.service', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthRead.mockResolvedValue(undefined); + mockSetLogFilePath.mockResolvedValue(undefined); + setUser({ email: 'u@x.com', authtoken: 'tok', region: 'NA', user_id: 'u1' }); + mockSpawn.mockImplementation(() => makeChild(0)); + }); + + it('exposes updateEntryCli via the utilsUpdateCli object', () => { + expect(utilsUpdateCli.updateEntryCli).toBe(updateEntryCli); + }); + + it('runs both CLI commands and logs success for a basic-auth user', async () => { + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + // config:set:region + cm:stacks:migration = 2 spawned commands + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSetBasicAuthConfig).toHaveBeenCalled(); + expect(mockSetOAuthConfig).not.toHaveBeenCalled(); + // a success completion line is written + const written = mockAppendFileSync.mock.calls.map((c) => String(c[1])); + expect(written.some((l) => l.includes('Entry Update Process Completed'))).toBe(true); + }); + + it('uses OAuth config when the user has an access token', async () => { + setUser({ email: 'u@x.com', access_token: 'oauth', region: 'NA', user_id: 'u1' }); + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + expect(mockSetOAuthConfig).toHaveBeenCalled(); + expect(mockSetBasicAuthConfig).not.toHaveBeenCalled(); + }); + + it('logs and returns early when stack_api_key is missing (no migration command)', async () => { + await updateEntryCli('NA', 'u1', '', '/tmp/log', '/tmp/config.json'); + + // only the first command (config:set:region) runs; migration is skipped + expect(mockSpawn).toHaveBeenCalledTimes(1); + const written = mockAppendFileSync.mock.calls.map((c) => String(c[1])); + expect(written.some((l) => l.includes('stack API key missing'))).toBe(true); + }); + + it('catches errors when the user has no authentication token', async () => { + setUser({ email: 'u@x.com', region: 'NA', user_id: 'u1' }); // no authtoken/access_token + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + // first command runs, then the missing-auth throw is caught and logged + const written = mockAppendFileSync.mock.calls.map((c) => String(c[1])); + expect(written.some((l) => l.includes('Failed to update entries'))).toBe(true); + }); + + it('classifies stdout/stderr output and writes log entries with correct levels', async () => { + mockSpawn + .mockImplementationOnce(() => makeChild(0, 'Operation failed: not found\n', 'boom\n')) + .mockImplementationOnce(() => makeChild(0, 'all good\n')); + + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + const entries = mockAppendFileSync.mock.calls + .map((c) => { + try { + return JSON.parse(String(c[1]).trim()); + } catch { + return null; + } + }) + .filter(Boolean); + + // stdout containing "failed"/"not found" -> error level + expect(entries.some((e) => e.level === 'error' && e.message.includes('Operation failed'))).toBe(true); + // stderr always logged at error level + expect(entries.some((e) => e.level === 'error' && e.message === 'boom')).toBe(true); + // benign stdout -> info level + expect(entries.some((e) => e.level === 'info' && e.message === 'all good')).toBe(true); + }); + + it('rejects/handles a non-zero exit code from a spawned command', async () => { + mockSpawn.mockImplementationOnce(() => makeChild(1)); // first command fails + + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + const entries = mockAppendFileSync.mock.calls + .map((c) => { + try { + return JSON.parse(String(c[1]).trim()); + } catch { + return null; + } + }) + .filter(Boolean); + expect(entries.some((e) => e.message?.includes('Command failed with exit code 1'))).toBe(true); + // the failed command bubbles into the catch block + const written = mockAppendFileSync.mock.calls.map((c) => String(c[1])); + expect(written.some((l) => l.includes('Failed to update entries'))).toBe(true); + }); + + it('maps a warning in stdout to warn level', async () => { + mockSpawn + .mockImplementationOnce(() => makeChild(0, 'this is a warning message\n')) + .mockImplementationOnce(() => makeChild(0)); + + await updateEntryCli('NA', 'u1', 'stackKey', '/tmp/log', '/tmp/config.json'); + + const entries = mockAppendFileSync.mock.calls + .map((c) => { + try { + return JSON.parse(String(c[1]).trim()); + } catch { + return null; + } + }) + .filter(Boolean); + expect(entries.some((e) => e.level === 'warn')).toBe(true); + }); +}); \ No newline at end of file From 34ac4b14454d5c8419bdfaace579201af80db1ec Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Sat, 13 Jun 2026 11:51:30 +0530 Subject: [PATCH 3/3] chore: bump fast-uri to >=3.1.2 to resolve high-severity SCA SLA breach --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8091adec5..5bdec6cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1441,9 +1441,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.0.0.tgz", + "integrity": "sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 0355eea8a..f646760dd 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "tar": ">=7.5.8", "@tootallnate/once": ">=3.0.1", "fast-xml-parser": ">=5.3.8", - "diff": ">=5.2.2" + "diff": ">=5.2.2", + "fast-uri": ">=3.1.2" }, "husky": { "hooks": {