From 4b0a43e24710d6d43a8a2d0e95ff15e081b72a7c Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 11 Sep 2026 01:30:25 -0400 Subject: [PATCH 01/11] test: add e2e coverage for the skip option in name collisions Covers single and multiple duplicates, per-item and apply-to-all skipping, non-conflicting files still uploading, and Replace trashing the matching file, with auth, bootstrap and Drive endpoints mocked so the specs run without a backend. Queued uploads are asserted through the task panel because Playwright cannot intercept the bridge in Firefox. --- .../components/TaskLogger/TaskLogger.tsx | 1 + test/e2e/tests/helper/authRouteMocks.ts | 14 ++- test/e2e/tests/helper/driveRouteMocks.ts | 86 ++++++++++++-- test/e2e/tests/helper/mockedDrive.ts | 31 ++++- test/e2e/tests/helper/staticData.ts | 7 ++ test/e2e/tests/pages/drivePage.ts | 8 ++ .../tests/pages/nameCollisionDialogPage.ts | 59 ++++++++++ ...DRIVE-internxt-name-collision-skip.spec.ts | 109 ++++++++++++++++++ test/e2e/tests/specs/internxt-login.spec.ts | 47 +------- 9 files changed, 306 insertions(+), 56 deletions(-) create mode 100644 test/e2e/tests/pages/nameCollisionDialogPage.ts create mode 100644 test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts diff --git a/src/app/tasks/components/TaskLogger/TaskLogger.tsx b/src/app/tasks/components/TaskLogger/TaskLogger.tsx index 33c3440ce2..5615b055b8 100644 --- a/src/app/tasks/components/TaskLogger/TaskLogger.tsx +++ b/src/app/tasks/components/TaskLogger/TaskLogger.tsx @@ -100,6 +100,7 @@ const TaskLogger = (): JSX.Element => { return (
json: { hasKeys: true, sKey: LOGIN_SALT_KEY, tfa: false, hasKyberKeys: true, hasEccKeys: true }, }); -const mockAccessCall = (route: Route) => - route.fulfill({ +const mockAccessCall = (route: Route, request: Request) => { + const { email } = request.postDataJSON(); + if (email === INVALID_EMAIL) { + return route.fulfill({ status: HTTP_BAD_REQUEST, json: { message: staticData.wrongLoginWarning } }); + } + + return route.fulfill({ json: { user: loggedUser.user, token: loggedUser.token, @@ -25,6 +32,7 @@ const mockAccessCall = (route: Route) => userTeam: loggedUser.userTeam, }, }); +}; const mockRefreshUserCall = (route: Route) => route.fulfill({ json: { user: loggedUser.user, newToken: loggedUser.newToken } }); diff --git a/test/e2e/tests/helper/driveRouteMocks.ts b/test/e2e/tests/helper/driveRouteMocks.ts index 69a739a024..8f83ecd59e 100644 --- a/test/e2e/tests/helper/driveRouteMocks.ts +++ b/test/e2e/tests/helper/driveRouteMocks.ts @@ -13,6 +13,7 @@ const BRIDGE_FILE_INFO_ID_PATTERN = /\/files\/([^/]+)\/info/; const FILES_LISTING_PATH = '/files/'; const START_UPLOAD_PATH = '/files/start'; const FINISH_UPLOAD_PATH = '/files/finish'; +const FILES_EXISTENCE_PATH = '/files/existence'; const HTTP_METHOD = { get: 'GET', post: 'POST', put: 'PUT' }; const HTTP_NOT_FOUND = 404; @@ -22,6 +23,7 @@ const BRIDGE_FILE_VERSION = 2; const BRIDGE_FILE_ID_BYTES = 12; const FIRST_UPLOADED_FILE_ID = 5000; const FIRST_THUMBNAIL_ID = 9000; +const EXISTING_FILE_SIZE = 1024; const ITEM_TIMESTAMP = '2026-08-01T10:00:00.000Z'; const ITEM_STATUS = 'EXISTS'; @@ -68,12 +70,21 @@ type FinishUploadRequest = { hmac?: { type: string; value: string }; }; +type ExistenceCheckRequest = { files: { plainName: string; type: string }[] }; +export type TrashRequest = { items: { uuid: string; type: string }[] }; + +/** + * Uploads only reach the bridge, and so `fileEntries`, in Chromium: Playwright cannot route + * the bridge CORS preflight in Firefox. + */ type RecordedRequests = { fileEntries: FileEntryRequest[]; thumbnailEntries: ThumbnailEntryRequest[]; downloadedFileIds: string[]; + trash: TrashRequest[]; }; export type MockedDriveOptions = { + files?: ExistingFile[]; declaredSizes?: Record; }; @@ -90,12 +101,16 @@ const buildThumbnail = (id: number, fileId: number, entry: ThumbnailEntryRequest }); type StoredThumbnail = ReturnType; -const buildFile = (id: number, { fileId, type, size, plainName, bucket, folderUuid }: FileEntryRequest) => { +const buildFile = ( + id: number, + { fileId, type, size, plainName, bucket, folderUuid }: FileEntryRequest, + uuid: string = randomUUID(), +) => { const thumbnails: StoredThumbnail[] = []; return { id, - uuid: randomUUID(), + uuid, fileId, name: plainName, plainName, @@ -112,23 +127,58 @@ const buildFile = (id: number, { fileId, type, size, plainName, bucket, folderUu }; type StoredFile = ReturnType; +export const buildExistingFile = (id: number, plainName: string, type: string) => + buildFile( + id, + { + fileId: `existing-bridge-file-${id}`, + type, + size: EXISTING_FILE_SIZE, + plainName, + bucket: loggedUser.user.bucket, + folderUuid: loggedUser.user.rootFolderId, + }, + `existing-file-uuid-${id}`, + ); +export type ExistingFile = StoredFile; + +/** + * Trashing removes files, so follow-up listings and existence checks see the new state. + */ class InMemoryDrive { - private readonly files: StoredFile[] = []; + private files: StoredFile[]; + private readonly declaredSizes: Record; + private uploadedFilesCount = 0; private thumbnailsCount = 0; - constructor(private readonly declaredSizes: Record = {}) {} + constructor({ files = [], declaredSizes = {} }: MockedDriveOptions) { + this.files = [...files]; + this.declaredSizes = declaredSizes; + } filesIn(folderUuid: string) { return this.files.filter((file) => file.folderUuid === folderUuid); } + existingFilesIn(folderUuid: string, candidates: ExistenceCheckRequest['files']) { + return this.filesIn(folderUuid).filter((existing) => + candidates.some(({ plainName, type }) => plainName === existing.plainName && type === existing.type), + ); + } + + trash(uuids: string[]) { + const trashed = new Set(uuids); + this.files = this.files.filter((file) => !trashed.has(file.uuid)); + } + findFile(uuid: string) { return this.files.find((file) => file.uuid === uuid); } addFile(entry: FileEntryRequest) { const size = this.declaredSizes[entry.plainName] ?? entry.size; - const file = buildFile(FIRST_UPLOADED_FILE_ID + this.files.length, { ...entry, size }); + const file = buildFile(FIRST_UPLOADED_FILE_ID + this.uploadedFilesCount, { ...entry, size }); + this.uploadedFilesCount += 1; this.files.push(file); return file; } @@ -210,6 +260,15 @@ const mockAppBootstrapCalls = async (page: Page) => { } }; +const fulfillExistenceCheck = (route: Route, request: Request, drive: InMemoryDrive) => { + const url = request.url(); + if (!url.endsWith(FILES_EXISTENCE_PATH)) return route.fulfill({ json: NO_DUPLICATES }); + + const { files } = request.postDataJSON() as ExistenceCheckRequest; + const existentFiles = drive.existingFilesIn(firstCapture(FOLDER_CONTENT_UUID_PATTERN, url), files); + return route.fulfill({ json: { existentFiles } }); +}; + const mockFolderContentRoutes = async (page: Page, drive: InMemoryDrive) => { const folderContentUrl = `${BASE_API_URL}/folders/content/**`; @@ -219,7 +278,9 @@ const mockFolderContentRoutes = async (page: Page, drive: InMemoryDrive) => { const files = drive.filesIn(firstCapture(FOLDER_CONTENT_UUID_PATTERN, url)); return route.fulfill({ json: isFilesListing ? { files } : { folders: [] } }); }); - await mockRouteForMethod(page, folderContentUrl, HTTP_METHOD.post, (route) => route.fulfill({ json: NO_DUPLICATES })); + await mockRouteForMethod(page, folderContentUrl, HTTP_METHOD.post, (route, request) => + fulfillExistenceCheck(route, request, drive), + ); }; const mockFileEntryRoutes = async (page: Page, drive: InMemoryDrive, requests: RecordedRequests) => { @@ -240,6 +301,14 @@ const mockFileEntryRoutes = async (page: Page, drive: InMemoryDrive, requests: R ); }; +const mockTrashRoute = (page: Page, drive: InMemoryDrive, requests: RecordedRequests) => + mockRouteForMethod(page, `${BASE_API_URL}/storage/trash/add`, HTTP_METHOD.post, (route, request) => { + const trashRequest = request.postDataJSON() as TrashRequest; + drive.trash(trashRequest.items.map((item) => item.uuid)); + requests.trash.push(trashRequest); + return route.fulfill({ json: {} }); + }); + const fulfillBridgeCall = (route: Route, request: Request, bridge: InMemoryBridge, requests: RecordedRequests) => { const url = request.url(); if (url.includes(START_UPLOAD_PATH)) return route.fulfill({ json: { uploads: [bridge.startUpload()] } }); @@ -274,13 +343,14 @@ const mockBridgeStorageRoutes = async (page: Page, requests: RecordedRequests) = }; export const mockDriveRoutes = async (page: Page, options: MockedDriveOptions = {}): Promise => { - const drive = new InMemoryDrive(options.declaredSizes); - const requests: RecordedRequests = { fileEntries: [], thumbnailEntries: [], downloadedFileIds: [] }; + const drive = new InMemoryDrive(options); + const requests: RecordedRequests = { fileEntries: [], thumbnailEntries: [], downloadedFileIds: [], trash: [] }; await mockAppBootstrapCalls(page); await mockAuthRoutes(page); await mockFolderContentRoutes(page, drive); await mockFileEntryRoutes(page, drive, requests); + await mockTrashRoute(page, drive, requests); await mockBridgeStorageRoutes(page, requests); return requests; diff --git a/test/e2e/tests/helper/mockedDrive.ts b/test/e2e/tests/helper/mockedDrive.ts index 971a3afdf8..d3d0aba49c 100644 --- a/test/e2e/tests/helper/mockedDrive.ts +++ b/test/e2e/tests/helper/mockedDrive.ts @@ -1,11 +1,36 @@ -import { Page } from '@playwright/test'; -import { DrivePage } from '../pages/drivePage'; +import { expect, Page } from '@playwright/test'; +import { DrivePage, UploadFile } from '../pages/drivePage'; +import { NameCollisionDialogPage } from '../pages/nameCollisionDialogPage'; import { logInThroughUI } from './authRouteMocks'; import { MockedDriveOptions, mockDriveRoutes } from './driveRouteMocks'; +const FIRST_FILE_LISTED_TIMEOUT = 10000; +const MIME_TYPES_BY_EXTENSION: Record = { + txt: 'text/plain', + pdf: 'application/pdf', +}; + +export const buildUploadFile = (name: string): UploadFile => ({ + name, + mimeType: MIME_TYPES_BY_EXTENSION[name.split('.').pop() ?? ''] ?? 'application/octet-stream', + buffer: Buffer.from(`content of ${name}`), +}); + +/** + * Mocks the API around the given Drive, logs in through the UI and, when the Drive has + * files, waits until the first one is listed. + */ export const openMockedDrive = async (page: Page, options: MockedDriveOptions = {}) => { const requests = await mockDriveRoutes(page, options); await logInThroughUI(page); - return { drivePage: new DrivePage(page), requests }; + const drivePage = new DrivePage(page); + const [firstFile] = options.files ?? []; + if (firstFile) { + await expect(drivePage.fileRow(`${firstFile.plainName}.${firstFile.type}`)).toBeVisible({ + timeout: FIRST_FILE_LISTED_TIMEOUT, + }); + } + + return { drivePage, collisionDialog: new NameCollisionDialogPage(page), requests }; }; diff --git a/test/e2e/tests/helper/staticData.ts b/test/e2e/tests/helper/staticData.ts index 250f04610b..c481c8c24a 100644 --- a/test/e2e/tests/helper/staticData.ts +++ b/test/e2e/tests/helper/staticData.ts @@ -47,4 +47,11 @@ export const staticData = { loadingPreviewText: 'Loading preview', downloadButtonText: 'Download', blobUrlPattern: /^blob:/, + + //NAME COLLISION DIALOG + collisionDialogTitle: 'Item already exists', + collisionReplaceOption: 'Replace current item', + collisionKeepBothOption: 'Keep both', + collisionSkipOption: 'Skip this item', + collisionApplyToAll: 'Apply this action to all duplicates', }; diff --git a/test/e2e/tests/pages/drivePage.ts b/test/e2e/tests/pages/drivePage.ts index f8d22c8c11..9398c0239e 100644 --- a/test/e2e/tests/pages/drivePage.ts +++ b/test/e2e/tests/pages/drivePage.ts @@ -201,6 +201,14 @@ export class DrivePage { await expect(thumbnail).toHaveAttribute('src', staticData.blobUrlPattern); } + fileRow(fileName: string) { + return this.page.locator(`[title="${fileName}"]`); + } + + taskItem(itemName: string) { + return this.page.locator(`[data-test="task-logger"] [title="${itemName}"]`); + } + private fileListElement(fileName: string, element: FileListElement) { return this.page.locator(`[data-test="file-list-file-${fileName}-${element}"]`); } diff --git a/test/e2e/tests/pages/nameCollisionDialogPage.ts b/test/e2e/tests/pages/nameCollisionDialogPage.ts new file mode 100644 index 0000000000..884cc4cbaf --- /dev/null +++ b/test/e2e/tests/pages/nameCollisionDialogPage.ts @@ -0,0 +1,59 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { staticData } from '../helper/staticData'; + +export class NameCollisionDialogPage { + private page: Page; + private dialog: Locator; + private title: Locator; + private options: Locator; + private applyToAllLabel: Locator; + private applyToAllCheckbox: Locator; + private submitButton: Locator; + + constructor(page: Page) { + this.page = page; + this.dialog = this.page.getByRole('dialog'); + this.title = this.dialog.getByText(staticData.collisionDialogTitle); + this.options = this.dialog.getByRole('radio'); + this.applyToAllLabel = this.dialog.getByText(staticData.collisionApplyToAll); + this.applyToAllCheckbox = this.dialog.locator('#apply-to-all'); + this.submitButton = this.dialog.getByRole('button', { name: /^(Upload|Move)$/ }); + } + + async expectOpenFor(itemName: string) { + await expect(this.title).toBeVisible({ timeout: 10000 }); + await expect(this.dialog.getByText(`${itemName} already exists in this location`)).toBeVisible(); + } + + async expectClosed() { + await expect(this.title).toBeHidden({ timeout: 10000 }); + } + + async expectOptions(optionNames: string[]) { + await expect(this.options).toHaveText(optionNames); + } + + async expectApplyToAllVisible(isVisible: boolean) { + await expect(this.applyToAllLabel).toBeVisible({ visible: isVisible }); + } + + async selectOption(optionName: string) { + await this.dialog.getByRole('radio', { name: optionName }).click(); + } + + async checkApplyToAllByClickingLabel() { + await this.applyToAllLabel.click(); + await expect(this.applyToAllCheckbox).toBeChecked(); + } + + async submit() { + await expect(this.submitButton).toBeVisible(); + await this.submitButton.click(); + } + + async resolve(itemName: string, optionName: string) { + await this.expectOpenFor(itemName); + await this.selectOption(optionName); + await this.submit(); + } +} diff --git a/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts b/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts new file mode 100644 index 0000000000..ec5e451a1e --- /dev/null +++ b/test/e2e/tests/specs/DRIVE-internxt-name-collision-skip.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { buildExistingFile } from '../helper/driveRouteMocks'; +import { buildUploadFile, openMockedDrive } from '../helper/mockedDrive'; +import { staticData } from '../helper/staticData'; + +const existingReport = buildExistingFile(1, 'report', 'txt'); +const existingInvoice = buildExistingFile(2, 'invoice', 'pdf'); + +const duplicatedReport = buildUploadFile('report.txt'); +const duplicatedInvoice = buildUploadFile('invoice.pdf'); +const newFile = buildUploadFile('brand-new.txt'); + +const allOptions = [ + staticData.collisionReplaceOption, + staticData.collisionKeepBothOption, + staticData.collisionSkipOption, +]; + +test.describe('Internxt name collision skip option', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + let drive: Awaited>; + + test.beforeEach('Logging in with existing files in Drive', async ({ page }) => { + drive = await openMockedDrive(page, { files: [existingReport, existingInvoice] }); + }); + + test('TC1: Validate that skipping a single duplicated file keeps the existing file and uploads nothing', async () => { + const { drivePage, collisionDialog, requests } = drive; + + await drivePage.uploadFiles([duplicatedReport]); + + await collisionDialog.expectOpenFor('report.txt'); + await collisionDialog.expectOptions(allOptions); + await collisionDialog.expectApplyToAllVisible(false); + + await collisionDialog.selectOption(staticData.collisionSkipOption); + await collisionDialog.submit(); + + await collisionDialog.expectClosed(); + await expect(drivePage.fileRow('report.txt')).toHaveCount(1); + expect(requests.trash).toHaveLength(0); + expect(requests.fileEntries).toHaveLength(0); + }); + + test('TC2: Validate that only the non-conflicting files are uploaded when the duplicated one is skipped', async () => { + const { drivePage, collisionDialog, requests } = drive; + + await drivePage.uploadFiles([duplicatedReport, newFile]); + + await collisionDialog.expectOpenFor('report.txt'); + await expect(drivePage.taskItem('brand-new.txt')).toBeVisible({ timeout: 10000 }); + + await collisionDialog.selectOption(staticData.collisionSkipOption); + await collisionDialog.submit(); + + await collisionDialog.expectClosed(); + expect(requests.trash).toHaveLength(0); + }); + + test('TC3: Validate that duplicated files are resolved one by one when "apply to all" is not checked', async () => { + const { drivePage, collisionDialog, requests } = drive; + + await drivePage.uploadFiles([duplicatedReport, duplicatedInvoice]); + + await collisionDialog.expectOpenFor('report.txt'); + await collisionDialog.expectApplyToAllVisible(true); + await collisionDialog.selectOption(staticData.collisionSkipOption); + await collisionDialog.submit(); + + await collisionDialog.expectOpenFor('invoice.pdf'); + await collisionDialog.expectApplyToAllVisible(false); + await collisionDialog.selectOption(staticData.collisionSkipOption); + await collisionDialog.submit(); + + await collisionDialog.expectClosed(); + expect(requests.trash).toHaveLength(0); + expect(requests.fileEntries).toHaveLength(0); + }); + + test('TC4: Validate that "apply to all" skips every duplicated file at once and closes the dialog', async () => { + const { drivePage, collisionDialog, requests } = drive; + + await drivePage.uploadFiles([duplicatedReport, duplicatedInvoice]); + + await collisionDialog.expectOpenFor('report.txt'); + await collisionDialog.checkApplyToAllByClickingLabel(); + await collisionDialog.selectOption(staticData.collisionSkipOption); + await collisionDialog.submit(); + + await collisionDialog.expectClosed(); + await expect(drivePage.fileRow('report.txt')).toHaveCount(1); + await expect(drivePage.fileRow('invoice.pdf')).toHaveCount(1); + expect(requests.trash).toHaveLength(0); + expect(requests.fileEntries).toHaveLength(0); + }); + + test('TC5: Validate that replacing a duplicated file sends its matching existing file to trash', async () => { + const { drivePage, collisionDialog, requests } = drive; + + await drivePage.uploadFiles([duplicatedInvoice]); + await collisionDialog.resolve('invoice.pdf', staticData.collisionReplaceOption); + + await expect + .poll(() => requests.trash, { timeout: 10000 }) + .toEqual([{ items: [{ uuid: existingInvoice.uuid, type: 'file' }] }]); + await collisionDialog.expectClosed(); + }); +}); diff --git a/test/e2e/tests/specs/internxt-login.spec.ts b/test/e2e/tests/specs/internxt-login.spec.ts index 21ec5fbfa8..a8f50a8e02 100644 --- a/test/e2e/tests/specs/internxt-login.spec.ts +++ b/test/e2e/tests/specs/internxt-login.spec.ts @@ -1,54 +1,17 @@ -import { expect, Request, Route, test } from '@playwright/test'; -import { getLoggedUser, getUserCredentials } from '../helper/getUser'; +import { expect, test } from '@playwright/test'; +import { INVALID_EMAIL, mockAuthRoutes } from '../helper/authRouteMocks'; +import { getUserCredentials } from '../helper/getUser'; import { staticData } from '../helper/staticData'; import { LoginPage } from '../pages/loginPage'; -const BASE_API_URL = process.env.REACT_APP_DRIVE_NEW_API_URL; const credentialsFile = getUserCredentials(); -const user = getLoggedUser(); -const invalidEmail = 'invalid@internxt.com'; - -const mockLoginCall = async (route: Route, request: Request) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasKeys: true, - sKey: '53616c7465645f5f2aa5386bc0b15f6f69a733acdd46a6551dc004f6c1cb6352390535de3ec17e9b96da7de984e5d27e79ad04a88a2cc8c6315f03dc0b0d174c', - tfa: false, - hasKyberKeys: true, - hasEccKeys: true, - }), - }); -}; - -const mockAccessCall = async (route: Route, request: Request) => { - const { email } = request.postDataJSON(); - - if (invalidEmail === email) { - return route.fulfill({ - status: 400, - body: JSON.stringify({ message: 'Wrong login credentials' }), - }); - } - - await route.fulfill({ - status: 200, - body: JSON.stringify({ - user: user.user, - token: user.token, - newToken: user.newToken, - userTeam: user.userTeam, - }), - }); -}; +const invalidEmail = INVALID_EMAIL; test.describe('internxt login', async () => { test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach('Visiting Internxt', async ({ page }) => { - await page.route(`${BASE_API_URL}/auth/login`, mockLoginCall); - await page.route(`${BASE_API_URL}/auth/login/access`, mockAccessCall); + await mockAuthRoutes(page); await page.goto('/'); await expect(page).toHaveURL('http://localhost:3000/login'); From c947fa07ecf08410ce2b4bb86c91669a7e49a97d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 10 Sep 2026 23:38:34 -0400 Subject: [PATCH 02/11] feat: skip item option in name collision Adds a Skip option to the name-collision dialog, next to Replace and Keep both, for both upload and move collisions. Duplicates are resolved one at a time, and a new "Apply this action to all duplicates" checkbox handles them all at once, closing the dialog immediately and running the work in the background. The renameModal i18n block is renamed to alreadyExistsModal (the dialog does not rename anything), the description text is operation-neutral and the apply-to-all label toggles the checkbox. --- .../NameCollisionContainer.tsx | 67 ++++++++++++--- .../components/NameCollisionDialog/index.tsx | 82 +++++++++++++------ .../nameCollision.actions.test.ts | 8 +- .../nameCollision.actions.ts | 11 ++- .../nameCollision.utils.test.ts | 53 +++++++++++- .../nameCollision.utils.ts | 26 ++++++ src/app/i18n/locales/de.json | 10 +-- src/app/i18n/locales/en.json | 10 +-- src/app/i18n/locales/es.json | 10 +-- src/app/i18n/locales/fr.json | 10 +-- src/app/i18n/locales/it.json | 10 +-- src/app/i18n/locales/ru.json | 10 +-- src/app/i18n/locales/tw.json | 10 +-- src/app/i18n/locales/zh.json | 10 +-- 14 files changed, 243 insertions(+), 84 deletions(-) diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 30cd66efc7..70aea771d7 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -7,6 +7,7 @@ import { IRoot } from 'app/store/slices/storage/types'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; import { NameCollisionContext, resolveCollision } from './nameCollision.actions'; +import { findExistingItemFor, findPendingGroupIndex, getRemainingGroups } from './nameCollision.utils'; const NameCollisionContainer: FC = () => { const dispatch = useAppDispatch(); @@ -17,6 +18,7 @@ const NameCollisionContainer: FC = () => { const operationType = collisionDialogInfo?.operation; const newItems = useMemo(() => collisionGroups.flatMap((g) => g.duplicatedItems), [collisionGroups]); const existingItems = useMemo(() => collisionGroups.flatMap((g) => g.existingItems), [collisionGroups]); + const remainingItemsCount = existingItems.length; const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const limits = useAppSelector(fileVersionsSelectors.getLimits); @@ -29,20 +31,60 @@ const NameCollisionContainer: FC = () => { dispatch(uiActions.setIsNameCollisionDialogOpen({ open: false, info: undefined })); }; - const triggerSelectedOptionsOnSubmit = async ({ operationType, operation }: OnSubmitPressed) => { - for (const group of collisionGroups) { - await resolveCollision( - { - operationType, - operation, - items: group.duplicatedItems, - existingItems: group.existingItems, - destinationUuid: group.destinationUuid, - }, - context, + const triggerSelectedOptionsOnSubmit = async ({ operationType, operation, applyToAll }: OnSubmitPressed) => { + if (applyToAll) { + closeDialog(); + await Promise.all( + collisionGroups.map((group) => + resolveCollision( + { + operationType, + operation, + items: group.duplicatedItems, + existingItems: group.existingItems, + destinationUuid: group.destinationUuid, + }, + context, + ), + ), ); + return; + } + + const groupIndex = findPendingGroupIndex(collisionGroups); + const hasPendingGroup = groupIndex !== -1; + if (!hasPendingGroup) { + closeDialog(); + return; + } + + const group = collisionGroups[groupIndex]; + const itemToUpload = group.duplicatedItems[0]; + const itemToReplace = findExistingItemFor(itemToUpload, group.existingItems); + + await resolveCollision( + { + operationType, + operation, + items: [itemToUpload], + existingItems: group.existingItems, + destinationUuid: group.destinationUuid, + }, + context, + ); + + const remainingGroups = getRemainingGroups(collisionGroups, groupIndex, itemToReplace); + const hasRemainingGroups = remainingGroups.length > 0; + if (hasRemainingGroups) { + dispatch( + uiActions.setIsNameCollisionDialogOpen({ + open: true, + info: { groups: remainingGroups, operation: operationType }, + }), + ); + } else { + closeDialog(); } - closeDialog(); }; if (!collisionDialogInfo) return null; @@ -56,6 +98,7 @@ const NameCollisionContainer: FC = () => { onSubmitButtonPressed={triggerSelectedOptionsOnSubmit} onCloseDialog={closeDialog} operationType={operationType as 'move' | 'upload'} + remainingItemsCount={remainingItemsCount} /> ); }; diff --git a/src/app/drive/components/NameCollisionDialog/index.tsx b/src/app/drive/components/NameCollisionDialog/index.tsx index 7db0c245bb..f6419a0d9d 100644 --- a/src/app/drive/components/NameCollisionDialog/index.tsx +++ b/src/app/drive/components/NameCollisionDialog/index.tsx @@ -1,7 +1,7 @@ import { FC, useEffect, useMemo, useState } from 'react'; import { Label, Radio, RadioGroup } from '@headlessui/react'; -import { Button, Modal } from '@internxt/ui'; +import { Button, Checkbox, Modal } from '@internxt/ui'; import { DriveItemData } from 'app/drive/types'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { IRoot } from 'app/store/slices/storage/types'; @@ -13,9 +13,10 @@ export const OPERATION_TYPE = { export type OnSubmitPressed = { operationType: 'move' | 'upload'; - operation: 'keep' | 'replace'; + operation: 'keep' | 'replace' | 'skip'; itemsToUpload: (File | IRoot | DriveItemData)[]; itemsToReplace: (DriveItemData | IRoot)[]; + applyToAll: boolean; }; export interface NameCollisionDialogProps { @@ -23,9 +24,16 @@ export interface NameCollisionDialogProps { isOpen: boolean; driveItems: (DriveItemData | IRoot)[]; newItems: (File | IRoot)[]; + remainingItemsCount: number; onCloseDialog(): void; onCancelButtonPressed(): void; - onSubmitButtonPressed({ operationType, operation, itemsToUpload, itemsToReplace }: OnSubmitPressed): Promise; + onSubmitButtonPressed({ + operationType, + operation, + itemsToUpload, + itemsToReplace, + applyToAll, + }: OnSubmitPressed): Promise; } const NameCollisionDialog: FC = ({ @@ -33,35 +41,41 @@ const NameCollisionDialog: FC = ({ operationType, newItems, driveItems, + remainingItemsCount, onCancelButtonPressed, onCloseDialog, onSubmitButtonPressed, }: NameCollisionDialogProps) => { const { translate } = useTranslationContext(); - const options = [ - { - operation: 'replace' as const, - name: translate('modals.renameModal.replaceItem'), - }, - { - operation: 'keep' as const, - name: translate('modals.renameModal.keepBoth'), - }, - ]; + const options = useMemo( + () => [ + { + operation: 'replace' as const, + name: translate('modals.alreadyExistsModal.replaceItem'), + }, + { + operation: 'keep' as const, + name: translate('modals.alreadyExistsModal.keepBoth'), + }, + { + operation: 'skip' as const, + name: translate('modals.alreadyExistsModal.skipItem'), + }, + ], + [translate], + ); const [isLoading, setIsLoading] = useState(false); const [selectedOption, setSelectedOption] = useState(options[0]); + const [applyToAll, setApplyToAll] = useState(false); + + const title = translate('modals.alreadyExistsModal.title'); + const description = translate('modals.alreadyExistsModal.description', { itemName: newItems?.[0]?.name }); - const title = - newItems?.length > 1 ? translate('modals.renameModal.titleMultipleItems') : translate('modals.renameModal.title'); - const description = - newItems?.length > 1 - ? translate('modals.renameModal.multipleDescription') - : translate('modals.renameModal.description', { itemName: newItems?.[0]?.name }); const primaryButtonText = useMemo( () => operationType === OPERATION_TYPE.MOVE - ? translate('modals.renameModal.move') - : translate('modals.renameModal.upload'), + ? translate('modals.alreadyExistsModal.move') + : translate('modals.alreadyExistsModal.upload'), [operationType], ); @@ -69,13 +83,15 @@ const NameCollisionDialog: FC = ({ if (isOpen) { setIsLoading(false); setSelectedOption(options[0]); + setApplyToAll(false); } - }, [isOpen]); + }, [isOpen, options]); const onClose = (): void => { onCloseDialog(); onCancelButtonPressed(); setSelectedOption(options[0]); + setApplyToAll(false); setIsLoading(false); }; @@ -88,9 +104,10 @@ const NameCollisionDialog: FC = ({ operation: selectedOption.operation, itemsToUpload: newItems, itemsToReplace: driveItems, + applyToAll, }); - onClose(); + setIsLoading(false); }; return ( @@ -100,7 +117,7 @@ const NameCollisionDialog: FC = ({

{description}

- +
{options.map((option) => ( @@ -109,12 +126,14 @@ const NameCollisionDialog: FC = ({ className={`flex h-5 w-5 flex-col items-center justify-center rounded-full ${ option.operation === selectedOption.operation ? 'bg-primary active:bg-primary-dark' - : 'border border-gray-40 bg-white group-hover:border-gray-50' + : 'border border-gray-40 bg-white dark:bg-transparent group-hover:border-gray-50' }`} >
@@ -128,6 +147,17 @@ const NameCollisionDialog: FC = ({
+ {remainingItemsCount > 1 && ( +
+ setApplyToAll((prev) => !prev)} /> + +
+ )}