diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 0298c6780..784e4dacd 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -50,7 +50,7 @@ import { import { aemService } from './aem.service.js'; import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; import { utilsUpdateCli } from './updateEntryCli.service.js'; -import { enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; +import { clearStaleEntries, enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; import { removeExistingAssets, saveAssetMetadata } from '../utils/asset-update.utils.js'; /** @@ -427,6 +427,20 @@ const startTestMigration = async (req: Request): Promise => { }; await copyLogsToTestStack(project?.current_test_stack_id, loggerPath); + // Clear any stale entries from a previous run before re-transforming, so orphaned + // chunk files cannot clobber this run's entry data during the update step. + clearStaleEntries(project?.current_test_stack_id, loggerPath); + // fieldAttacher uses destinationStackId as a path segment when writing content-type + // files. Confirm the sanitized stack id resolves inside the migration-data base before + // passing it in, so request-derived input cannot escape via path traversal. + const testMigrationDataBase = path.resolve( + process.cwd(), + MIGRATION_DATA_CONFIG.DATA + ); + assertResolvedPathUnderBase( + testMigrationDataBase, + path.join(testMigrationDataBase, safeTestStackId) + ); const contentTypes = await fieldAttacher({ orgId, projectId: safeTestProjectId, @@ -849,6 +863,22 @@ const startMigration = async (req: Request): Promise => { await copyLogsToStack(project?.destination_stack_id, loggerPath); + // Clear any stale entries from a previous run before re-transforming, so orphaned + // chunk files cannot clobber this run's entry data during the update step. + clearStaleEntries(project?.destination_stack_id, loggerPath); + + // fieldAttacher uses destinationStackId as a path segment when writing content-type + // files. Confirm the sanitized stack id resolves inside the migration-data base before + // passing it in, so request-derived input cannot escape via path traversal. + const finalMigrationDataBase = path.resolve( + process.cwd(), + MIGRATION_DATA_CONFIG.DATA + ); + assertResolvedPathUnderBase( + finalMigrationDataBase, + path.join(finalMigrationDataBase, safeFinalStackId) + ); + const contentTypes = await fieldAttacher({ orgId, projectId: safeFinalProjectId, diff --git a/api/src/services/runCli.service.ts b/api/src/services/runCli.service.ts index dbb9dc3b1..c814117f8 100644 --- a/api/src/services/runCli.service.ts +++ b/api/src/services/runCli.service.ts @@ -19,8 +19,7 @@ interface TestStack { isMigrated: boolean; } import { setBasicAuthConfig, setOAuthConfig } from '../utils/config-handler.util.js'; -import getUidMapperDb from '../models/uidMapper.js'; -import customLogger from '../utils/custom-logger.utils.js'; +import writeUidMapping from '../utils/uid-mapper.utils.js'; /** * Determines log level based on message content without removing ANSI codes diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 2677bba82..489fc4c1b 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -3,6 +3,7 @@ import ProjectModelLowdb from "../models/project-lowdb.js"; import path from "path"; import fs from "node:fs"; import { MIGRATION_DATA_CONFIG, DATABASE_FILES } from "../constants/index.js"; +import { sanitizeStackId, assertResolvedPathUnderBase } from "./sanitize-path.utils.js"; /** * Helper function to write log entries to file @@ -19,6 +20,38 @@ const writeLogEntry = (message: string, methodName: string, loggerPath?: string) } }; +/** + * Deletes the transformed entries tree for a stack before a fresh import. + * + * Each import run writes entry chunk files with fresh random UUID names and overwrites + * index.json, but never removes the previous run's chunk files. Those orphans carry + * stale (previous-iteration) content that can later clobber current data during update. + * Wiping the entries tree up front guarantees the importer starts from a clean slate. + * + * Scope is limited to the `entries/` subtree only — assets, references, environments, + * locales and content-type creation (driven by the lowdb mappers, not this folder) are + * untouched. + */ +export const clearStaleEntries = (stackId: string, loggerPath?: string): void => { + const safeStackId = sanitizeStackId(stackId); + if (!safeStackId) { + writeLogEntry(`Invalid stackId, skipping stale entries cleanup.`, "clearStaleEntries", loggerPath); + return; + } + + const dataBase = path.resolve(process.cwd(), MIGRATION_DATA_CONFIG.DATA); + const entriesDir = path.join(dataBase, safeStackId, MIGRATION_DATA_CONFIG.ENTRIES_DIR_NAME); + assertResolvedPathUnderBase(dataBase, entriesDir); + + if (!fs.existsSync(entriesDir)) { + writeLogEntry(`No existing entries directory to clear: ${entriesDir}`, "clearStaleEntries", loggerPath); + return; + } + + fs.rmSync(entriesDir, { recursive: true, force: true }); + writeLogEntry(`Cleared stale entries directory before import: ${entriesDir}`, "clearStaleEntries", loggerPath); +}; + export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: string): Promise => { const entriesToUpdate: Record> = {}; @@ -72,8 +105,36 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: for (const localeDir of localeDirs) { const localePath = path.join(ctPath, localeDir.name); - const jsonFiles = fs.readdirSync(localePath) - ?.filter((file) => file?.endsWith(".json") && file !== "index.json"); + // Respect index.json — only the chunk files it lists are current. Each import run + // writes chunk files with fresh random UUID names and overwrites index.json, but does + // not delete prior runs' chunk files. Globbing all *.json would pick up those orphans, + // whose stale (previous-iteration) content can then clobber the current entry data. + const indexPath = path.join(localePath, MIGRATION_DATA_CONFIG.ENTRIES_MASTER_FILE); + let jsonFiles: string[]; + if (fs.existsSync(indexPath)) { + // Guard against a missing/corrupt/non-object index.json — a parse failure or + // unexpected shape here would otherwise throw and abort the entire removal step. + let indexData: unknown; + try { + indexData = JSON.parse(fs.readFileSync(indexPath, "utf-8")); + } catch (err) { + writeLogEntry(`Failed to parse index.json at ${indexPath}, skipping locale: ${(err as Error)?.message}`, "removeEntriesFromDatabase", loggerPath); + continue; + } + if (!indexData || typeof indexData !== "object") { + writeLogEntry(`index.json at ${indexPath} is not an object, skipping locale.`, "removeEntriesFromDatabase", loggerPath); + continue; + } + jsonFiles = Object.values(indexData as Record) + // Sanitize to path.basename — index values are trusted verbatim otherwise, + // so an unexpected value could introduce extra path segments. + .filter((file): file is string => typeof file === "string" && file.endsWith(".json")) + .map((file) => path.basename(file)); + } else { + // Legacy data without an index.json — fall back to globbing. + jsonFiles = fs.readdirSync(localePath) + ?.filter((file) => file?.endsWith(".json") && file !== MIGRATION_DATA_CONFIG.ENTRIES_MASTER_FILE); + } for (const jsonFile of jsonFiles) { const filePath = path.join(localePath, jsonFile); diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index cd77dc392..236ed65b1 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -3,13 +3,21 @@ import getContentTypesMapperDb from "../models/contentTypesMapper-lowdb.js"; import getFieldMapperDb from "../models/FieldMapper.js"; import { contenTypeMaker } from "./content-type-creator.utils.js"; import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js"; -import { sanitizeProjectId } from "./sanitize-path.utils.js"; +import { sanitizeProjectId, sanitizeStackId } from "./sanitize-path.utils.js"; +import customLogger from "./custom-logger.utils.js"; export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => { const safeProjectId = sanitizeProjectId(projectId); if (!safeProjectId) { throw new Error("Invalid project identifier"); } + // Re-sanitize the destination stack id here as well: it is used as a path segment + // downstream (contenTypeMaker -> writeFile), so it must be validated at the sink's + // entry point to break any path-traversal taint chain regardless of the caller. + const safeDestinationStackId = sanitizeStackId(destinationStackId); + if (!safeDestinationStackId) { + throw new Error("Invalid destination stack identifier"); + } await ProjectModelLowdb.read(); const projectData: any = ProjectModelLowdb.chain.get("projects").find({ id: safeProjectId, @@ -38,16 +46,16 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi } if (iteration === 1) { - await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { const shouldSkip = await shouldSkipContentTypeCreation(safeProjectId, contentType?.otherCmsUid, iteration); if (!shouldSkip) { - console.info(`Creating new content type: ${contentType.otherCmsUid}`); - await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + await customLogger(safeProjectId, safeDestinationStackId, 'info', `Creating new content type: ${contentType.otherCmsUid}`); + await contenTypeMaker({ contentType, destinationStackId: safeDestinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { - console.info(`Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`); + await customLogger(safeProjectId, safeDestinationStackId, 'info', `Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`); } } contentTypes?.push?.(contentType); diff --git a/api/tests/unit/utils/entry-update.utils.test.ts b/api/tests/unit/utils/entry-update.utils.test.ts index dc9e39ed9..31ed59996 100644 --- a/api/tests/unit/utils/entry-update.utils.test.ts +++ b/api/tests/unit/utils/entry-update.utils.test.ts @@ -11,6 +11,9 @@ const { mockMkdirSync, mockReaddirSync, mockAppendFileSync, + mockRmSync, + mockSanitizeStackId, + mockAssertResolvedPathUnderBase, } = vi.hoisted(() => ({ mockProjectRead: vi.fn(), mockChainGet: vi.fn(), @@ -22,6 +25,9 @@ const { mockMkdirSync: vi.fn(), mockReaddirSync: vi.fn(), mockAppendFileSync: vi.fn(), + mockRmSync: vi.fn(), + mockSanitizeStackId: vi.fn(), + mockAssertResolvedPathUnderBase: vi.fn(), })); vi.mock('../../../src/models/project-lowdb.js', () => ({ @@ -46,9 +52,16 @@ vi.mock('node:fs', () => ({ mkdirSync: mockMkdirSync, appendFileSync: mockAppendFileSync, readdirSync: mockReaddirSync, + rmSync: mockRmSync, }, })); +vi.mock('../../../src/utils/sanitize-path.utils.js', () => ({ + sanitizeStackId: mockSanitizeStackId, + assertResolvedPathUnderBase: mockAssertResolvedPathUnderBase, + getSafePath: (p: string) => p, +})); + describe('entry-update.utils', () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,6 +81,52 @@ describe('entry-update.utils', () => { { otherCmsEntryUid: 'legacy-key', isUpdate: true, contentstackEntryUid: 'cs-uid' }, ], }); + mockSanitizeStackId.mockImplementation((id: string) => id); + mockAssertResolvedPathUnderBase.mockReturnValue(undefined); + }); + + describe('clearStaleEntries', () => { + it('skips cleanup and logs when stackId is invalid', async () => { + mockSanitizeStackId.mockReturnValue(''); + const { clearStaleEntries } = await import('../../../src/utils/entry-update.utils.js'); + + clearStaleEntries('../../evil', '/tmp/mig.log'); + + expect(mockRmSync).not.toHaveBeenCalled(); + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/mig.log', + expect.stringContaining('Invalid stackId') + ); + }); + + it('does nothing when entries directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const { clearStaleEntries } = await import('../../../src/utils/entry-update.utils.js'); + + clearStaleEntries('stack1', '/tmp/mig.log'); + + expect(mockRmSync).not.toHaveBeenCalled(); + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/mig.log', + expect.stringContaining('No existing entries directory') + ); + }); + + it('removes the entries directory when it exists', async () => { + mockExistsSync.mockReturnValue(true); + const { clearStaleEntries } = await import('../../../src/utils/entry-update.utils.js'); + + clearStaleEntries('stack1', '/tmp/mig.log'); + + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining('entries'), + { recursive: true, force: true } + ); + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/mig.log', + expect.stringContaining('Cleared stale entries directory') + ); + }); }); it('removeEntriesFromDatabase returns null when stackId missing', async () => { @@ -93,15 +152,20 @@ describe('entry-update.utils', () => { }); it('removeEntriesFromDatabase walks dirs, updates json, writes config', async () => { - mockExistsSync.mockReturnValue(true); + // entriesDir + content-type/locale dirs exist, but no index.json → glob fallback. + mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('index.json')); const dirent = (name: string, isDir: boolean) => ({ name, isDirectory: () => isDir, }); - mockReaddirSync - .mockReturnValueOnce([dirent('ct1', true)]) - .mockReturnValueOnce([dirent('en', true)]) - .mockReturnValueOnce(['page.json']); + // Branch on the options arg instead of queued returns so the once-queue can't + // leak into a later test. + mockReaddirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => { + if (opts?.withFileTypes) { + return String(p).endsWith('ct1') ? [dirent('en', true)] : [dirent('ct1', true)]; + } + return ['page.json']; + }); mockReadFileSync.mockReturnValue( JSON.stringify({ 'legacy-key': { title: 'Hello' }, keep: { x: 1 } }) ); @@ -114,6 +178,128 @@ describe('entry-update.utils', () => { expect(mockMkdirSync).toHaveBeenCalled(); }); + describe('removeEntriesFromDatabase index.json-driven chunk selection', () => { + const dirent = (name: string, isDir: boolean) => ({ + name, + isDirectory: () => isDir, + }); + + it('only processes chunk files listed in index.json (ignores stale orphans)', async () => { + // entriesDir exists + index.json exists + the listed chunk exists + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => + opts?.withFileTypes + ? (String(p).endsWith('ct1') ? [dirent('en', true)] : [dirent('ct1', true)]) + : [] + ); + // index.json lists only current.json; orphan.json is NOT listed and must be ignored + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('index.json')) { + return JSON.stringify({ '1': 'current.json' }); + } + return JSON.stringify({ 'legacy-key': { title: 'Hello' } }); + }); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + const result = await removeEntriesFromDatabase('p1', '/tmp/mig.log'); + + expect(result).toMatch(/updated-entries\.json$/); + // The chunk read must be the index-listed file, never the orphan. + const readPaths = mockReadFileSync.mock.calls.map((c) => String(c[0])); + expect(readPaths.some((p) => p.endsWith('current.json'))).toBe(true); + expect(readPaths.some((p) => p.endsWith('orphan.json'))).toBe(false); + }); + + it('skips the locale and logs when index.json is corrupt', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => + opts?.withFileTypes + ? (String(p).endsWith('ct1') ? [dirent('en', true)] : [dirent('ct1', true)]) + : [] + ); + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('index.json')) return '{ not valid json'; + return JSON.stringify({ 'legacy-key': { title: 'Hello' } }); + }); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + const result = await removeEntriesFromDatabase('p1', '/tmp/mig.log'); + + // Whole step must not abort; config still written, locale skipped. + expect(result).toMatch(/updated-entries\.json$/); + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/mig.log', + expect.stringContaining('Failed to parse index.json') + ); + // No chunk file was read because the locale was skipped. + const readPaths = mockReadFileSync.mock.calls.map((c) => String(c[0])); + expect(readPaths.some((p) => p.endsWith('current.json'))).toBe(false); + }); + + it('skips the locale and logs when index.json is not an object', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => + opts?.withFileTypes + ? (String(p).endsWith('ct1') ? [dirent('en', true)] : [dirent('ct1', true)]) + : [] + ); + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('index.json')) return JSON.stringify('a string, not an object'); + return JSON.stringify({ 'legacy-key': { title: 'Hello' } }); + }); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + const result = await removeEntriesFromDatabase('p1', '/tmp/mig.log'); + + expect(result).toMatch(/updated-entries\.json$/); + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/mig.log', + expect.stringContaining('is not an object') + ); + }); + + // readdirSync is called two ways: `{ withFileTypes: true }` for the dir walks + // (content-type dirs, then locale dirs) and with no options for the glob fallback. + // Branch on the options arg so call ordering can't desync the mocks. + const dirWalkImpl = (globResult: string[] = ['page.json', 'index.json']) => + (p: string, opts?: { withFileTypes?: boolean }) => { + if (opts?.withFileTypes) { + return String(p).endsWith('ct1') ? [dirent('en', true)] : [dirent('ct1', true)]; + } + return globResult; + }; + + it('falls back to globbing when index.json is absent (legacy data)', async () => { + // entriesDir exists, but index.json does not → glob the locale dir + mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('index.json')); + mockReaddirSync.mockImplementation(dirWalkImpl(['page.json', 'index.json'])); + mockReadFileSync.mockReturnValue(JSON.stringify({ 'legacy-key': { title: 'Hello' } })); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + const result = await removeEntriesFromDatabase('p1', '/tmp/mig.log'); + + expect(result).toMatch(/updated-entries\.json$/); + const readPaths = mockReadFileSync.mock.calls.map((c) => String(c[0])); + // index.json itself is filtered out of the glob; only page.json is read. + expect(readPaths.some((p) => p.endsWith('page.json'))).toBe(true); + }); + + it('throws when an index-listed chunk file does not exist on disk', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockImplementation(dirWalkImpl()); + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('index.json')) return JSON.stringify({ '1': 'ghost.json' }); + // Reading the listed-but-missing chunk fails like a real fs.readFileSync ENOENT + const err: NodeJS.ErrnoException = new Error('ENOENT: no such file'); + err.code = 'ENOENT'; + throw err; + }); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + await expect(removeEntriesFromDatabase('p1', '/tmp/mig.log')).rejects.toThrow(/ENOENT/); + }); + }); + it('enrichConfigWithAssetMapping covers iteration 1 (no old path branch)', async () => { mockExistsSync.mockReturnValue(false); const { enrichConfigWithAssetMapping } = await import('../../../src/utils/entry-update.utils.js'); diff --git a/api/tests/unit/utils/uid-mapper.utils.test.ts b/api/tests/unit/utils/uid-mapper.utils.test.ts new file mode 100644 index 000000000..d4c2623e8 --- /dev/null +++ b/api/tests/unit/utils/uid-mapper.utils.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockProjectRead, + mockChainGet, + mockExistsSync, + mockReadFileSync, + mockUidRead, + mockUidWrite, + mockGetUidMapperDb, + mockCustomLogger, +} = vi.hoisted(() => ({ + mockProjectRead: vi.fn(), + mockChainGet: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockUidRead: vi.fn(), + mockUidWrite: vi.fn(), + mockGetUidMapperDb: vi.fn(), + mockCustomLogger: vi.fn(), +})); + +vi.mock('../../../src/models/project-lowdb.js', () => ({ + default: { + read: mockProjectRead, + chain: { get: mockChainGet }, + }, +})); + +vi.mock('../../../src/models/uidMapper.js', () => ({ + default: mockGetUidMapperDb, +})); + +vi.mock('../../../src/utils/custom-logger.utils.js', () => ({ + default: mockCustomLogger, +})); + +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }, +})); + +import writeUidMapping from '../../../src/utils/uid-mapper.utils'; + +describe('uid-mapper.utils - writeUidMapping', () => { + const uidDb = { + read: mockUidRead, + write: mockUidWrite, + data: {} as Record, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockProjectRead.mockResolvedValue(undefined); + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue({ + id: 'p1', + destination_stack_id: 'stack1', + }), + }), + }); + mockCustomLogger.mockResolvedValue(undefined); + mockUidRead.mockResolvedValue(undefined); + mockUidWrite.mockResolvedValue(undefined); + mockGetUidMapperDb.mockReturnValue(uidDb); + }); + + it('writes combined asset and entry mapping when both files have data', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('assets')) return JSON.stringify({ a1: 'asset-uid' }); + return JSON.stringify({ e1: 'entry-uid' }); + }); + + await writeUidMapping('/backup', 'p1', 1); + + expect(mockGetUidMapperDb).toHaveBeenCalledWith('p1', 1); + expect(uidDb.data).toEqual({ + assets: { a1: 'asset-uid' }, + entry: { e1: 'entry-uid' }, + }); + expect(mockUidWrite).toHaveBeenCalled(); + }); + + it('does not write when entry mapper file is missing', async () => { + // asset file exists with data, entry file does not exist + mockExistsSync.mockImplementation((p: string) => p.includes('assets')); + mockReadFileSync.mockReturnValue(JSON.stringify({ a1: 'asset-uid' })); + + await writeUidMapping('/backup', 'p1', 1); + + expect(mockUidWrite).not.toHaveBeenCalled(); + }); + + it('falls back to previous iteration for assets when current asset data is empty', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('mapper') && p.includes('assets')) return JSON.stringify({}); + if (p.includes('database')) return JSON.stringify({ assets: { prev: 'a' } }); + return JSON.stringify({ e1: 'entry-uid' }); + }); + + await writeUidMapping('/backup', 'p1', 2); + + expect(uidDb.data).toMatchObject({ assets: { prev: 'a' } }); + expect(mockUidWrite).toHaveBeenCalled(); + }); + + it('falls back to previous iteration for entries when current entry data is empty', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (p.includes('mapper') && p.includes('assets')) return JSON.stringify({ a1: 'x' }); + if (p.includes('mapper') && p.includes('entries')) return JSON.stringify({}); + if (p.includes('database')) return JSON.stringify({ entry: { prevE: 'e' } }); + return JSON.stringify({}); + }); + + await writeUidMapping('/backup', 'p1', 2); + + expect(uidDb.data).toMatchObject({ entry: { prevE: 'e' } }); + expect(mockUidWrite).toHaveBeenCalled(); + }); + + it('handles missing project data gracefully (undefined destination stack)', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(undefined), + }), + }); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ k: 'v' })); + + await expect(writeUidMapping('/backup', 'p1', 1)).resolves.toBeUndefined(); + expect(mockUidWrite).toHaveBeenCalled(); + }); + + it('catches and logs errors without throwing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation(() => { + throw new Error('boom'); + }); + + await expect(writeUidMapping('/backup', 'p1', 1)).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error writing UID mapping file:', + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 6c299da5e..61a0cfe8f 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -11,7 +11,7 @@ interface Props { closeModal: () => void; isopen?: (flag: boolean) => void; otherCmsTitle?: string; - saveContentType?: () => void; + saveContentType?: () => void | Promise; openContentType?: () => void; changeStep?: () => void | Promise; dropdownStateChange: () => void; @@ -61,7 +61,7 @@ const SaveChangesModal = (props: Props) => { version={'v2'} onClick={async () => { props?.dropdownStateChange(); - await Promise.resolve(props?.saveContentType?.()); + await props?.saveContentType?.(); props.closeModal(); props.openContentType?.(); await props?.changeStep?.(); diff --git a/ui/src/components/ContentMapper/contentMapper.interface.ts b/ui/src/components/ContentMapper/contentMapper.interface.ts index f60e85e24..23a52ac58 100644 --- a/ui/src/components/ContentMapper/contentMapper.interface.ts +++ b/ui/src/components/ContentMapper/contentMapper.interface.ts @@ -191,8 +191,6 @@ export interface ContentTypeMap { export interface ContentTypeSaveHandles { handleSaveContentType: () => void; - handleUpdateAutoMappedContentMapping: () => Promise; - shouldPromptShowAutoMappedMerge: () => boolean; } export type MouseOrKeyboardEvent = | React.MouseEvent diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 23d281c6c..c79bcfaf7 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -129,64 +129,6 @@ fill: $color-brand-draft-base; } } - -.ct-status-cluster { - align-items: center; - column-gap: $space-6; - display: flex; - flex-shrink: 0; - - .tippy-wrapper { - align-items: center; - display: inline-flex; - line-height: 1; - } -} - -/* Venus Pills wrapper: Contentstack draft accent, align with list row */ -.ct-auto-map-pill-wrap { - align-items: center; - cursor: default; - display: inline-flex; - max-width: 11rem; - background-color: $color-brand-draft-light; - stroke: $color-placeholder; - - /*no focus ring or Venus pill click/focus styling */ - .ct-auto-map-pill.Pill, - .ct-auto-map-pill .PillItem { - box-shadow: none !important; - outline: none !important; - pointer-events: none; - } - - .ct-auto-map-pill.Pill:focus, - .ct-auto-map-pill.Pill:focus-visible, - .ct-auto-map-pill .PillItem:focus, - .ct-auto-map-pill .PillItem:focus-visible { - box-shadow: none !important; - outline: none !important; - } - - .ct-auto-map-pill.Pill { - cursor: default; - min-height: 0; - padding: 0; - } - - .ct-auto-map-pill .PillItem { - max-width: 100%; - } - - .ct-auto-map-pill .PillItem__text-content { - color: $color-brand-draft-base; - font-weight: $font-weight-medium; - } -} - -.auto-mapped-filter-icon { - color: $color-brand-draft-base; -} .dropdown-align { .Dropdown__menu--primary { right: 0; @@ -561,8 +503,38 @@ div .table-row { } } -.select { - .tippy-wrapper { - display: inline; +.entry-mapper-container { + .Table { + + // Force row layout alignment + .Table__head, + .Table__body__row { + display: flex !important; + width: 100%; + } + + // Keep the row-select (checkbox) cell at its fixed width and never shrink it + .Table-select-head, + .Table-select-body { + flex: 0 0 68px !important; + width: 68px !important; + min-width: 68px !important; + } + + // Target actual column containers + .Table__head__column, + .Table__body__column { + flex: 1 1 0 !important; + min-width: 0 !important; + display: flex !important; + align-items: center; + border-right: 1px solid $color-brand-secondary-lightest !important; + } + + // Remove last column border + .Table__head__column:last-child, + .Table__body__column:last-child { + 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 7c79657d1..0c2872be6 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -21,8 +21,7 @@ import { InstructionText, CircularLoader, EmptyState, - OutlineTag, - Pills + OutlineTag } from '@contentstack/venus-components'; // Services @@ -87,7 +86,6 @@ import { import './index.scss'; import { NoDataFound, SCHEMA_PREVIEW } from '../../common/assets'; import EntryMapper from './entryMapper'; -import { AUTO_MAPPED_PILL_ITEMS } from '../../utilities/constants'; const FIELD_MAP_MENU_VIEW_MARGIN = 8; const FIELD_MAP_MENU_HYSTERESIS = 36; @@ -192,112 +190,6 @@ function FieldMappingSelect(props: ISelectProps) { const rowHistoryObj: FieldHistoryObj = {} - -/** - * Picks the destination content type/global field for a source row: use saved mapping first, - * otherwise when a destination model has the same uid as the source contentstackUid. - */ -function resolveDestinationModelForSource( - sourceUid: string, - models: ContentTypeList[], - contentTypeMapping: Record | undefined, - suppressedUidAutoMatch?: ReadonlySet -): ContentTypeList | undefined { - if (!sourceUid || !models?.length) return undefined; - const mapping = contentTypeMapping ?? {}; - const mappedDestUid = mapping?.[sourceUid]; - const byExplicitMap = models?.find((item) => item?.uid === mappedDestUid); - if (byExplicitMap?.uid) return byExplicitMap; - - if (suppressedUidAutoMatch?.has(sourceUid)) return undefined; - - const uidMatch = models?.find((item) => item?.uid === sourceUid); - if (!uidMatch?.uid) return undefined; - - const takenByAnotherSource = Object?.entries(mapping)?.some( - ([src, destUid]) => destUid === uidMatch?.uid && src !== sourceUid - ); - return takenByAnotherSource ? undefined : uidMatch; -} - - -/** - * True when this row uses same-UID mapping to the destination stack (auto-map semantics). - * Shows after mapper_keys sync too: saved `sourceUid -> sourceUid` must still count as auto-mapped. - */ -function isContentTypeAutoMapped( - sourceUid: string | undefined, - models: ContentTypeList[], - contentTypeMapping: Record | undefined, - suppressedUidAutoMatch?: ReadonlySet -): boolean { - if (!sourceUid || !models?.length) return false; - if (suppressedUidAutoMatch?.has(sourceUid)) return false; - const mapping = contentTypeMapping ?? {}; - const uidMatch = models?.find((item) => item?.uid === sourceUid); - if (!uidMatch?.uid) return false; - - const takenByAnotherSource = Object?.entries(mapping)?.some( - ([src, destUid]) => destUid === uidMatch?.uid && src !== sourceUid - ); - if (takenByAnotherSource) return false; - - const mappedDestUid = mapping?.[sourceUid]; - if (!mappedDestUid) return true; - if (mappedDestUid === sourceUid) return true; - if (models?.some((m) => m?.uid === mappedDestUid)) return false; - return true; -} - -/** Destination stack models for a sidebar row (content type vs global field). */ -function getDestinationModelsForRow( - ct: ContentType, - existingCT: ContentTypeList[], - existingGlobal: ContentTypeList[] -): ContentTypeList[] { - return ct?.type === 'content_type' ? existingCT : existingGlobal; -} - -/** Mapper state may store arrays or getter fns; normalize for runtime + TS. */ -function asContentTypeListArray(value: unknown): ContentTypeList[] { - if (value == null) return []; - if (typeof value === 'function') { - const out = (value as () => ContentTypeList[] | undefined)(); - return out ?? []; - } - return Array?.isArray(value) ? (value as ContentTypeList[]) : []; -} - -/** Merge saved mapping with each row’s resolved destination (explicit or UID auto-map). */ -function buildContentTypeMappingWithAutoMap( - contentTypesList: ContentType[], - existingCT: ContentTypeList[] | undefined, - existingGlobal: ContentTypeList[] | undefined, - baseMapping: Record, - suppressedUidAutoMatch?: ReadonlySet -): Record { - const result: Record = { ...baseMapping }; - if (!contentTypesList?.length) return result; - - for (const ct of contentTypesList) { - const sourceUid = ct?.contentstackUid; - if (!sourceUid) continue; - const models = - ct?.type === 'content_type' ? existingCT ?? [] : existingGlobal ?? []; - if (!models?.length) continue; - const resolved = resolveDestinationModelForSource( - sourceUid, - models ?? [], - result, - suppressedUidAutoMatch - ); - if (resolved?.uid) { - result[sourceUid] = resolved?.uid; - } - } - return result; -} - const Fields: MappingFields = { 'single_line_text': { label: 'Single Line Textbox', @@ -679,11 +571,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const [isResetFetch, setIsResetFetch] = useState(false); const [iterationCount, setIterationCount] = useState(newMigrationData?.iteration); - /** After reset-to-initial-mapping, do not re-apply UID auto-match until user picks a destination again. */ - const [uidAutoMapSuppressedForSourceUids, setUidAutoMapSuppressedForSourceUids] = useState>( - () => new Set() - ); - /** ALL HOOKS Here */ const { projectId = '' } = useParams(); const navigate = useNavigate(); @@ -739,16 +626,10 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: useEffect(() => { const selectedSourceUid = selectedContentType?.contentstackUid || ''; - const combinedMapping: Record = { - ...newMigrationData?.content_mapping?.content_type_mapping, - ...contentTypeMapped - }; - const mappedContentType = resolveDestinationModelForSource( - selectedSourceUid, - contentModels ?? [], - combinedMapping, - uidAutoMapSuppressedForSourceUids - ); + const mappedDestinationUid = + contentTypeMapped?.[selectedSourceUid] ?? + newMigrationData?.content_mapping?.content_type_mapping?.[selectedSourceUid]; + const mappedContentType = contentModels?.find((item) => item?.uid === mappedDestinationUid); if (mappedContentType?.uid) { setOtherContentType((prev) => { @@ -760,20 +641,12 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: }; }); setIsContentDeleted(false); - } else if ( - selectedSourceUid && - uidAutoMapSuppressedForSourceUids?.has(selectedSourceUid) - ) { - const placeholder = `Select ${isContentType ? 'Content Type' : 'Global Field'} from Destination Stack`; - setOtherContentType({ label: placeholder, value: placeholder }); } }, [ contentTypeMapped, contentModels, selectedContentType?.contentstackUid, - newMigrationData?.content_mapping?.content_type_mapping, - uidAutoMapSuppressedForSourceUids, - isContentType + newMigrationData?.content_mapping?.content_type_mapping ]); useEffect(() => { @@ -1295,35 +1168,18 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: setIsFieldDeleted(false); setActive(i); const otherTitle = filteredContentTypes?.[i]?.contentstackUid; - const combinedMapping: Record = { - ...newMigrationData?.content_mapping?.content_type_mapping, - ...contentTypeMapped - }; - const mappedContentType = resolveDestinationModelForSource( - otherTitle, - contentModels ?? [], - combinedMapping, - uidAutoMapSuppressedForSourceUids - ); - const placeholder = `Select ${filteredContentTypes?.[i]?.type === "content_type" ? 'Content Type' : 'Global Field'} from Destination Stack`; + const mappedContentType = contentModels?.find((item) => item?.uid === newMigrationData?.content_mapping?.content_type_mapping?.[otherTitle]); setOtherCmsTitle(filteredContentTypes?.[i]?.otherCmsTitle); setContentTypeUid(filteredContentTypes?.[i]?.id ?? ''); fetchFields(filteredContentTypes?.[i]?.id ?? '', searchText || ''); setOtherCmsUid(filteredContentTypes?.[i]?.otherCmsUid); setSelectedContentType(filteredContentTypes?.[i]); setIsContentType(filteredContentTypes?.[i]?.type === "content_type"); - setOtherContentType( - mappedContentType?.uid - ? { - id: mappedContentType?.uid, - label: mappedContentType?.title, - value: mappedContentType?.title - } - : { - label: placeholder, - value: placeholder - } - ); + setOtherContentType({ + label: mappedContentType?.title ?? `Select ${filteredContentTypes?.[i]?.type === "content_type" ? 'Content Type' : 'Global Field'} from Destination Stack`, + value: mappedContentType?.title ?? `Select ${filteredContentTypes?.[i]?.type === "content_type" ? 'Content Type' : 'Global Field'} from Destination Stack`, + + }); } const updateFieldSettings = (rowId: string, updatedSettings: Advanced, checkBoxChanged: boolean, rowContentstackFieldUid: string) => { @@ -1743,15 +1599,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: setIsAllCheck(false); setOtherContentType(value); - const srcUid = selectedContentType?.contentstackUid; - if (srcUid) { - setUidAutoMapSuppressedForSourceUids((prev) => { - if (!prev?.has(srcUid)) return prev; - const next = new Set(prev); - next?.delete(srcUid); - return next; - }); - } }; const handleAdvancedSetting = (fieldtype: string, fieldvalue: UpdatedSettings, rowId: string, data: FieldMapType) => { @@ -2820,15 +2667,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: }); setIsDropDownChanged(false); if (otherContentType?.id) { - const savedSrcUid = selectedContentType?.contentstackUid; - if (savedSrcUid) { - setUidAutoMapSuppressedForSourceUids((prev) => { - if (!prev?.has(savedSrcUid)) return prev; - const next = new Set(prev); - next?.delete(savedSrcUid); - return next; - }); - } const newMigrationDataObj: INewMigration = { ...newMigrationData, content_mapping: { @@ -2916,111 +2754,9 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: dispatch(updateNewMigrationData((dropdownChangeState))); } - const handleUpdateAutoMappedContentMapping = useCallback(async () => { - const isNewStack = - newMigrationData?.stackDetails?.isNewStack ?? - newMigrationData?.destination_stack?.selectedStack?.isNewStack; - if (isNewStack) return; - - const orgId = selectedOrganisation?.uid; - if (!orgId || !projectId || !contentTypes?.length) return; - - const existingCT = asContentTypeListArray(newMigrationData?.content_mapping?.existingCT); - const existingGlobal = asContentTypeListArray(newMigrationData?.content_mapping?.existingGlobal); - const hasCtModels = validateArray(existingCT); - const hasGfModels = validateArray(existingGlobal); - if (!hasCtModels && !hasGfModels) return; - - const baseMapping: Record = { - ...(newMigrationData?.content_mapping?.content_type_mapping ?? {}), - ...contentTypeMapped - }; - const merged = buildContentTypeMappingWithAutoMap( - contentTypes, - existingCT, - existingGlobal, - baseMapping, - uidAutoMapSuppressedForSourceUids - ); - - const unchanged = - Object?.keys(merged)?.length === Object?.keys(baseMapping)?.length && - Object?.entries(merged)?.every(([key, val]) => baseMapping?.[key] === val); - if (unchanged) return; - - await updateContentMapper(orgId, projectId, merged); - setContentTypeMapped(merged); - dispatch( - updateNewMigrationData({ - ...newMigrationData, - content_mapping: { - ...newMigrationData?.content_mapping, - content_type_mapping: merged - } - }) - ); - }, [ - contentTypeMapped, - contentTypes, - dispatch, - newMigrationData, - projectId, - selectedOrganisation?.uid, - uidAutoMapSuppressedForSourceUids - ]); - - /** - * Open confirm modal if any same-UID auto-mappable row is not yet reflected in mapper state. - * Example: 2 auto-mapped CTs, only 1 saved → still prompt. All saved (base === merged for each) → no prompt. - */ - const shouldPromptShowAutoMappedMerge = useCallback((): boolean => { - if (isNewStack) return false; - if (!contentTypes?.length) return false; - const existingCT = asContentTypeListArray(newMigrationData?.content_mapping?.existingCT); - const existingGlobal = asContentTypeListArray(newMigrationData?.content_mapping?.existingGlobal); - if (!validateArray(existingCT) && !validateArray(existingGlobal)) { - return false; - } - - const baseMapping: Record = { - ...(newMigrationData?.content_mapping?.content_type_mapping ?? {}), - ...contentTypeMapped - }; - - const merged = buildContentTypeMappingWithAutoMap( - contentTypes, - existingCT, - existingGlobal, - { ...baseMapping }, - uidAutoMapSuppressedForSourceUids - ); - - for (const ct of contentTypes) { - const suid = ct?.contentstackUid; - if (!suid) continue; - const models = getDestinationModelsForRow(ct, existingCT, existingGlobal); - if (!models?.length) continue; - if (!isContentTypeAutoMapped(suid, models, baseMapping, uidAutoMapSuppressedForSourceUids)) { - continue; - } - if (baseMapping?.[suid] !== merged[suid]) return true; - } - return false; - }, [ - isNewStack, - contentTypes, - contentTypeMapped, - newMigrationData?.content_mapping?.content_type_mapping, - newMigrationData?.content_mapping?.existingCT, - newMigrationData?.content_mapping?.existingGlobal, - uidAutoMapSuppressedForSourceUids - ]); - useImperativeHandle(ref, () => ({ handleSaveContentType, - handleDropdownState, - handleUpdateAutoMappedContentMapping, - shouldPromptShowAutoMappedMerge + handleDropdownState })); const handleResetContentType = debounce(async () => { @@ -3102,14 +2838,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: }; dispatch(updateNewMigrationData(newMigrationDataObj)); - const resetSourceUid = selectedContentType?.contentstackUid; - if (resetSourceUid) { - setUidAutoMapSuppressedForSourceUids((prev) => { - const next = new Set(prev); - next?.add(resetSourceUid); - return next; - }); - } const resetCT = filteredContentTypes?.map?.(ct => ct?.id === selectedContentType?.id ? { ...ct, status: data?.data?.status } : ct ) @@ -3455,34 +3183,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: }); } - const combinedContentTypeMapping: Record = { - ...newMigrationData?.content_mapping?.content_type_mapping, - ...contentTypeMapped - }; - - /** Destinations already assigned to another source row (saved mapping or UID auto-map). */ - const destinationUidsClaimedByOtherSources = (() => { - const currentUid = selectedContentType?.contentstackUid; - const claimed = new Set(); - if (!currentUid || !contentTypes?.length) return claimed; - const existingCT = asContentTypeListArray(newMigrationData?.content_mapping?.existingCT); - const existingGlobal = asContentTypeListArray(newMigrationData?.content_mapping?.existingGlobal); - for (const ct of contentTypes) { - const sourceUid = ct?.contentstackUid; - if (!sourceUid || sourceUid === currentUid) continue; - const models = getDestinationModelsForRow(ct, existingCT, existingGlobal); - if (!models?.length) continue; - const resolved = resolveDestinationModelForSource( - sourceUid, - models, - combinedContentTypeMapping, - uidAutoMapSuppressedForSourceUids - ); - if (resolved?.uid) claimed?.add(resolved?.uid); - } - return claimed; - })(); - const isDestinationMappedByAnotherSource = (destinationUid: string | undefined) => !!destinationUid && !!contentTypeMapped && @@ -3524,22 +3224,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: (e?.target as HTMLElement)?.closest('li')?.classList?.add('active-filter'); - const autoMappedLabel = CONTENT_MAPPING_STATUS['5']; - const existingCT = asContentTypeListArray(newMigrationData?.content_mapping?.existingCT); - const existingGlobal = asContentTypeListArray(newMigrationData?.content_mapping?.existingGlobal); - const filteredCT = contentTypes?.filter((ct) => { - if (value === autoMappedLabel) { - if (isNewStack) return false; - const rowModels = getDestinationModelsForRow(ct, existingCT, existingGlobal); - return isContentTypeAutoMapped( - ct?.contentstackUid, - rowModels, - combinedContentTypeMapping, - uidAutoMapSuppressedForSourceUids - ); - } - return CONTENT_MAPPING_STATUS?.[ct?.status] === value; - }); + const filteredCT = contentTypes?.filter((ct) => { return CONTENT_MAPPING_STATUS[ct?.status] === value }); if (value !== 'All') { setFilteredContentTypes(filteredCT); setCount(filteredCT?.length); @@ -3621,9 +3306,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: {showFilter && (
    - {Object.keys(CONTENT_MAPPING_STATUS) - ?.filter((key) => key !== '5' || !isNewStack) - ?.map?.((key, keyInd) => ( + {Object.keys(CONTENT_MAPPING_STATUS)?.map?.((key, keyInd) => (
  • ))} @@ -3663,34 +3333,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
      {filteredContentTypes?.map?.((content: ContentType, index: number) => { const icon = STATUS_ICON_Mapping[content?.status] || ''; - const existingCTSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingCT - ); - const existingGlobalSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingGlobal - ); - let rowDestinationModels: ContentTypeList[] = getDestinationModelsForRow( - content, - existingCTSide, - existingGlobalSide - ); - if ( - !rowDestinationModels?.length && - contentModels?.length && - selectedContentType?.contentstackUid === content?.contentstackUid && - ((content?.type === 'content_type' && isContentType) || - (content?.type !== 'content_type' && !isContentType)) - ) { - rowDestinationModels = contentModels; - } - const showAutoMappedBadge = - !isNewStack && - isContentTypeAutoMapped( - content?.contentstackUid, - rowDestinationModels, - combinedContentTypeMapping, - uidAutoMapSuppressedForSourceUids - ); const format = (str: string) => { const frags = str?.split('_'); @@ -3728,38 +3370,14 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
- - {showAutoMappedBadge && ( - - e.preventDefault()} - > - - - - )} + {icon && ( )} - + diff --git a/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx b/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx index 391886ab7..c9d524fbd 100644 --- a/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx +++ b/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx @@ -92,6 +92,7 @@ const LoadFileFormat = (_props: LoadFileFormatProps) => { aria-label="File format icon" /> } + disabled={true} />
diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index 48c4e6ba7..dc34b0c00 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -191,8 +191,8 @@ export interface IDestinationStack { csLocale: string[]; } export interface IContentMapper { - existingGlobal: ContentTypeList[]; - existingCT: ContentTypeList[]; + existingGlobal: ContentTypeList[] | (() => ContentTypeList[]); + existingCT: ContentTypeList[] | (() => ContentTypeList[]); content_type_mapping: ContentTypeMap; isDropDownChanged?: boolean; otherCmsTitle?: string; diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 3cfbc47a1..5658e2e0c 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -750,22 +750,6 @@ const Migration = () => { * Calls when click Continue button on Content Mapper step and handles to proceed to Test Migration */ const handleOnClickContentMapper = async (event: MouseEvent) => { - const persistAutoMappedContentMapper = async (): Promise => { - try { - await saveRef?.current?.handleUpdateAutoMappedContentMapping?.(); - return true; - } catch { - Notification({ - notificationContent: { - text: 'Could not save content type mapping. Please try again.' - }, - notificationProps: { position: 'bottom-center', hideProgressBar: true }, - type: 'error' - }); - return false; - } - }; - if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); @@ -777,7 +761,6 @@ const Migration = () => { otherCmsTitle={newMigrationData?.content_mapping?.otherCmsTitle} saveContentType={saveRef?.current?.handleSaveContentType} changeStep={async () => { - if (!(await persistAutoMappedContentMapper())) return; const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); @@ -793,35 +776,12 @@ const Migration = () => { } }); } else { - const finishContentMapperNavigation = async () => { - if (!(await persistAutoMappedContentMapper())) return; - await updateCurrentStepData(selectedOrganisation.value, projectId); - setIsLoading(false); - event?.preventDefault?.(); - handleStepChange(3); - const url = `/projects/${projectId}/migration/steps/4`; - navigate(url, { replace: true }); - }; - - if (saveRef?.current?.shouldPromptShowAutoMappedMerge?.()) { - return cbModal({ - component: (props: ModalObj) => ( - { - props.closeModal(); - await finishContentMapperNavigation(); - }} - /> - ), - modalProps: { - size: 'xsmall', - shouldCloseOnOverlayClick: false - } - }); - } - - await finishContentMapperNavigation(); + await updateCurrentStepData(selectedOrganisation.value, projectId); + setIsLoading(false); + event?.preventDefault?.(); + handleStepChange(3); + const url = `/projects/${projectId}/migration/steps/4`; + navigate(url, { replace: true }); } }; diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 6f831b92e..04050d38d 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -106,7 +106,6 @@ export const CONTENT_MAPPING_STATUS: ObjectType = { '2': 'Updated', '3': 'Failed', '4': 'All', - '5': 'Auto-mapped', }; export const STATUS_ICON_Mapping: { [key: string]: string } = { '1': 'CheckedCircle', @@ -198,5 +197,3 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } - -export const AUTO_MAPPED_PILL_ITEMS = [{ id: 'auto-mapped', text: 'Auto-mapped' }]; \ No newline at end of file diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index 543c7f638..7df7686bf 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -111,7 +111,6 @@ describe('utilities/constants', () => { expect(CONTENT_MAPPING_STATUS['1']).toBe('Mapped'); expect(CONTENT_MAPPING_STATUS['2']).toBe('Updated'); expect(CONTENT_MAPPING_STATUS['3']).toBe('Failed'); - expect(CONTENT_MAPPING_STATUS['5']).toBe('Auto-mapped'); expect(CONTENT_MAPPING_STATUS['4']).toBe('All'); }); diff --git a/upload-api/.env b/upload-api/.env new file mode 100644 index 000000000..d86f70a1c --- /dev/null +++ b/upload-api/.env @@ -0,0 +1,2 @@ +PORT=4002 +NODE_BACKEND_API=http://localhost:5001 \ No newline at end of file