From 76fedc746e00fccbe9231a32027c0e3dc0901f73 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 23 Jun 2026 22:31:26 +0800 Subject: [PATCH] fix duplicate inline database in row pages --- .../features/database/row-document.feature | 19 +- .../bdd/steps/database-row-document.steps.ts | 102 ++++++-- .../toolbar/block-controls/ControlsMenu.tsx | 240 ++++++++++++++---- 3 files changed, 289 insertions(+), 72 deletions(-) diff --git a/playwright/bdd/features/database/row-document.feature b/playwright/bdd/features/database/row-document.feature index e2ede363..68630fd8 100644 --- a/playwright/bdd/features/database/row-document.feature +++ b/playwright/bdd/features/database/row-document.feature @@ -1,5 +1,6 @@ Feature: Database row document Row document content should be reflected in the database primary cell. + Inline grids duplicated inside row pages should become independent databases. Scenario: Image link content shows the row document indicator Given a board database with a card is open @@ -15,7 +16,23 @@ Feature: Database row document And I create an inline grid in the row page And I duplicate the inline grid block in the row page Then the duplicated inline grid shows a loading placeholder - Then the duplicated inline grid has a fresh database view id + And the duplicated inline grid has fresh view and database ids + When I edit the duplicated inline grid + Then the original row-page inline grid remains unchanged + When I edit the original inline grid + Then the duplicated row-page inline grid remains unchanged + + # Same as above, but the outer database is itself an inline grid embedded in a + # document, reproducing the exact reported folder shape: + # document -> parent grid -> row page -> inline subgrid. + Scenario: Duplicating an inline grid block in the row page of a document-embedded grid creates an independent database + Given a document is open for row-page inline grid duplication + And I create a parent inline grid in the document + When I open the first parent grid row as a full row page + And I create an inline grid in the row page + And I duplicate the inline grid block in the row page + Then the duplicated inline grid shows a loading placeholder + And the duplicated inline grid has fresh view and database ids When I edit the duplicated inline grid Then the original row-page inline grid remains unchanged When I edit the original inline grid diff --git a/playwright/bdd/steps/database-row-document.steps.ts b/playwright/bdd/steps/database-row-document.steps.ts index e8bc61f4..39839e4b 100644 --- a/playwright/bdd/steps/database-row-document.steps.ts +++ b/playwright/bdd/steps/database-row-document.steps.ts @@ -2,6 +2,7 @@ import { expect, Locator, Page, Route } from '@playwright/test'; import { createBdd } from 'playwright-bdd'; import { v4 as uuidv4 } from 'uuid'; +import { signUpAndLoginWithPasswordViaUi } from '../../support/auth-flow-helpers'; import { signInAndCreateDatabaseView, waitForGridReady } from '../../support/database-ui-helpers'; import { databaseBlocks, @@ -10,6 +11,7 @@ import { firstGridCellText, insertInlineGridViaSlash, } from '../../support/duplicate-test-helpers'; +import { createDocumentPageAndNavigate } from '../../support/page-utils'; import { closeRowDetailWithEscape, openRowDetail } from '../../support/row-detail-helpers'; import { BlockSelectors, @@ -34,6 +36,7 @@ interface DatabaseBlockState { } interface RowInlineGridState { + documentViewId?: string; rowPageViewId?: string; originalBlock?: DatabaseBlockState; duplicatedBlock?: DatabaseBlockState; @@ -117,11 +120,7 @@ Then('the grid primary cell shows a row document icon', async ({ page }) => { Given('a grid database is open for row-page inline grid duplication', async ({ page, request }) => { setupPageErrorHandling(page); - rowInlineGridStateByPage.set(page, { - originalCellText: `row-page-original-${uuidv4().slice(0, 6)}`, - originalEditedCellText: `row-page-original-edited-${uuidv4().slice(0, 6)}`, - duplicateCellText: `row-page-duplicate-${uuidv4().slice(0, 6)}`, - }); + initializeRowInlineGridState(page); await signInAndCreateDatabaseView(page, request, generateRandomEmail(), 'Grid', { createWaitMs: 6000, @@ -129,22 +128,43 @@ Given('a grid database is open for row-page inline grid duplication', async ({ p }); }); -When('I open the first row as a full row page', async ({ page }) => { - await openRowDetail(page, 0); - await page.locator('.MuiDialogTitle-root').locator('button').first().click({ force: true }); +Given('a document is open for row-page inline grid duplication', async ({ page, request }) => { + setupPageErrorHandling(page); + const state = initializeRowInlineGridState(page); + + await signUpAndLoginWithPasswordViaUi(page, request, generateRandomEmail()); + await expect(page).toHaveURL(/\/app/, { timeout: 30000 }); await page.waitForTimeout(2000); - const editor = page.locator('[id^="editor-"]').first(); + state.documentViewId = await createDocumentPageAndNavigate(page); +}); - await expect(editor).toBeVisible({ timeout: 15000 }); - const editorId = await editor.getAttribute('id'); - const rowPageViewId = editorId?.replace('editor-', ''); +When('I create a parent inline grid in the document', async ({ page }) => { + const docViewId = getDocumentViewId(page); - if (!rowPageViewId) { - throw new Error(`Expected mounted row document editor id, got ${String(editorId)}`); - } + await insertInlineGridViaSlash(page, docViewId); - getRowInlineGridState(page).rowPageViewId = rowPageViewId; + // Inserting an inline grid opens the new database in a ViewModal. Close it so the + // row-open step targets the document's inline grid rather than a row hidden behind + // the modal backdrop (page-level `grid-row-*` would otherwise match both). + await closeOpenDialogs(page); + + const editor = editorForView(page, docViewId); + + await expect(databaseBlocks(editor)).toHaveCount(1, { timeout: 30000 }); + // The inline grid's first row must be hydrated before `openRowDetail` can hover it. + // Scope to the document's grid block (page-level would also match a modal grid). + await expect(databaseBlocks(editor).first().locator('[data-testid^="grid-row-"]').first()).toBeVisible({ + timeout: 30000, + }); +}); + +When('I open the first row as a full row page', async ({ page }) => { + await openFirstRowAsFullRowPage(page); +}); + +When('I open the first parent grid row as a full row page', async ({ page }) => { + await openFirstRowAsFullRowPage(page); }); When('I create an inline grid in the row page', async ({ page }) => { @@ -194,7 +214,7 @@ Then('the duplicated inline grid shows a loading placeholder', async ({ page }) await expectDuplicatedInlineGridLoadingPlaceholder(page, rowPageViewId); }); -Then('the duplicated inline grid has a fresh database view id', async ({ page }) => { +Then('the duplicated inline grid has fresh view and database ids', async ({ page }) => { const state = getRowInlineGridState(page); const rowPageViewId = getRowPageViewId(page); @@ -307,6 +327,17 @@ function getCurrentCardName(page: Page) { return cardName; } +function initializeRowInlineGridState(page: Page): RowInlineGridState { + const state: RowInlineGridState = { + originalCellText: `row-page-original-${uuidv4().slice(0, 6)}`, + originalEditedCellText: `row-page-original-edited-${uuidv4().slice(0, 6)}`, + duplicateCellText: `row-page-duplicate-${uuidv4().slice(0, 6)}`, + }; + + rowInlineGridStateByPage.set(page, state); + return state; +} + function getRowInlineGridState(page: Page): RowInlineGridState { const state = rowInlineGridStateByPage.get(page); @@ -317,6 +348,34 @@ function getRowInlineGridState(page: Page): RowInlineGridState { return state; } +function getDocumentViewId(page: Page): string { + const documentViewId = getRowInlineGridState(page).documentViewId; + + if (!documentViewId) { + throw new Error('No document view id is available for this scenario'); + } + + return documentViewId; +} + +async function openFirstRowAsFullRowPage(page: Page): Promise { + await openRowDetail(page, 0); + await page.locator('.MuiDialogTitle-root').locator('button').first().click({ force: true }); + await page.waitForTimeout(2000); + + const editor = page.locator('[id^="editor-"]').first(); + + await expect(editor).toBeVisible({ timeout: 15000 }); + const editorId = await editor.getAttribute('id'); + const rowPageViewId = editorId?.replace('editor-', ''); + + if (!rowPageViewId) { + throw new Error(`Expected mounted row document editor id, got ${String(editorId)}`); + } + + getRowInlineGridState(page).rowPageViewId = rowPageViewId; +} + function getRowPageViewId(page: Page): string { const rowPageViewId = getRowInlineGridState(page).rowPageViewId; @@ -327,6 +386,15 @@ function getRowPageViewId(page: Page): string { return rowPageViewId; } +async function closeOpenDialogs(page: Page): Promise { + for (let attempt = 0; attempt < 5 && (await page.locator('[role="dialog"]').count()) > 0; attempt++) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + + await expect(page.locator('[role="dialog"]')).toHaveCount(0, { timeout: 10000 }); +} + async function getDatabaseBlockStates(page: Page, rowPageViewId: string): Promise { return page.evaluate((currentRowPageViewId) => { const testWindow = window as Window & { diff --git a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx index 5cfb6e6c..23377937 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx @@ -47,6 +47,121 @@ function getViewNoCache(workspaceId: string, viewId: string, depth: number = 1): return executeAPIRequest(() => getAxios()?.get>(url)); } +const DUPLICATED_DATABASE_METADATA_ATTEMPTS = 20; +const DUPLICATED_DATABASE_METADATA_INTERVAL_MS = 300; + +interface FreshDatabaseView { + viewId: string; + databaseId: string; +} + +interface FreshDuplicatedContainer { + container: View; + primaryViewId: string; + databaseId: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function pollForDuplicateMetadata(loadMetadata: () => Promise): Promise { + for (let attempt = 0; attempt < DUPLICATED_DATABASE_METADATA_ATTEMPTS; attempt++) { + const metadata = await loadMetadata(); + + if (metadata) { + return metadata; + } + + if (attempt < DUPLICATED_DATABASE_METADATA_ATTEMPTS - 1) { + await sleep(DUPLICATED_DATABASE_METADATA_INTERVAL_MS); + } + } + + return undefined; +} + +async function findFreshDatabaseViewInContainer(params: { + workspaceId: string; + sourceContainerId: string; + beforeChildIds: Set; + sourceDatabaseId: string; +}): Promise { + return pollForDuplicateMetadata(async () => { + ViewService.invalidateCache(params.workspaceId, params.sourceContainerId); + const afterContainer = await getViewNoCache(params.workspaceId, params.sourceContainerId, 2); + const candidate = (afterContainer.children ?? []).find((child) => !params.beforeChildIds.has(child.view_id)); + + if (!candidate) { + return undefined; + } + + ViewService.invalidateCache(params.workspaceId, candidate.view_id); + const candidateView = await getViewNoCache(params.workspaceId, candidate.view_id, 1); + const candidateDatabaseId = getDatabaseIdFromExtra(candidateView); + + // The deep copy must yield a fresh database; keep polling until the new + // child view reports a database id distinct from the source. + if (!candidateDatabaseId || candidateDatabaseId === params.sourceDatabaseId) { + return undefined; + } + + return { + viewId: candidate.view_id, + databaseId: candidateDatabaseId, + }; + }); +} + +async function findFreshDuplicatedContainer(params: { + workspaceId: string; + parentId: string; + beforeParentView: View; + sourceContainerId: string; + sourceContainerView: View; + sourceViewId: string; + sourceDatabaseId: string; + duplicateCopySuffix: string; +}): Promise { + return pollForDuplicateMetadata(async () => { + ViewService.invalidateCache(params.workspaceId, params.parentId); + const afterParentView = await getViewNoCache(params.workspaceId, params.parentId, 2); + + const candidate = findDuplicatedContainerChild({ + beforeChildren: params.beforeParentView.children, + afterChildren: afterParentView.children, + sourceContainerId: params.sourceContainerId, + duplicatedName: `${params.sourceContainerView.name}${params.duplicateCopySuffix}`, + }); + + if (!candidate) { + return undefined; + } + + ViewService.invalidateCache(params.workspaceId, candidate.view_id); + const candidateContainer = await getViewNoCache(params.workspaceId, candidate.view_id, 2); + const candidatePrimaryViewId = candidateContainer.children?.[0]?.view_id; + const candidateDatabaseId = getDatabaseIdFromExtra(candidateContainer); + + // Inline duplication must produce a brand-new container with a new child view + // and a new database_id. Keep polling until the fresh duplicate is visible. + if ( + !candidatePrimaryViewId || + !candidateDatabaseId || + candidatePrimaryViewId === params.sourceViewId || + candidateDatabaseId === params.sourceDatabaseId + ) { + return undefined; + } + + return { + container: candidateContainer, + primaryViewId: candidatePrimaryViewId, + databaseId: candidateDatabaseId, + }; + }); +} + function waitForDatabaseBlobSeeds(workspaceId: string, databaseId: string): Promise { const timeoutMs = 10000; @@ -209,81 +324,98 @@ function ControlsMenu({ ViewService.invalidateCache(workspaceId, sourceContainerId); ViewService.invalidateCache(workspaceId, parentId); - const [sourceContainerView, beforeParentView] = await Promise.all([ + const duplicateBlobPreSync = async () => { + await prefetchDatabaseBlobDiff(workspaceId, databaseId, { + forceFullSync: true, + }); + }; + + // Fetch both views concurrently (single round trip). The source container is + // a folder view in both cases, so it must resolve; `parentId` is a regular + // folder view only when the grid is embedded in a normal document — inside a + // database row page it is an orphan row-document the view API 404s on. Probe + // it, tolerating failure, to choose the discovery strategy below. + const [sourceContainerResult, parentResult] = await Promise.allSettled([ getView(workspaceId, sourceContainerId, 2), getView(workspaceId, parentId, 2), ]); + if (sourceContainerResult.status === 'rejected') { + throw sourceContainerResult.reason; + } + + const sourceContainerView = sourceContainerResult.value; + const beforeParentView: View | null = parentResult.status === 'fulfilled' ? parentResult.value : null; + + // Row-page case: parentId is an orphan row document the folder API can't see. + // Duplicate the child database view directly — the server deep-copies the + // embedded database and registers the new view under the same container — then + // discover the new view by diffing the (resolvable) source container's children. + if (!beforeParentView) { + const beforeContainerChildIds = new Set((sourceContainerView.children ?? []).map((c) => c.view_id)); + + await duplicatePage(sourceViewIds[0], { + includeChildren: true, + suffix: duplicateCopySuffix, + source: 0, + afterPreSync: duplicateBlobPreSync, + }); + + const rowPageDuplicate = await findFreshDatabaseViewInContainer({ + workspaceId, + sourceContainerId, + beforeChildIds: beforeContainerChildIds, + sourceDatabaseId: databaseId, + }); + + if (!rowPageDuplicate) { + throw new Error(t('document.plugins.subPage.errors.failedDuplicatePage')); + } + + await waitForDatabaseBlobSeeds(workspaceId, rowPageDuplicate.databaseId); + + return createDatabaseNodeData({ + parentId, + viewIds: [rowPageDuplicate.viewId], + databaseId: rowPageDuplicate.databaseId, + }); + } + + // Normal-document case (unchanged): duplicate the container under the document + // and find the freshly created container by diffing the document's children. await duplicatePage(sourceContainerId, { parentViewId: parentId, includeChildren: true, suffix: duplicateCopySuffix, source: 0, - afterPreSync: async () => { - await prefetchDatabaseBlobDiff(workspaceId, databaseId, { - forceFullSync: true, - }); - }, + afterPreSync: duplicateBlobPreSync, }); - let duplicatedContainerView; - let duplicatedContainer; - let newPrimaryViewId: string | undefined; - let newDatabaseId: string | undefined; - // The folder duplicate call is async with respect to sidebar/view metadata updates. // Poll the refreshed parent view instead of assuming the new child is visible immediately. // Use cache-busting (_t param) and depth=2 to ensure fresh, complete children lists. - for (let attempt = 0; attempt < 20; attempt++) { - ViewService.invalidateCache(workspaceId, parentId); - const afterParentView = await getViewNoCache(workspaceId, parentId, 2); - - const candidate = findDuplicatedContainerChild({ - beforeChildren: beforeParentView.children, - afterChildren: afterParentView.children, - sourceContainerId, - duplicatedName: `${sourceContainerView.name}${duplicateCopySuffix}`, - }); - - if (!candidate) { - await new Promise((resolve) => window.setTimeout(resolve, 300)); - continue; - } - - ViewService.invalidateCache(workspaceId, candidate.view_id); - const candidateContainer = await getViewNoCache(workspaceId, candidate.view_id, 2); - const candidatePrimaryViewId = candidateContainer.children?.[0]?.view_id; - const candidateDatabaseId = getDatabaseIdFromExtra(candidateContainer); - - // Inline duplication must produce a brand-new container with a new child view - // and a new database_id. Keep polling until the fresh duplicate is visible. - if ( - candidatePrimaryViewId && - candidateDatabaseId && - candidatePrimaryViewId !== sourceViewIds[0] && - candidateDatabaseId !== databaseId - ) { - duplicatedContainerView = candidate; - duplicatedContainer = candidateContainer; - newPrimaryViewId = candidatePrimaryViewId; - newDatabaseId = candidateDatabaseId; - break; - } - - await new Promise((resolve) => window.setTimeout(resolve, 300)); - } + const duplicatedContainer = await findFreshDuplicatedContainer({ + workspaceId, + parentId, + beforeParentView, + sourceContainerId, + sourceContainerView, + sourceViewId: sourceViewIds[0], + sourceDatabaseId: databaseId, + duplicateCopySuffix, + }); - if (!duplicatedContainerView || !duplicatedContainer || !newPrimaryViewId || !newDatabaseId) { + if (!duplicatedContainer) { throw new Error(t('document.plugins.subPage.errors.failedDuplicatePage')); } - await waitForDatabaseBlobSeeds(workspaceId, newDatabaseId); + await waitForDatabaseBlobSeeds(workspaceId, duplicatedContainer.databaseId); // Map source view IDs to their index positions within the source container, // then select the same positions from the duplicated container. This preserves // the block's tab subset (e.g., if the block only shows tabs B and C out of // A, B, C, the duplicate will also show only B' and C'). - const duplicatedChildIds = duplicatedContainer.children?.map((c) => c.view_id) ?? []; + const duplicatedChildIds = duplicatedContainer.container.children?.map((c) => c.view_id) ?? []; const sourceContainerChildIds = sourceContainerView.children?.map((c) => c.view_id) ?? []; const mappedViewIds = sourceViewIds .map((id) => sourceContainerChildIds.indexOf(id)) @@ -295,12 +427,12 @@ function ControlsMenu({ ? mappedViewIds : duplicatedChildIds.length > 0 ? duplicatedChildIds.slice(0, sourceViewIds.length) - : [newPrimaryViewId]; + : [duplicatedContainer.primaryViewId]; return createDatabaseNodeData({ parentId, viewIds: allDuplicatedViewIds, - databaseId: newDatabaseId, + databaseId: duplicatedContainer.databaseId, }); }, [createDatabaseView, duplicateCopySuffix, duplicatePage, loadViewMeta, t, workspaceId]