diff --git a/playwright/bdd/features/database/published-relation-template.feature b/playwright/bdd/features/database/published-relation-template.feature new file mode 100644 index 000000000..0391724cb --- /dev/null +++ b/playwright/bdd/features/database/published-relation-template.feature @@ -0,0 +1,27 @@ +@published-relation-template @mode:serial +Feature: Published database templates preserve relations + This scenario reuses the `pdf_db_relation@appflowy.io` fixture documented in + `AppFlowy-Cloud-Premium/backup/README.md`. Its `New Database` Grid has a + Relation field that points to `Related DB`, whose row title is + `Related DB content`. The matching server regression fixture is documented in + `tests/workspace/publish/duplication_test.rs` by + `publishing_only_database_with_relation_includes_related_database_in_template`. + + Background: + Given the seeded relation template fixture exists + + Scenario: Another account starts with a published database that has a relation + Given I sign in as the relation template fixture publisher + And the seeded relation cell resolves before publishing + When I publish only the seeded source database as a template + And another account opens the published relation template + Then the published relation cell shows "Related DB content" + When that account starts with the relation template in "General" + Then the destination space request uses depth 2 + And the relation template duplication contains 2 database mappings + And the duplicated relation database is named "Related DB" + And the duplicated relation cell shows "Related DB content" + And the duplicated relation cell does not show "No access" + When I clear the duplication mappings and reload the duplicated relation template + Then the duplicated relation cell shows "Related DB content" + And the duplicated relation cell does not show "No access" diff --git a/playwright/bdd/features/page/published-document-template-dependencies.feature b/playwright/bdd/features/page/published-document-template-dependencies.feature new file mode 100644 index 000000000..b2a273324 --- /dev/null +++ b/playwright/bdd/features/page/published-document-template-dependencies.feature @@ -0,0 +1,54 @@ +@published-document-template-dependencies @mode:serial +Feature: Published document templates preserve referenced content + These scenarios reuse the `duplicate@appflowy.io` fixture documented by + `AppFlowy-Cloud-Premium/tests/workspace/page_view/duplication_test.rs`. + Its `DB 1` database contains `db 1 row 1`, while `DB 2` contains the rows + `Row with linked database` and `Row with inline database`; each DB 2 row page + contains the corresponding nested database kind. Temporary source documents + are created in that fixture workspace and removed during teardown. + + Every scenario publishes only the temporary root document. A newly registered, + different account then opens the public page, clicks "Start with this template", + adds it to General, and validates the resulting private workspace copy. + + Background: + Given I sign in as the seeded document dependency publisher + + Scenario: A document template deep-copies a referenced database view + Given a temporary document template contains a referenced view of database "DB 1" + And the source referenced database shows "db 1 row 1" + When I publish only the temporary document template + And a different account starts with the published document template in "General" + Then the duplicated referenced database shows "db 1 row 1" + And the duplicated referenced database remains available after reload + + Scenario: A document template deep-copies an inline database view + Given a temporary document template contains an inline database with identifiable row data + When I publish only the temporary document template + And a different account starts with the published document template in "General" + Then the duplicated inline database has an independent database identity + And the duplicated inline database row remains available after reload + + Scenario: A document template deep-copies a referenced page + Given a temporary document template contains a reference to another temporary page + When I publish only the temporary document template + And a different account starts with the published document template in "General" + Then the duplicated page reference points to a new accessible page copy + + Scenario: A referenced database preserves a nested referenced database in a row page + Given a temporary document template contains a referenced view of nested fixture database "DB 2" + And the source referenced database contains nested rows "Row with linked database" and "Row with inline database" + When I publish only the temporary document template + And a different account starts with the published document template in "General" + Then the duplicated referenced database contains nested rows "Row with linked database" and "Row with inline database" + And row "Row with linked database" has an available nested referenced database + And the nested database in row "Row with linked database" remains available after reload + + Scenario: A referenced database preserves a nested inline database in a row page + Given a temporary document template contains a referenced view of nested fixture database "DB 2" + And the source referenced database contains nested rows "Row with linked database" and "Row with inline database" + When I publish only the temporary document template + And a different account starts with the published document template in "General" + Then the duplicated referenced database contains nested rows "Row with linked database" and "Row with inline database" + And row "Row with inline database" has an available nested inline database + And the nested database in row "Row with inline database" remains available after reload diff --git a/playwright/bdd/steps/published-document-template-dependencies.steps.ts b/playwright/bdd/steps/published-document-template-dependencies.steps.ts new file mode 100644 index 000000000..a3b4cf030 --- /dev/null +++ b/playwright/bdd/steps/published-document-template-dependencies.steps.ts @@ -0,0 +1,941 @@ +import { APIRequestContext, BrowserContext, expect, Locator, Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { signInAndWaitForApp, signInWithPasswordViaUi } from '../../support/auth-flow-helpers'; +import { + databaseBlocks, + editFirstGridCell, + editorForView, + firstGridCellText, + insertInlineGridViaSlash, + insertLinkedGridViaSlash, +} from '../../support/duplicate-test-helpers'; +import { closeRowDetailWithEscape } from '../../support/row-detail-helpers'; +import { ShareSelectors, SidebarSelectors } from '../../support/selectors'; +import { generateRandomEmail, setupPageErrorHandling, TestConfig } from '../../support/test-config'; + +const { Given, When, Then, Before, After } = createBdd(); + +const FIXTURE_EMAIL = 'duplicate@appflowy.io'; +const FIXTURE_PASSWORD = 'AppFlowy!@123'; +const PERMANENTLY_DELETED_TEXT = 'This referenced database was permanently deleted'; +const NO_ACCESS_TEXT = 'No access'; +const NO_PERMISSION_TEXT = "You don't have permission to view this database"; + +const ViewLayout = { + Document: 0, +} as const; + +type ApiResponse = { + code?: number; + data?: T; + message?: string; +}; + +type WorkspaceView = { + view_id: string; + name: string; + layout?: number; + extra?: { + database_id?: string; + is_database_container?: boolean; + }; + children?: WorkspaceView[]; +}; + +type CreatePageResponse = { + view_id: string; + database_id?: string; +}; + +type DuplicateResult = { + view_id: string; + database_mappings: Record; +}; + +type DatabaseBlockIdentity = { + databaseId: string; + viewId: string; + parentId: string; +}; + +type PublishedDocumentTemplateState = { + publisherToken?: string; + publisherWorkspaceId?: string; + publisherGeneralSpaceId?: string; + sourceDocumentId?: string; + sourceDocumentName?: string; + sourceDatabaseIdentity?: DatabaseBlockIdentity; + inlineRowMarker?: string; + duplicatedInlineRowMarker?: string; + referencedPageId?: string; + referencedPageName?: string; + referencedPageMarker?: string; + publishedUrl?: string; + consumerContext?: BrowserContext; + consumerPage?: Page; + consumerToken?: string; + consumerWorkspaceId?: string; + duplicateResult?: DuplicateResult; + publisherRoots: string[]; + consumerRoots: string[]; +}; + +const stateByPage = new WeakMap(); + +Before({ tags: '@published-document-template-dependencies' }, async ({ page }) => { + setupPageErrorHandling(page); + await page.setViewportSize({ width: 1440, height: 900 }); + stateByPage.set(page, { + publisherRoots: [], + consumerRoots: [], + }); +}); + +After({ tags: '@published-document-template-dependencies' }, async ({ page, request }) => { + const state = stateByPage.get(page); + + if (!state) return; + + const cleanupErrors: string[] = []; + + await cleanupConsumerCopies(request, state).catch((error) => { + cleanupErrors.push(`consumer cleanup: ${errorMessage(error)}`); + }); + await cleanupPublisherPages(request, state).catch((error) => { + cleanupErrors.push(`publisher cleanup: ${errorMessage(error)}`); + }); + await state.consumerContext?.close().catch((error) => { + cleanupErrors.push(`consumer context cleanup: ${errorMessage(error)}`); + }); + + stateByPage.delete(page); + + if (cleanupErrors.length > 0) { + throw new Error(`Published document template teardown failed:\n${cleanupErrors.join('\n')}`); + } +}); + +Given('I sign in as the seeded document dependency publisher', async ({ page, request }) => { + const state = getState(page); + + // Fixture source: + // AppFlowy-Cloud-Premium/tests/workspace/page_view/duplication_test.rs + // - duplicate_preconfigured_document_with_linked_and_inline_databases + // - duplicate_preconfigured_database_row_docs_preserve_linked_and_inline_semantics + await signInWithPasswordViaUi(page, FIXTURE_EMAIL, FIXTURE_PASSWORD, 3000); + await expect(page).toHaveURL(/\/app\//, { timeout: 30000 }); + await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 }); + + state.publisherToken = await requireAuthToken(page); + state.publisherWorkspaceId = workspaceIdFromAppUrl(page.url()); + + const workspaceRoot = await getWorkspaceView( + request, + state.publisherToken, + state.publisherWorkspaceId, + state.publisherWorkspaceId, + 2 + ); + const generalSpace = workspaceRoot.children?.find((view) => view.name === 'General'); + + if (!generalSpace) { + throw new Error('The seeded document dependency fixture has no General space'); + } + + state.publisherGeneralSpaceId = generalSpace.view_id; +}); + +Given( + 'a temporary document template contains a referenced view of database {string}', + async ({ page, request }, databaseName: string) => { + const state = getState(page); + const source = await createTemporaryDocument(page, request, state, 'referenced database'); + + await insertLinkedGridViaSlash(page, source.view_id, databaseName); + + const sourceBlock = firstDatabaseBlock(page, source.view_id); + + await expect(sourceBlock).toBeVisible({ timeout: 30000 }); + state.sourceDatabaseIdentity = await requireDatabaseBlockIdentity(page, source.view_id); + } +); + +Given('the source referenced database shows {string}', async ({ page }, expectedRow: string) => { + const state = getState(page); + const sourceDocumentId = requireValue(state.sourceDocumentId, 'source document id'); + + await expect(firstDatabaseBlock(page, sourceDocumentId)).toContainText(expectedRow, { timeout: 60000 }); +}); + +Given( + 'a temporary document template contains an inline database with identifiable row data', + async ({ page, request }) => { + const state = getState(page); + const source = await createTemporaryDocument(page, request, state, 'inline database'); + const marker = `BDD inline template row ${Date.now()}`; + + await insertInlineGridViaSlash(page, source.view_id); + + const sourceBlock = firstDatabaseBlock(page, source.view_id); + + await expect(sourceBlock).toBeVisible({ timeout: 30000 }); + await editFirstGridCell(page, sourceBlock, marker); + + state.inlineRowMarker = marker; + state.sourceDatabaseIdentity = await requireDatabaseBlockIdentity(page, source.view_id); + + // Reload before publishing so this scenario proves the block and row reached + // the server rather than duplicating unsaved browser state. + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(firstDatabaseBlock(page, source.view_id)).toContainText(marker, { timeout: 60000 }); + } +); + +Given('a temporary document template contains a reference to another temporary page', async ({ page, request }) => { + const state = getState(page); + const targetName = `BDD referenced page ${Date.now()}`; + const targetMarker = `Referenced page body ${Date.now()}`; + const target = await createDocumentViaApi(request, state, targetName, targetMarker); + const source = await createTemporaryDocument(page, request, state, 'page reference'); + + state.referencedPageId = target.view_id; + state.referencedPageName = targetName; + state.referencedPageMarker = targetMarker; + registerPublisherRoot(state, target.view_id); + + const slateEditor = page.locator('[data-slate-editor="true"]').first(); + const targetUrl = `${new URL(page.url()).origin}/app/${requireValue( + state.publisherWorkspaceId, + 'publisher workspace id' + )}/${target.view_id}`; + + await expect(slateEditor).toBeVisible({ timeout: 30000 }); + await slateEditor.click({ force: true }); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.evaluate(async (url) => navigator.clipboard.writeText(url), targetUrl); + await page.keyboard.press(`${modifierKey()}+V`); + + const mention = page.locator(`.mention-inline[data-mention-id="${target.view_id}"]`); + + await expect(mention).toBeVisible({ timeout: 30000 }); + await expect(mention.locator('.mention-content')).toContainText(targetName, { timeout: 30000 }); + + // A reload is the persistence boundary used by the template publish below. + await page.waitForTimeout(1500); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.locator(`.mention-inline[data-mention-id="${target.view_id}"]`)).toBeVisible({ + timeout: 30000, + }); +}); + +Given( + 'a temporary document template contains a referenced view of nested fixture database {string}', + async ({ page, request }, databaseName: string) => { + const state = getState(page); + const source = await createTemporaryDocument(page, request, state, 'nested referenced database'); + + await insertLinkedGridViaSlash(page, source.view_id, databaseName); + + const sourceBlock = firstDatabaseBlock(page, source.view_id); + + await expect(sourceBlock).toBeVisible({ timeout: 30000 }); + state.sourceDatabaseIdentity = await requireDatabaseBlockIdentity(page, source.view_id); + } +); + +Given( + 'the source referenced database contains nested rows {string} and {string}', + async ({ page }, linkedRow: string, inlineRow: string) => { + const state = getState(page); + const sourceDocumentId = requireValue(state.sourceDocumentId, 'source document id'); + const sourceBlock = firstDatabaseBlock(page, sourceDocumentId); + + await expect(sourceBlock).toContainText(linkedRow, { timeout: 60000 }); + await expect(sourceBlock).toContainText(inlineRow, { timeout: 60000 }); + + // Validate the seeded precondition so a later failure cannot be attributed + // to stale or incomplete fixture data. + await expectNestedRowDatabaseAvailable(page, sourceBlock, linkedRow); + await closeRowDetailWithEscape(page); + await expectNestedRowDatabaseAvailable(page, sourceBlock, inlineRow); + await closeRowDetailWithEscape(page); + } +); + +When('I publish only the temporary document template', async ({ page }) => { + const state = getState(page); + const sourceDocumentId = requireValue(state.sourceDocumentId, 'source document id'); + + state.publishedUrl = await publishCurrentDocument( + page, + requireValue(state.publisherWorkspaceId, 'publisher workspace id'), + sourceDocumentId + ); +}); + +When( + 'a different account starts with the published document template in {string}', + async ({ page, request, browser }, destinationSpaceName: string) => { + const state = getState(page); + const publishedUrl = requireValue(state.publishedUrl, 'published URL'); + const consumerEmail = generateRandomEmail(); + + expect(consumerEmail).not.toBe(FIXTURE_EMAIL); + + const consumerContext = await browser.newContext({ + baseURL: new URL(publishedUrl).origin, + viewport: { width: 1440, height: 900 }, + }); + const consumerPage = await consumerContext.newPage(); + + state.consumerContext = consumerContext; + state.consumerPage = consumerPage; + setupPageErrorHandling(consumerPage); + + await signInAndWaitForApp(consumerPage, request, consumerEmail); + + state.consumerToken = await requireAuthToken(consumerPage); + state.consumerWorkspaceId = workspaceIdFromAppUrl(consumerPage.url()); + + const consumerWorkspaceRoot = await getWorkspaceView( + request, + state.consumerToken, + state.consumerWorkspaceId, + state.consumerWorkspaceId, + 2 + ); + const destinationSpace = consumerWorkspaceRoot.children?.find((view) => view.name === destinationSpaceName); + + if (!destinationSpace) { + throw new Error(`The consumer workspace has no ${destinationSpaceName} space`); + } + + await consumerPage.goto(publishedUrl, { waitUntil: 'domcontentloaded' }); + + const startWithTemplate = consumerPage.getByRole('button', { name: 'Start with this template' }); + + await expect(startWithTemplate).toBeVisible({ timeout: 60000 }); + await startWithTemplate.click(); + + const destinationDialog = consumerPage.getByRole('dialog').filter({ hasText: 'Where would you like to add' }).last(); + + await expect(destinationDialog).toBeVisible({ timeout: 30000 }); + + const destinationSpaceItem = destinationDialog + .getByTestId('space-item') + .filter({ hasText: destinationSpaceName }) + .first(); + + await expect(destinationSpaceItem).toBeVisible({ timeout: 30000 }); + await destinationSpaceItem.click(); + + const addButton = destinationDialog.getByRole('button', { name: 'Add', exact: true }); + + await expect(addButton).toBeEnabled({ timeout: 15000 }); + + const duplicateResponsePromise = consumerPage.waitForResponse( + (response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'POST' && + url.pathname === `/api/workspace/${state.consumerWorkspaceId}/published-duplicate` + ); + }, + { timeout: 90000 } + ); + + await addButton.click(); + + const duplicateResponse = await duplicateResponsePromise; + const responseBody = (await duplicateResponse.json().catch(() => null)) as ApiResponse | null; + + expect( + duplicateResponse.ok() && responseBody?.code === 0 && responseBody.data, + `Published document template duplication failed with HTTP ${duplicateResponse.status()}: ${JSON.stringify( + responseBody + )}` + ).toBeTruthy(); + + state.duplicateResult = responseBody!.data!; + state.consumerRoots.push(responseBody!.data!.view_id); + + const openInBrowser = consumerPage.getByRole('button', { name: /Open in browser/i }); + + await expect(openInBrowser).toBeVisible({ timeout: 30000 }); + await openInBrowser.click(); + await expect(consumerPage).toHaveURL(/\/app\//, { timeout: 30000 }); + await expect(editorForView(consumerPage, responseBody!.data!.view_id)).toBeVisible({ timeout: 60000 }); + } +); + +Then('the duplicated referenced database shows {string}', async ({ page }, expectedRow: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + const duplicatedBlock = firstDatabaseBlock(consumerPage, duplicateResult.view_id); + + await expectDatabaseBlockAvailable(consumerPage, duplicatedBlock, expectedRow); + + const duplicatedIdentity = await requireDatabaseBlockIdentity(consumerPage, duplicateResult.view_id); + const sourceIdentity = requireValue(state.sourceDatabaseIdentity, 'source database identity'); + + expect(duplicatedIdentity.databaseId).not.toBe(sourceIdentity.databaseId); + expect(Object.keys(duplicateResult.database_mappings)).toContain(duplicatedIdentity.databaseId); +}); + +Then('the duplicated referenced database remains available after reload', async ({ page }) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + + await reloadDuplicatedDocument(consumerPage, state); + await expectDatabaseBlockAvailable(consumerPage, firstDatabaseBlock(consumerPage, duplicateResult.view_id)); +}); + +Then('the duplicated inline database has an independent database identity', async ({ page }) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + const sourceDocumentId = requireValue(state.sourceDocumentId, 'source document id'); + const sourceIdentity = requireValue(state.sourceDatabaseIdentity, 'source database identity'); + const sourceMarker = requireValue(state.inlineRowMarker, 'inline row marker'); + const duplicatedBlock = firstDatabaseBlock(consumerPage, duplicateResult.view_id); + + await expectDatabaseBlockAvailable(consumerPage, duplicatedBlock, sourceMarker); + + const duplicatedIdentity = await requireDatabaseBlockIdentity(consumerPage, duplicateResult.view_id); + + expect(duplicatedIdentity.databaseId).not.toBe(sourceIdentity.databaseId); + expect(Object.keys(duplicateResult.database_mappings)).toContain(duplicatedIdentity.databaseId); + + const duplicatedMarker = `Edited only in consumer ${Date.now()}`; + + await editFirstGridCell(consumerPage, duplicatedBlock, duplicatedMarker); + state.duplicatedInlineRowMarker = duplicatedMarker; + + // Prove this is a deep copy, not merely a working reference to the source DB. + await page.goto(`/app/${requireValue(state.publisherWorkspaceId, 'publisher workspace id')}/${sourceDocumentId}`, { + waitUntil: 'domcontentloaded', + }); + await expect(firstDatabaseBlock(page, sourceDocumentId)).toContainText(sourceMarker, { timeout: 60000 }); + expect(await firstGridCellText(firstDatabaseBlock(page, sourceDocumentId))).not.toContain(duplicatedMarker); +}); + +Then('the duplicated inline database row remains available after reload', async ({ page }) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + const expectedMarker = requireValue(state.duplicatedInlineRowMarker, 'duplicated inline row marker'); + + await reloadDuplicatedDocument(consumerPage, state); + await expectDatabaseBlockAvailable( + consumerPage, + firstDatabaseBlock(consumerPage, duplicateResult.view_id), + expectedMarker + ); +}); + +Then('the duplicated page reference points to a new accessible page copy', async ({ page, request }) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + const sourceReferenceId = requireValue(state.referencedPageId, 'source referenced page id'); + const referenceName = requireValue(state.referencedPageName, 'referenced page name'); + const referenceMarker = requireValue(state.referencedPageMarker, 'referenced page marker'); + const mention = editorForView(consumerPage, duplicateResult.view_id).locator('.mention-inline').first(); + + await expect(mention).toBeVisible({ timeout: 60000 }); + + const duplicatedReferenceId = await mention.getAttribute('data-mention-id'); + + expect(duplicatedReferenceId, 'The duplicated mention must have a page id').toBeTruthy(); + expect( + duplicatedReferenceId, + 'The duplicated mention must point to a new destination page instead of the publisher page' + ).not.toBe(sourceReferenceId); + await expect(mention).toContainText(referenceName, { timeout: 30000 }); + + const duplicatedSubtree = await getWorkspaceView( + request, + requireValue(state.consumerToken, 'consumer token'), + requireValue(state.consumerWorkspaceId, 'consumer workspace id'), + duplicateResult.view_id, + 2 + ); + const duplicatedReference = findView(duplicatedSubtree, duplicatedReferenceId!); + + expect(duplicatedReference?.name).toBe(referenceName); + + await mention.click({ force: true }); + await expect(consumerPage.getByText(referenceMarker, { exact: true })).toBeVisible({ timeout: 60000 }); +}); + +Then( + 'the duplicated referenced database contains nested rows {string} and {string}', + async ({ page }, linkedRow: string, inlineRow: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + const duplicatedBlock = firstDatabaseBlock(consumerPage, duplicateResult.view_id); + + await expectDatabaseBlockAvailable(consumerPage, duplicatedBlock, linkedRow); + await expect(duplicatedBlock).toContainText(inlineRow, { timeout: 60000 }); + + const duplicatedIdentity = await requireDatabaseBlockIdentity(consumerPage, duplicateResult.view_id); + const sourceIdentity = requireValue(state.sourceDatabaseIdentity, 'source database identity'); + + expect(duplicatedIdentity.databaseId).not.toBe(sourceIdentity.databaseId); + expect(Object.keys(duplicateResult.database_mappings)).toContain(duplicatedIdentity.databaseId); + } +); + +Then('row {string} has an available nested referenced database', async ({ page }, rowName: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + + await expectNestedRowDatabaseAvailable( + consumerPage, + firstDatabaseBlock(consumerPage, duplicateResult.view_id), + rowName + ); + await closeRowDetailWithEscape(consumerPage); +}); + +Then('row {string} has an available nested inline database', async ({ page }, rowName: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + + await expectNestedRowDatabaseAvailable( + consumerPage, + firstDatabaseBlock(consumerPage, duplicateResult.view_id), + rowName + ); + await closeRowDetailWithEscape(consumerPage); +}); + +Then('the nested database in row {string} remains available after reload', async ({ page }, rowName: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const duplicateResult = requireDuplicateResult(state); + + await reloadDuplicatedDocument(consumerPage, state); + + const duplicatedBlock = firstDatabaseBlock(consumerPage, duplicateResult.view_id); + + await expectNestedRowDatabaseAvailable(consumerPage, duplicatedBlock, rowName); + await closeRowDetailWithEscape(consumerPage); +}); + +async function createTemporaryDocument( + page: Page, + request: APIRequestContext, + state: PublishedDocumentTemplateState, + label: string +): Promise { + const sourceName = `BDD published ${label} ${Date.now()}`; + const sourceMarker = `${sourceName} body`; + const source = await createDocumentViaApi(request, state, sourceName, sourceMarker); + + state.sourceDocumentId = source.view_id; + state.sourceDocumentName = sourceName; + registerPublisherRoot(state, source.view_id); + + await page.goto(`/app/${requireValue(state.publisherWorkspaceId, 'publisher workspace id')}/${source.view_id}`, { + waitUntil: 'domcontentloaded', + }); + await expect(editorForView(page, source.view_id)).toBeVisible({ timeout: 60000 }); + await expect(page.getByText(sourceMarker, { exact: true })).toBeVisible({ timeout: 30000 }); + + return source; +} + +async function createDocumentViaApi( + request: APIRequestContext, + state: PublishedDocumentTemplateState, + name: string, + marker: string +): Promise { + return postApi( + request, + requireValue(state.publisherToken, 'publisher token'), + `/api/workspace/${requireValue(state.publisherWorkspaceId, 'publisher workspace id')}/page-view`, + { + parent_view_id: requireValue(state.publisherGeneralSpaceId, 'publisher General space id'), + layout: ViewLayout.Document, + name, + page_data: { + type: 'page', + children: [ + { + type: 'paragraph', + data: { + delta: [{ insert: marker }], + }, + }, + ], + }, + } + ); +} + +async function publishCurrentDocument(page: Page, workspaceId: string, documentViewId: string): Promise { + await expect(ShareSelectors.shareButton(page)).toBeVisible({ timeout: 30000 }); + await ShareSelectors.shareButton(page).click({ force: true }); + + const sharePopover = ShareSelectors.sharePopover(page); + + await expect(sharePopover).toBeVisible({ timeout: 15000 }); + await sharePopover.getByText('Publish', { exact: true }).click({ force: true }); + + const publishButton = ShareSelectors.publishConfirmButton(page); + + await expect(publishButton).toBeEnabled({ timeout: 30000 }); + + const publishResponsePromise = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'POST' && + url.pathname === `/api/workspace/${workspaceId}/page-view/${documentViewId}/publish` + ); + }, + { timeout: 90000 } + ); + + await publishButton.click({ force: true }); + + const publishResponse = await publishResponsePromise; + + expect( + publishResponse.ok(), + `Publishing document ${documentViewId} failed with HTTP ${publishResponse.status()}` + ).toBeTruthy(); + await expect(ShareSelectors.publishNamespace(page)).toBeVisible({ timeout: 60000 }); + + const namespace = ((await ShareSelectors.publishNamespace(page).textContent()) ?? '').trim(); + const publishName = (await ShareSelectors.publishNameInput(page).inputValue()).trim(); + + expect(namespace, 'Expected the publisher to have a publish namespace').not.toBe(''); + expect(publishName, 'Expected the document to have a publish name').not.toBe(''); + + const publishedUrl = `${new URL(page.url()).origin}/${namespace}/${publishName}`; + + await page.keyboard.press('Escape'); + return publishedUrl; +} + +async function reloadDuplicatedDocument(page: Page, state: PublishedDocumentTemplateState): Promise { + const duplicateResult = requireDuplicateResult(state); + const workspaceId = requireValue(state.consumerWorkspaceId, 'consumer workspace id'); + + await page.goto(`/app/${workspaceId}/${duplicateResult.view_id}`, { waitUntil: 'domcontentloaded' }); + await expect(editorForView(page, duplicateResult.view_id)).toBeVisible({ timeout: 60000 }); +} + +function firstDatabaseBlock(page: Page, documentViewId: string): Locator { + return databaseBlocks(editorForView(page, documentViewId)).first(); +} + +async function expectDatabaseBlockAvailable(page: Page, block: Locator, expectedText?: string): Promise { + await expect(block).toBeVisible({ timeout: 60000 }); + await expect(block.locator('[data-testid="database-grid"]')).toBeVisible({ timeout: 60000 }); + + if (expectedText) { + await expect(block).toContainText(expectedText, { timeout: 60000 }); + } + + await expect(block.getByText(PERMANENTLY_DELETED_TEXT, { exact: true })).toHaveCount(0); + await expect(block.getByText(NO_ACCESS_TEXT, { exact: true })).toHaveCount(0); + await expect(block.getByText(NO_PERMISSION_TEXT, { exact: true })).toHaveCount(0); + + // `page` is deliberately part of this helper's signature so failures report + // the active URL, which is useful when the copied document unexpectedly + // navigates to a stale source view. + expect(page.url()).toContain('/app/'); +} + +async function expectNestedRowDatabaseAvailable(page: Page, outerDatabase: Locator, rowName: string): Promise { + await expectDatabaseBlockAvailable(page, outerDatabase, rowName); + + const row = outerDatabase.locator('[data-testid^="grid-row-"]').filter({ hasText: rowName }).first(); + + await expect(row).toBeVisible({ timeout: 60000 }); + await row.scrollIntoViewIfNeeded(); + await row.hover({ force: true }); + + const scopedExpandButton = row.getByTestId('row-expand-button'); + const expandButton = + (await scopedExpandButton.count()) > 0 ? scopedExpandButton.first() : page.getByTestId('row-expand-button').first(); + + await expect(expandButton).toBeVisible({ timeout: 15000 }); + await expandButton.click({ force: true }); + + const dialog = page.getByRole('dialog').last(); + + await expect(dialog).toBeVisible({ timeout: 30000 }); + + const scrollContainer = dialog.locator('.appflowy-scroll-container').last(); + + if ((await scrollContainer.count()) > 0) { + await scrollContainer.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + } + + const nestedDatabase = dialog.locator('.appflowy-database').last(); + + await expect(nestedDatabase).toBeVisible({ timeout: 60000 }); + await expect(nestedDatabase.locator('[data-testid="database-grid"]')).toBeVisible({ timeout: 60000 }); + await expect(dialog.getByText(PERMANENTLY_DELETED_TEXT, { exact: true })).toHaveCount(0); + await expect(dialog.getByText(NO_ACCESS_TEXT, { exact: true })).toHaveCount(0); + await expect(dialog.getByText(NO_PERMISSION_TEXT, { exact: true })).toHaveCount(0); +} + +async function requireDatabaseBlockIdentity(page: Page, documentViewId: string): Promise { + await expect + .poll(() => databaseBlockIdentity(page, documentViewId), { + timeout: 30000, + message: `Expected document ${documentViewId} to expose a database block identity`, + }) + .not.toBeNull(); + + const identity = await databaseBlockIdentity(page, documentViewId); + + if (!identity) { + throw new Error(`Document ${documentViewId} has no database block identity`); + } + + return identity; +} + +async function databaseBlockIdentity(page: Page, documentViewId: string): Promise { + return page.evaluate((viewId) => { + type EditorNode = { + type?: string; + data?: { + database_id?: unknown; + view_id?: unknown; + view_ids?: unknown; + parent_id?: unknown; + }; + children?: EditorNode[]; + }; + + const testWindow = window as Window & { + __TEST_EDITORS__?: Record; + }; + const editor = testWindow.__TEST_EDITORS__?.[viewId]; + const queue = [...(editor?.children ?? [])]; + + while (queue.length > 0) { + const node = queue.shift()!; + + if (node.type === 'grid') { + const data = node.data; + const databaseId = typeof data?.database_id === 'string' ? data.database_id : ''; + const scalarViewId = typeof data?.view_id === 'string' ? data.view_id : ''; + const arrayViewId = Array.isArray(data?.view_ids) + ? data.view_ids.find((value): value is string => typeof value === 'string') ?? '' + : ''; + const parentId = typeof data?.parent_id === 'string' ? data.parent_id : ''; + + if (databaseId && (scalarViewId || arrayViewId) && parentId) { + return { + databaseId, + viewId: scalarViewId || arrayViewId, + parentId, + }; + } + } + + queue.push(...(node.children ?? [])); + } + + return null; + }, documentViewId); +} + +async function getWorkspaceView( + request: APIRequestContext, + token: string, + workspaceId: string, + viewId: string, + depth: number +): Promise { + return getApi(request, token, `/api/workspace/${workspaceId}/view/${viewId}?depth=${depth}`); +} + +async function getApi(request: APIRequestContext, token: string, path: string): Promise { + const response = await request.get(`${TestConfig.apiUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0 || body.data === undefined) { + throw new Error(`GET ${path} failed with HTTP ${response.status()}: ${JSON.stringify(body)}`); + } + + return body.data; +} + +async function postApi( + request: APIRequestContext, + token: string, + path: string, + data?: Record +): Promise { + const response = await request.post(`${TestConfig.apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0 || body.data === undefined) { + throw new Error(`POST ${path} failed with HTTP ${response.status()}: ${JSON.stringify(body)}`); + } + + return body.data; +} + +async function cleanupConsumerCopies(request: APIRequestContext, state: PublishedDocumentTemplateState): Promise { + if (!state.consumerToken || !state.consumerWorkspaceId) return; + + for (const viewId of unique(state.consumerRoots)) { + await deleteWorkspaceView(request, state.consumerToken, state.consumerWorkspaceId, viewId); + } +} + +async function cleanupPublisherPages(request: APIRequestContext, state: PublishedDocumentTemplateState): Promise { + if (!state.publisherToken || !state.publisherWorkspaceId) return; + + const roots = unique([...(state.sourceDocumentId ? [state.sourceDocumentId] : []), ...state.publisherRoots]); + + for (const viewId of roots) { + await deleteWorkspaceView(request, state.publisherToken, state.publisherWorkspaceId, viewId); + } +} + +async function deleteWorkspaceView( + request: APIRequestContext, + token: string, + workspaceId: string, + viewId: string +): Promise { + const moveResponse = await request.post( + `${TestConfig.apiUrl}/api/workspace/${workspaceId}/page-view/${viewId}/move-to-trash`, + { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + } + ); + + if (moveResponse.status() === 404) return; + + const moveBody = (await moveResponse.json().catch(() => null)) as ApiResponse | null; + + if (!moveResponse.ok() || moveBody?.code !== 0) { + throw new Error(`Moving ${viewId} to trash failed with HTTP ${moveResponse.status()}: ${JSON.stringify(moveBody)}`); + } + + const deleteResponse = await request.delete(`${TestConfig.apiUrl}/api/workspace/${workspaceId}/trash/${viewId}`, { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }); + const deleteBody = (await deleteResponse.json().catch(() => null)) as ApiResponse | null; + + if (deleteResponse.status() !== 404 && (!deleteResponse.ok() || deleteBody?.code !== 0)) { + throw new Error( + `Deleting ${viewId} from trash failed with HTTP ${deleteResponse.status()}: ${JSON.stringify(deleteBody)}` + ); + } +} + +async function requireAuthToken(page: Page): Promise { + const token = await page.evaluate(() => { + const rawToken = localStorage.getItem('token'); + + if (rawToken) { + try { + const parsed = JSON.parse(rawToken) as { access_token?: string }; + + if (parsed.access_token) return parsed.access_token; + } catch { + // Fall back to the test-only token mirror below. + } + } + + return localStorage.getItem('af_auth_token') ?? ''; + }); + + if (!token) throw new Error('The signed-in browser has no access token'); + return token; +} + +function registerPublisherRoot(state: PublishedDocumentTemplateState, viewId: string): void { + if (!state.publisherRoots.includes(viewId)) { + state.publisherRoots.push(viewId); + } +} + +function workspaceIdFromAppUrl(urlValue: string): string { + const segments = new URL(urlValue).pathname.split('/').filter(Boolean); + const appIndex = segments.indexOf('app'); + const workspaceId = appIndex >= 0 ? segments[appIndex + 1] : undefined; + + if (!workspaceId) throw new Error(`Could not read a workspace id from ${urlValue}`); + return workspaceId; +} + +function findView(root: WorkspaceView, viewId: string): WorkspaceView | undefined { + if (root.view_id === viewId) return root; + + for (const child of root.children ?? []) { + const found = findView(child, viewId); + + if (found) return found; + } + + return undefined; +} + +function modifierKey(): 'Meta' | 'Control' { + return process.platform === 'darwin' ? 'Meta' : 'Control'; +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +function getState(page: Page): PublishedDocumentTemplateState { + const state = stateByPage.get(page); + + if (!state) throw new Error('Published document template dependency scenario state is missing'); + return state; +} + +function requireConsumerPage(state: PublishedDocumentTemplateState): Page { + return requireValue(state.consumerPage, 'consumer page'); +} + +function requireDuplicateResult(state: PublishedDocumentTemplateState): DuplicateResult { + return requireValue(state.duplicateResult, 'published document duplicate result'); +} + +function requireValue(value: T | undefined, label: string): T { + if (value === undefined) throw new Error(`Missing ${label}`); + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/playwright/bdd/steps/published-relation-template.steps.ts b/playwright/bdd/steps/published-relation-template.steps.ts new file mode 100644 index 000000000..b70f70f9a --- /dev/null +++ b/playwright/bdd/steps/published-relation-template.steps.ts @@ -0,0 +1,584 @@ +import { APIRequestContext, BrowserContext, expect, Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { signInAndWaitForApp, signInWithPasswordViaUi } from '../../support/auth-flow-helpers'; +import { DatabaseGridSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors'; +import { generateRandomEmail, setupPageErrorHandling, TestConfig } from '../../support/test-config'; + +const { Given, When, Then, Before, After } = createBdd(); + +const FIXTURE_EMAIL = 'pdf_db_relation@appflowy.io'; +const FIXTURE_PASSWORD = 'AppFlowy!@123'; +const FIXTURE_WORKSPACE_ID = 'd767a65f-8d86-4f34-8498-07d2e7eed114'; +const SOURCE_DATABASE_VIEW_ID = 'b92c86f7-e385-43c2-a480-cca651beba35'; +const SOURCE_GRID_VIEW_ID = 'c21f0728-18ef-404b-8b4f-909bcf4d1fdd'; +const RELATED_DATABASE_VIEW_ID = 'e20165e8-0e95-40a4-927c-8e0ee3475d47'; +const RELATED_GRID_VIEW_ID = 'acb652f6-61c2-442b-a526-1d3ecc5c96f7'; +const RELATION_CONTENT = 'Related DB content'; +const FIXTURE_PUBLISH_VIEW_IDS = [ + SOURCE_DATABASE_VIEW_ID, + SOURCE_GRID_VIEW_ID, + RELATED_DATABASE_VIEW_ID, + RELATED_GRID_VIEW_ID, +] as const; + +type ApiResponse = { + code?: number; + data?: T; + message?: string; +}; + +type FolderView = { + view_id: string; + name: string; + children?: FolderView[]; +}; + +type DuplicateResult = { + view_id: string; + database_mappings: Record; +}; + +type PublishState = Record<(typeof FIXTURE_PUBLISH_VIEW_IDS)[number], boolean>; + +type RelationTemplateState = { + publisherToken?: string; + initialPublishState?: PublishState; + publishedUrl?: string; + consumerContext?: BrowserContext; + consumerPage?: Page; + consumerToken?: string; + consumerWorkspaceId?: string; + destinationSpaceId?: string; + destinationChildrenBefore?: Set; + createdDestinationRoots: Set; + destinationDepth?: string | null; + duplicateResult?: DuplicateResult; +}; + +const stateByPage = new WeakMap(); + +Before({ tags: '@published-relation-template' }, async ({ page }) => { + setupPageErrorHandling(page); + await page.setViewportSize({ width: 1440, height: 900 }); + stateByPage.set(page, { createdDestinationRoots: new Set() }); +}); + +After({ tags: '@published-relation-template' }, async ({ page, request }) => { + const state = stateByPage.get(page); + + if (!state) return; + + const cleanupErrors: string[] = []; + + await cleanupDestinationCopies(request, state).catch((error) => { + cleanupErrors.push(`destination cleanup: ${errorMessage(error)}`); + }); + await state.consumerContext?.close().catch((error) => { + cleanupErrors.push(`consumer context cleanup: ${errorMessage(error)}`); + }); + await restoreFixturePublishState(request, state).catch((error) => { + cleanupErrors.push(`fixture publish-state restore: ${errorMessage(error)}`); + }); + + stateByPage.delete(page); + + if (cleanupErrors.length > 0) { + throw new Error(`Relation template teardown failed:\n${cleanupErrors.join('\n')}`); + } +}); + +Given('the seeded relation template fixture exists', async () => { + // Fixture source: + // AppFlowy-Cloud-Premium/backup/README.md (`pdf_db_relation@appflowy.io`) + // Server regression: + // tests/workspace/publish/duplication_test.rs:: + // publishing_only_database_with_relation_includes_related_database_in_template +}); + +Given('I sign in as the relation template fixture publisher', async ({ page, request }) => { + const state = getState(page); + + await signInWithPasswordViaUi(page, FIXTURE_EMAIL, FIXTURE_PASSWORD, 2000); + await expect(page).toHaveURL(/\/app\//, { timeout: 30000 }); + await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 }); + + state.publisherToken = await requireAuthToken(page); + state.initialPublishState = Object.fromEntries( + await Promise.all(FIXTURE_PUBLISH_VIEW_IDS.map(async (viewId) => [viewId, await isPublished(request, viewId)])) + ) as PublishState; + + // Both databases must begin unpublished. Otherwise an old server can reuse an + // already-published relation target and conceal the auto-publish regression. + for (const viewId of FIXTURE_PUBLISH_VIEW_IDS) { + await setPublished(request, state.publisherToken, viewId, false); + } +}); + +Given('the seeded relation cell resolves before publishing', async ({ page }) => { + await openFixtureView(page, RELATED_GRID_VIEW_ID); + await expect(DatabaseGridSelectors.grid(page)).toContainText(RELATION_CONTENT, { timeout: 30000 }); + + // Opening the related database first primes a fresh browser's local collab + // cache, matching the fixture's existing relation-cell integration test. + await openFixtureView(page, SOURCE_GRID_VIEW_ID); + await expect(page.locator('.relation-cell').first()).toContainText(RELATION_CONTENT, { + timeout: 30000, + }); +}); + +When('I publish only the seeded source database as a template', async ({ page, request }) => { + const state = getState(page); + + await expect(ShareSelectors.shareButton(page)).toBeVisible({ timeout: 30000 }); + await ShareSelectors.shareButton(page).click({ force: true }); + + const sharePopover = ShareSelectors.sharePopover(page); + + await expect(sharePopover).toBeVisible({ timeout: 15000 }); + await sharePopover.getByText('Publish', { exact: true }).click({ force: true }); + + const publishButton = ShareSelectors.publishConfirmButton(page); + + await expect(publishButton).toBeEnabled({ timeout: 30000 }); + + const publishResponsePromise = page.waitForResponse( + (response) => { + const url = new URL(response.url()); + + return response.request().method() === 'POST' && url.pathname === `/api/workspace/${FIXTURE_WORKSPACE_ID}/publish`; + }, + { timeout: 60000 } + ); + + await publishButton.click({ force: true }); + + const publishResponse = await publishResponsePromise; + + expect( + publishResponse.ok(), + `Publishing the relation fixture failed with HTTP ${publishResponse.status()}` + ).toBeTruthy(); + await expect(ShareSelectors.publishNamespace(page)).toBeVisible({ timeout: 60000 }); + + const namespace = ((await ShareSelectors.publishNamespace(page).textContent()) ?? '').trim(); + const publishName = (await ShareSelectors.publishNameInput(page).inputValue()).trim(); + + expect(namespace, 'Expected the fixture publisher to have a publish namespace').not.toBe(''); + expect(publishName, 'Expected the source database to have a publish name').not.toBe(''); + + state.publishedUrl = `${new URL(page.url()).origin}/${namespace}/${publishName}`; + + await expect + .poll(() => isPublished(request, SOURCE_DATABASE_VIEW_ID), { + timeout: 30000, + message: 'Expected the source database to be published', + }) + .toBe(true); +}); + +When('another account opens the published relation template', async ({ page, request, browser }) => { + const state = getState(page); + const publishedUrl = requirePublishedUrl(state); + const consumerContext = await browser.newContext({ + baseURL: new URL(publishedUrl).origin, + viewport: { width: 1440, height: 900 }, + }); + const consumerPage = await consumerContext.newPage(); + + state.consumerContext = consumerContext; + state.consumerPage = consumerPage; + setupPageErrorHandling(consumerPage); + + await signInAndWaitForApp(consumerPage, request, generateRandomEmail()); + + state.consumerToken = await requireAuthToken(consumerPage); + state.consumerWorkspaceId = workspaceIdFromAppUrl(consumerPage.url()); + + const destinationFolder = await getWorkspaceFolder(request, state.consumerToken, state.consumerWorkspaceId); + const generalSpace = destinationFolder.children?.find((view) => view.name === 'General'); + + if (!generalSpace) { + throw new Error('The consumer workspace does not contain the General space'); + } + + state.destinationSpaceId = generalSpace.view_id; + state.destinationChildrenBefore = new Set((generalSpace.children ?? []).map((view) => view.view_id)); + + await consumerPage.goto(publishedUrl, { waitUntil: 'domcontentloaded' }); + await expect(consumerPage.getByRole('button', { name: 'Start with this template' })).toBeVisible({ + timeout: 60000, + }); + await expect(DatabaseGridSelectors.grid(consumerPage)).toBeVisible({ timeout: 60000 }); +}); + +Then('the published relation cell shows {string}', async ({ page }, expectedContent: string) => { + const consumerPage = requireConsumerPage(getState(page)); + + await expect(consumerPage.locator('.relation-cell').first()).toContainText(expectedContent, { + timeout: 60000, + }); +}); + +When('that account starts with the relation template in {string}', async ({ page, request }, spaceName: string) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const consumerWorkspaceId = requireValue(state.consumerWorkspaceId, 'consumer workspace id'); + + const spaceResponsePromise = consumerPage.waitForResponse( + (response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'GET' && + url.pathname === `/api/workspace/${consumerWorkspaceId}/view/${consumerWorkspaceId}` + ); + }, + { timeout: 60000 } + ); + + await consumerPage.getByRole('button', { name: 'Start with this template' }).click(); + + const spaceResponse = await spaceResponsePromise; + + expect(spaceResponse.ok(), `Loading destination spaces failed with HTTP ${spaceResponse.status()}`).toBeTruthy(); + state.destinationDepth = new URL(spaceResponse.url()).searchParams.get('depth'); + + const destinationDialog = consumerPage.getByRole('dialog').filter({ hasText: 'Where would you like to add' }).last(); + + await expect(destinationDialog).toBeVisible({ timeout: 30000 }); + + const destinationSpace = destinationDialog.getByTestId('space-item').filter({ hasText: spaceName }).first(); + + await expect(destinationSpace).toBeVisible({ timeout: 30000 }); + await destinationSpace.click(); + + const addButton = destinationDialog.getByRole('button', { name: 'Add', exact: true }); + + await expect(addButton).toBeEnabled({ timeout: 15000 }); + + const duplicateResponsePromise = consumerPage.waitForResponse( + (response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'POST' && + url.pathname === `/api/workspace/${consumerWorkspaceId}/published-duplicate` + ); + }, + { timeout: 60000 } + ); + + await addButton.click(); + + const duplicateResponse = await duplicateResponsePromise; + const responseBody = (await duplicateResponse.json().catch(() => null)) as ApiResponse | null; + + expect( + duplicateResponse.ok() && responseBody?.code === 0 && responseBody.data, + `Relation template duplication failed with HTTP ${duplicateResponse.status()}: ${JSON.stringify(responseBody)}` + ).toBeTruthy(); + + state.duplicateResult = responseBody!.data!; + await recordCreatedDestinationRoots(request, state); + + const openInBrowser = consumerPage.getByRole('button', { name: /Open in browser/i }); + + await expect(openInBrowser).toBeVisible({ timeout: 30000 }); + await openInBrowser.click(); + await expect(consumerPage).toHaveURL(/\/app\//, { timeout: 30000 }); + await expect(DatabaseGridSelectors.grid(consumerPage)).toBeVisible({ timeout: 60000 }); +}); + +Then('the destination space request uses depth 2', async ({ page }) => { + expect(getState(page).destinationDepth).toBe('2'); +}); + +Then('the relation template duplication contains {int} database mappings', async ({ page }, count: number) => { + const result = requireDuplicateResult(getState(page)); + + expect(Object.keys(result.database_mappings)).toHaveLength(count); +}); + +Then('the duplicated relation database is named {string}', async ({ page, request }, expectedName: string) => { + const state = getState(page); + const result = requireDuplicateResult(state); + const token = requireValue(state.consumerToken, 'consumer token'); + const workspaceId = requireValue(state.consumerWorkspaceId, 'consumer workspace id'); + const duplicatedDatabaseViewIds = new Set(Object.values(result.database_mappings).flat()); + + await expect + .poll( + async () => { + const folder = await getWorkspaceFolder(request, token, workspaceId); + + return [...duplicatedDatabaseViewIds] + .map((viewId) => findView(folder, viewId)?.name) + .filter((name): name is string => name !== undefined); + }, + { + timeout: 30000, + message: `Expected a duplicated relation database view named ${expectedName}`, + } + ) + .toContain(expectedName); +}); + +Then('the duplicated relation cell shows {string}', async ({ page }, expectedContent: string) => { + const consumerPage = requireConsumerPage(getState(page)); + + await expect(consumerPage.locator('.relation-cell').first()).toContainText(expectedContent, { + timeout: 60000, + }); +}); + +Then('the duplicated relation cell does not show {string}', async ({ page }, inaccessibleText: string) => { + const consumerPage = requireConsumerPage(getState(page)); + const relationCell = consumerPage.locator('.relation-cell').first(); + + await expect(relationCell).not.toContainText(inaccessibleText); + await expect(consumerPage.getByText(inaccessibleText, { exact: true })).toHaveCount(0); +}); + +When('I clear the duplication mappings and reload the duplicated relation template', async ({ page }) => { + const state = getState(page); + const consumerPage = requireConsumerPage(state); + const workspaceId = requireValue(state.consumerWorkspaceId, 'consumer workspace id'); + + // Remove both client-side mapping sources to reproduce a later direct visit. + // The relation must still resolve from refreshed workspace metadata. + await consumerPage.evaluate((workspaceId) => { + const url = new URL(window.location.href); + + url.searchParams.delete('db_mappings'); + localStorage.removeItem(`db_mappings_${workspaceId}`); + window.history.replaceState(null, '', url); + }, workspaceId); + await consumerPage.reload({ waitUntil: 'domcontentloaded' }); + await expect(DatabaseGridSelectors.grid(consumerPage)).toBeVisible({ timeout: 60000 }); +}); + +async function openFixtureView(page: Page, viewId: string): Promise { + await page.goto(`/app/${FIXTURE_WORKSPACE_ID}/${viewId}`, { waitUntil: 'domcontentloaded' }); + await expect(DatabaseGridSelectors.grid(page)).toBeVisible({ timeout: 60000 }); + await expect(DatabaseGridSelectors.cells(page).first()).toBeVisible({ timeout: 60000 }); +} + +async function getWorkspaceFolder(request: APIRequestContext, token: string, workspaceId: string): Promise { + // A destination selector only needs the workspace, its spaces, and each + // space's immediate children, so this intentionally mirrors the web request. + return getApi(request, token, `/api/workspace/${workspaceId}/view/${workspaceId}?depth=2`); +} + +async function recordCreatedDestinationRoots(request: APIRequestContext, state: RelationTemplateState): Promise { + const token = requireValue(state.consumerToken, 'consumer token'); + const workspaceId = requireValue(state.consumerWorkspaceId, 'consumer workspace id'); + const destinationSpaceId = requireValue(state.destinationSpaceId, 'destination space id'); + const before = state.destinationChildrenBefore; + + if (!before) throw new Error('Destination child baseline is missing'); + + await expect + .poll( + async () => { + const folder = await getWorkspaceFolder(request, token, workspaceId); + const destination = findView(folder, destinationSpaceId); + const created = (destination?.children ?? []).filter((view) => !before.has(view.view_id)); + + state.createdDestinationRoots = new Set(created.map((view) => view.view_id)); + return created.length; + }, + { + timeout: 30000, + message: 'Expected the duplicated template to create destination pages', + } + ) + .toBeGreaterThan(0); +} + +async function cleanupDestinationCopies(request: APIRequestContext, state: RelationTemplateState): Promise { + if ( + !state.consumerToken || + !state.consumerWorkspaceId || + !state.destinationSpaceId || + !state.destinationChildrenBefore + ) { + return; + } + + const folder = await getWorkspaceFolder(request, state.consumerToken, state.consumerWorkspaceId); + const destination = findView(folder, state.destinationSpaceId); + + for (const child of destination?.children ?? []) { + if (!state.destinationChildrenBefore.has(child.view_id)) { + state.createdDestinationRoots.add(child.view_id); + } + } + + for (const viewId of state.createdDestinationRoots) { + await postVoid( + request, + state.consumerToken, + `/api/workspace/${state.consumerWorkspaceId}/page-view/${viewId}/move-to-trash` + ); + await deleteVoid(request, state.consumerToken, `/api/workspace/${state.consumerWorkspaceId}/trash/${viewId}`); + } +} + +async function restoreFixturePublishState(request: APIRequestContext, state: RelationTemplateState): Promise { + if (!state.publisherToken || !state.initialPublishState) return; + + // Reconcile the source first and relation target last because publishing A + // can auto-publish B. A second pass handles parent/child side effects. + for (let pass = 0; pass < 2; pass += 1) { + for (const viewId of FIXTURE_PUBLISH_VIEW_IDS) { + await setPublished(request, state.publisherToken, viewId, state.initialPublishState[viewId]); + } + } +} + +async function setPublished( + request: APIRequestContext, + token: string, + viewId: string, + expected: boolean +): Promise { + if ((await isPublished(request, viewId)) === expected) return; + + const action = expected ? 'publish' : 'unpublish'; + const data = expected ? { comments_enabled: true, duplicate_enabled: true } : undefined; + + await postVoid(request, token, `/api/workspace/${FIXTURE_WORKSPACE_ID}/page-view/${viewId}/${action}`, data); + await expect + .poll(() => isPublished(request, viewId), { + timeout: 30000, + message: `Expected fixture view ${viewId} published=${expected}`, + }) + .toBe(expected); +} + +async function isPublished(request: APIRequestContext, viewId: string): Promise { + const response = await request.get(`${TestConfig.apiUrl}/api/workspace/v1/published-info/${viewId}`, { + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + return response.ok() && body?.code === 0 && body.data !== undefined; +} + +async function getApi(request: APIRequestContext, token: string, path: string): Promise { + const response = await request.get(`${TestConfig.apiUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0 || body.data === undefined) { + throw new Error(`GET ${path} failed with HTTP ${response.status()}: ${JSON.stringify(body)}`); + } + + return body.data; +} + +async function postVoid( + request: APIRequestContext, + token: string, + path: string, + data?: Record +): Promise { + const response = await request.post(`${TestConfig.apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0) { + throw new Error(`POST ${path} failed with HTTP ${response.status()}: ${JSON.stringify(body)}`); + } +} + +async function deleteVoid(request: APIRequestContext, token: string, path: string): Promise { + const response = await request.delete(`${TestConfig.apiUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0) { + throw new Error(`DELETE ${path} failed with HTTP ${response.status()}: ${JSON.stringify(body)}`); + } +} + +async function requireAuthToken(page: Page): Promise { + const token = await page.evaluate(() => { + const rawToken = localStorage.getItem('token'); + + if (rawToken) { + try { + const parsed = JSON.parse(rawToken) as { access_token?: string }; + + if (parsed.access_token) return parsed.access_token; + } catch { + // Fall back to the test-only token mirror below. + } + } + + return localStorage.getItem('af_auth_token') ?? ''; + }); + + if (!token) throw new Error('The signed-in browser has no access token'); + return token; +} + +function workspaceIdFromAppUrl(urlValue: string): string { + const segments = new URL(urlValue).pathname.split('/').filter(Boolean); + const appIndex = segments.indexOf('app'); + const workspaceId = appIndex >= 0 ? segments[appIndex + 1] : undefined; + + if (!workspaceId) throw new Error(`Could not read a workspace id from ${urlValue}`); + return workspaceId; +} + +function findView(root: FolderView, viewId: string): FolderView | undefined { + if (root.view_id === viewId) return root; + + for (const child of root.children ?? []) { + const found = findView(child, viewId); + + if (found) return found; + } + + return undefined; +} + +function getState(page: Page): RelationTemplateState { + const state = stateByPage.get(page); + + if (!state) throw new Error('Published relation template scenario state is missing'); + return state; +} + +function requirePublishedUrl(state: RelationTemplateState): string { + return requireValue(state.publishedUrl, 'published relation template URL'); +} + +function requireConsumerPage(state: RelationTemplateState): Page { + return requireValue(state.consumerPage, 'consumer page'); +} + +function requireDuplicateResult(state: RelationTemplateState): DuplicateResult { + return requireValue(state.duplicateResult, 'relation template duplicate result'); +} + +function requireValue(value: T | undefined, label: string): T { + if (value === undefined) throw new Error(`Missing ${label}`); + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 85781db3b..827b34c61 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -3220,6 +3220,8 @@ "duplicateTitle": "Where would you like to add", "selectWorkspace": "Select a workspace", "addTo": "Add to", + "loadSpacesFailed": "Couldn't load spaces.", + "noSpacesAvailable": "No spaces available.", "duplicateSuccessfully": "Added to your workspace", "duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.", "downloadIt": "Download", diff --git a/src/application/publish/__tests__/context.test.tsx b/src/application/publish/__tests__/context.test.tsx index d76703bd8..7ccc36af0 100644 --- a/src/application/publish/__tests__/context.test.tsx +++ b/src/application/publish/__tests__/context.test.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; +import { getRowKey } from '@/application/database-yjs/row_meta'; import { normalizePublishedPageSnapshot } from '@/application/publish-snapshot/normalize'; import { getPublishedDatabaseRenderRowMap } from '@/application/publish-snapshot/database-yjs-render-bridge'; import { @@ -9,6 +10,7 @@ import { publishedRowDocumentId, } from '@/application/publish-snapshot/__fixtures__/published-page-snapshots'; import { PublishContextType, PublishProvider, usePublishContext } from '@/application/publish'; +import { RowService } from '@/application/services/domains'; import { YjsEditorKey } from '@/application/types'; import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert'; @@ -180,12 +182,18 @@ describe('PublishProvider', () => { const doc = await latestContext?.loadView(relatedSnapshot.view.viewId); const database = doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database); + const publishedRows = getPublishedDatabaseRenderRowMap(doc) ?? {}; + const publishedRow = await latestContext?.createRow?.( + getRowKey(relatedSnapshot.database.databaseId, 'published-row-id') + ); expect(mockGetPage).toHaveBeenCalledWith(relatedSnapshot.namespace, relatedSnapshot.publishName); expect(mockGetView).not.toHaveBeenCalled(); expect(doc?.guid).toBe(relatedSnapshot.database.databaseId); expect(database).toBeDefined(); - expect(Object.keys(getPublishedDatabaseRenderRowMap(doc) ?? {})).toEqual(['published-row-id']); + expect(Object.keys(publishedRows)).toEqual(['published-row-id']); + expect(publishedRow).toBe(publishedRows['published-row-id']); + expect(RowService.create).not.toHaveBeenCalled(); }); it('loads published row documents from the related database JSON snapshot', async () => { diff --git a/src/application/publish/context.tsx b/src/application/publish/context.tsx index d231f93ac..391e7d03d 100644 --- a/src/application/publish/context.tsx +++ b/src/application/publish/context.tsx @@ -88,14 +88,6 @@ function findViewInfoById(views: ViewInfo[] | null | undefined, viewId: string): } } -function snapshotToRenderDoc(snapshot: PublishedPageSnapshot) { - if (snapshot.kind === 'database') { - return createDatabaseYjsRenderDocsFromSnapshot(snapshot).doc; - } - - return createDocumentYjsRenderDocFromSnapshot(snapshot); -} - function rowDocumentsFromSnapshot(snapshot: PublishedPageSnapshot): Record { if (snapshot.kind !== 'database') return {}; @@ -146,12 +138,24 @@ export const PublishProvider = ({ const [snapshotDataSource] = useState(() => createPublishSnapshotDataSource()); const rowDocumentSnapshotsRef = useRef>({}); const rowDocumentDocsRef = useRef>(new Map()); + const databaseRowDocsRef = useRef>(new Map()); const viewMetaSubscribersRef = useRef void>>(new Map()); const registerSnapshotRowDocuments = useCallback((snapshot: PublishedPageSnapshot) => { Object.assign(rowDocumentSnapshotsRef.current, rowDocumentsFromSnapshot(snapshot)); }, []); + const createSnapshotRenderDoc = useCallback((snapshot: PublishedPageSnapshot) => { + if (snapshot.kind === 'database') { + const { doc, rowMap } = createDatabaseYjsRenderDocsFromSnapshot(snapshot); + + Object.values(rowMap).forEach((rowDoc) => databaseRowDocsRef.current.set(rowDoc.guid, rowDoc)); + return doc; + } + + return createDocumentYjsRenderDocFromSnapshot(snapshot); + }, []); + const snapshotViewMeta = useMemo(() => { return snapshot ? publishedSnapshotToViewMeta(snapshot) : undefined; }, [snapshot]); @@ -218,6 +222,7 @@ export const PublishProvider = ({ useEffect(() => { rowDocumentSnapshotsRef.current = {}; rowDocumentDocsRef.current.clear(); + databaseRowDocsRef.current.clear(); if (snapshot) { registerSnapshotRowDocuments(snapshot); @@ -429,6 +434,10 @@ export const PublishProvider = ({ const createRow = useCallback( async (rowKey: string) => { try { + const snapshotRow = databaseRowDocsRef.current.get(rowKey); + + if (snapshotRow) return snapshotRow; + const doc = await RowService.create(rowKey); if (!doc) { @@ -473,7 +482,7 @@ export const PublishProvider = ({ try { if (snapshot?.view.viewId === viewId) { - return snapshotToRenderDoc(snapshot); + return createSnapshotRenderDoc(snapshot); } const res = await PublishService.getViewInfo(viewId); @@ -492,12 +501,12 @@ export const PublishProvider = ({ registerSnapshotRowDocuments(data); - return snapshotToRenderDoc(data); + return createSnapshotRenderDoc(data); } catch (e) { return Promise.reject(e); } }, - [loadRowDocument, registerSnapshotRowDocuments, snapshot, snapshotDataSource] + [createSnapshotRenderDoc, loadRowDocument, registerSnapshotRowDocuments, snapshot, snapshotDataSource] ); const onRendered = useCallback(() => { diff --git a/src/application/services/js-services/http/workspace-api.ts b/src/application/services/js-services/http/workspace-api.ts index efdc03b19..8bd26b148 100644 --- a/src/application/services/js-services/http/workspace-api.ts +++ b/src/application/services/js-services/http/workspace-api.ts @@ -120,8 +120,8 @@ function iterateFolder(folder: WorkspaceFolder): FolderView { }; } -export async function getWorkspaceFolder(workspaceId: string): Promise { - const url = `/api/workspace/${workspaceId}/view/${workspaceId}?depth=50`; +export async function getWorkspaceFolder(workspaceId: string, depth = 50): Promise { + const url = `/api/workspace/${workspaceId}/view/${workspaceId}?depth=${depth}`; const payload = await executeAPIRequest(() => getAxios()?.get>(url) ); diff --git a/src/components/app/hooks/__tests__/useDatabaseIdentity.test.ts b/src/components/app/hooks/__tests__/useDatabaseIdentity.test.ts new file mode 100644 index 000000000..89415f023 --- /dev/null +++ b/src/components/app/hooks/__tests__/useDatabaseIdentity.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react'; + +import type { SyncContextType } from '@/components/ws/useSync'; + +import { useDatabaseIdentity } from '../useDatabaseIdentity'; + +jest.mock('@/application/db', () => ({ + openCollabDB: jest.fn(), +})); + +jest.mock('@/application/view-loader', () => ({ + getDatabaseIdFromDoc: jest.fn(), +})); + +const WORKSPACE_ID = 'workspace-1'; +const DATABASE_ID = 'database-1'; +const PRIMARY_VIEW_ID = 'view-1'; +const SECONDARY_VIEW_ID = 'view-2'; +const STORAGE_KEY = `db_mappings_${WORKSPACE_ID}`; +const DATABASE_MAPPINGS = { + [DATABASE_ID]: [PRIMARY_VIEW_ID, SECONDARY_VIEW_ID], +}; + +const registerSyncContext = jest.fn() as unknown as SyncContextType['registerSyncContext']; + +type LoadDatabaseRelations = (options?: { refresh?: boolean }) => Promise | undefined>; + +function renderDatabaseIdentity(loadDatabaseRelations?: LoadDatabaseRelations) { + const params: Parameters[0] = { + currentWorkspaceId: WORKSPACE_ID, + registerSyncContext, + loadDatabaseRelations, + }; + + return renderHook(() => + useDatabaseIdentity(params) + ); +} + +describe('useDatabaseIdentity', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, '', `/app/${WORKSPACE_ID}/page`); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves a database view from template duplication URL mappings', async () => { + const encodedMappings = encodeURIComponent(JSON.stringify(DATABASE_MAPPINGS)); + + window.history.replaceState({}, '', `/app/${WORKSPACE_ID}/page?db_mappings=${encodedMappings}`); + + const { result } = renderDatabaseIdentity(); + + await expect(result.current.getViewIdFromDatabaseId(DATABASE_ID)).resolves.toBe(PRIMARY_VIEW_ID); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual(DATABASE_MAPPINGS); + }); + + it('uses URL mappings when localStorage persistence is unavailable', async () => { + const storageError = new DOMException('Storage is disabled', 'SecurityError'); + + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw storageError; + }); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const encodedMappings = encodeURIComponent(JSON.stringify(DATABASE_MAPPINGS)); + + window.history.replaceState({}, '', `/app/${WORKSPACE_ID}/page?db_mappings=${encodedMappings}`); + + const { result } = renderDatabaseIdentity(); + + await expect(result.current.getViewIdFromDatabaseId(DATABASE_ID)).resolves.toBe(PRIMARY_VIEW_ID); + }); + + it('resolves a database view from persisted template mappings after reload', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(DATABASE_MAPPINGS)); + + const { result } = renderDatabaseIdentity(); + + await expect(result.current.getViewIdFromDatabaseId(DATABASE_ID)).resolves.toBe(PRIMARY_VIEW_ID); + }); + + it('refreshes workspace relation metadata when the synced mapping is unavailable', async () => { + const loadDatabaseRelations = jest + .fn, Parameters>() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ [DATABASE_ID]: PRIMARY_VIEW_ID }); + const { result } = renderDatabaseIdentity(loadDatabaseRelations); + + await expect(result.current.getViewIdFromDatabaseId(DATABASE_ID)).resolves.toBe(PRIMARY_VIEW_ID); + expect(loadDatabaseRelations).toHaveBeenNthCalledWith(1); + expect(loadDatabaseRelations).toHaveBeenNthCalledWith(2, { refresh: true }); + }); +}); diff --git a/src/components/app/hooks/useDatabaseIdentity.ts b/src/components/app/hooks/useDatabaseIdentity.ts index 6c37c5f26..595c3d95a 100644 --- a/src/components/app/hooks/useDatabaseIdentity.ts +++ b/src/components/app/hooks/useDatabaseIdentity.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react'; import { openCollabDB } from '@/application/db'; -import { DatabaseId, Types, ViewId, YDoc, YjsEditorKey } from '@/application/types'; +import { DatabaseId, DatabaseRelations, Types, ViewId, YDoc, YjsEditorKey } from '@/application/types'; import { getDatabaseIdFromDoc } from '@/application/view-loader'; import type { SyncContextType } from '@/components/ws/useSync'; import { Log } from '@/utils/log'; @@ -10,8 +10,70 @@ type UseDatabaseIdentityParams = { currentWorkspaceId?: string; databaseStorageId?: string; registerSyncContext: SyncContextType['registerSyncContext']; + loadDatabaseRelations?: (options?: { refresh?: boolean }) => Promise; }; +type DatabaseMappings = Record; + +function parseDatabaseMappings(value: string): DatabaseMappings { + const parsed = JSON.parse(value) as unknown; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [DatabaseId, ViewId[]] => + Array.isArray(entry[1]) && entry[1].every((viewId) => typeof viewId === 'string') + ) + ); +} + +function getTemplateDatabaseMappings(workspaceId: string): DatabaseMappings { + const storageKey = `db_mappings_${workspaceId}`; + let storedMappings: DatabaseMappings = {}; + + try { + const cachedMappings = localStorage.getItem(storageKey); + + if (cachedMappings) { + storedMappings = parseDatabaseMappings(cachedMappings); + } + } catch (e) { + console.warn('[useDatabaseIdentity] failed to read db_mappings from localStorage', e); + } + + let urlMappings: DatabaseMappings; + + try { + const dbMappingsParam = new URLSearchParams(window.location.search).get('db_mappings'); + + if (!dbMappingsParam) { + return storedMappings; + } + + urlMappings = parseDatabaseMappings(dbMappingsParam); + } catch (e) { + console.warn('[useDatabaseIdentity] failed to parse db_mappings from URL', e); + return storedMappings; + } + + const mergedMappings = { ...storedMappings, ...urlMappings }; + + try { + localStorage.setItem(storageKey, JSON.stringify(mergedMappings)); + Log.debug('[useDatabaseIdentity] stored db_mappings to localStorage', mergedMappings); + } catch (e) { + // URL mappings are the authoritative source for this navigation. Storage + // persistence is best-effort and must not break relation rendering when + // localStorage is unavailable or full. + console.warn('[useDatabaseIdentity] failed to persist db_mappings to localStorage', e); + } + + return mergedMappings; +} + /** * Encapsulates database-specific collab identity mapping. * @@ -27,6 +89,7 @@ export function useDatabaseIdentity({ currentWorkspaceId, databaseStorageId, registerSyncContext, + loadDatabaseRelations, }: UseDatabaseIdentityParams) { const workspaceDatabaseDocMapRef = useRef>(new Map()); const databaseIdViewIdMapRef = useRef>(new Map()); @@ -53,51 +116,15 @@ export function useDatabaseIdentity({ async (viewId: string) => { if (!currentWorkspaceId) return; - // First check URL params for database mappings (passed from template duplication) - // This allows immediate lookup without waiting for workspace database sync - try { - const urlParams = new URLSearchParams(window.location.search); - const dbMappingsParam = urlParams.get('db_mappings'); - - if (dbMappingsParam) { - const dbMappings: Record = JSON.parse(decodeURIComponent(dbMappingsParam)); - // Store in localStorage for persistence across page refreshes - const storageKey = `db_mappings_${currentWorkspaceId}`; - const existingMappings = JSON.parse(localStorage.getItem(storageKey) || '{}'); - const mergedMappings = { ...existingMappings, ...dbMappings }; - - localStorage.setItem(storageKey, JSON.stringify(mergedMappings)); - Log.debug('[useDatabaseIdentity] stored db_mappings to localStorage', mergedMappings); - - // Find the database ID that contains this view - for (const [databaseId, viewIds] of Object.entries(dbMappings)) { - if (viewIds.includes(viewId)) { - Log.debug('[useDatabaseIdentity] found databaseId from URL params', { viewId, databaseId }); - return databaseId; - } - } - } - } catch (e) { - console.warn('[useDatabaseIdentity] failed to parse db_mappings from URL', e); - } - - // Check localStorage for cached database mappings (persists across page refreshes) - try { - const storageKey = `db_mappings_${currentWorkspaceId}`; - const cachedMappings = localStorage.getItem(storageKey); - - if (cachedMappings) { - const dbMappings: Record = JSON.parse(cachedMappings); + // Template duplication mappings are available immediately and persist + // across reloads, so prefer them over waiting for workspace sync. + const databaseMappings = getTemplateDatabaseMappings(currentWorkspaceId); - for (const [databaseId, viewIds] of Object.entries(dbMappings)) { - if (viewIds.includes(viewId)) { - Log.debug('[useDatabaseIdentity] found databaseId from localStorage', { viewId, databaseId }); - return databaseId; - } - } + for (const [databaseId, viewIds] of Object.entries(databaseMappings)) { + if (viewIds.includes(viewId)) { + Log.debug('[useDatabaseIdentity] found databaseId from template mappings', { viewId, databaseId }); + return databaseId; } - } catch (e) { - console.warn('[useDatabaseIdentity] failed to read db_mappings from localStorage', e); } if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { @@ -182,6 +209,39 @@ export function useDatabaseIdentity({ return databaseIdViewIdMapRef.current.get(databaseId) || null; } + const mappedViewId = getTemplateDatabaseMappings(currentWorkspaceId)[databaseId]?.[0]; + + if (mappedViewId) { + databaseIdViewIdMapRef.current.set(databaseId, mappedViewId); + Log.debug('[useDatabaseIdentity] found viewId from template mappings', { databaseId, viewId: mappedViewId }); + return mappedViewId; + } + + if (loadDatabaseRelations) { + try { + let databaseRelations = await loadDatabaseRelations(); + let relatedViewId = databaseRelations?.[databaseId]; + + // The workspace cache can predate a template duplication. Refresh + // once before falling back to the eventually-consistent sync doc. + if (!relatedViewId && databaseRelations) { + databaseRelations = await loadDatabaseRelations({ refresh: true }); + relatedViewId = databaseRelations?.[databaseId]; + } + + if (relatedViewId) { + databaseIdViewIdMapRef.current.set(databaseId, relatedViewId); + Log.debug('[useDatabaseIdentity] found viewId from workspace relation metadata', { + databaseId, + viewId: relatedViewId, + }); + return relatedViewId; + } + } catch (e) { + Log.warn('[useDatabaseIdentity] failed to load workspace relation metadata', e); + } + } + // Lazy-load workspace database doc if not yet registered (e.g. after page refresh). // This mirrors the logic in getDatabaseIdForViewId. if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { @@ -267,7 +327,7 @@ export function useDatabaseIdentity({ }, 10000); }); }, - [currentWorkspaceId, databaseStorageId, registerWorkspaceDatabaseDoc] + [currentWorkspaceId, databaseStorageId, loadDatabaseRelations, registerWorkspaceDatabaseDoc] ); const resolveCollabObjectId = useCallback( diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index 70a0b9b8b..765f60e11 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -7,6 +7,7 @@ import { APP_EVENTS } from '@/application/constants'; import { CollabService, ViewService, WorkspaceService } from '@/application/services/domains'; import { AccessLevel, + DatabaseRelations, LoadViewOptions, Types, View, @@ -69,7 +70,11 @@ export function getViewReadOnlyStatus(viewId: string, outline?: View[], fallback } // Hook for managing view-related operations -export function useViewOperations() { +export function useViewOperations({ + loadDatabaseRelations, +}: { + loadDatabaseRelations?: (options?: { refresh?: boolean }) => Promise; +} = {}) { const { currentWorkspaceId, userWorkspaceInfo } = useAuthInternal(); const { registerSyncContext, eventEmitter } = useSyncInternal(); const navigate = useNavigate(); @@ -109,6 +114,7 @@ export function useViewOperations() { currentWorkspaceId, databaseStorageId, registerSyncContext, + loadDatabaseRelations, }); // Check if view should be readonly based on access permissions diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx index 841588149..61d128d88 100644 --- a/src/components/app/layers/AppBusinessLayer.tsx +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -192,7 +192,7 @@ export const AppBusinessLayer: FC = ({ children }) => { bindViewSync, getCollabHistory, previewCollabVersion, - } = useViewOperations(); + } = useViewOperations({ loadDatabaseRelations }); // Initialize row operations const { createRow } = useRowOperations(); diff --git a/src/components/database/components/property/relation/RelationCreationDialog.test.tsx b/src/components/database/components/property/relation/RelationCreationDialog.test.tsx new file mode 100644 index 000000000..f3dde9560 --- /dev/null +++ b/src/components/database/components/property/relation/RelationCreationDialog.test.tsx @@ -0,0 +1,281 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { useDatabaseContext } from '@/application/database-yjs'; +import { getMultiple as getViews } from '@/application/services/domains/view'; +import { View, ViewLayout } from '@/application/types'; +import { RelationCreationDialog } from '@/components/database/components/property/relation/RelationCreationDialog'; + +import type { ReactNode } from 'react'; + +jest.mock('@/application/database-yjs', () => ({ + useDatabaseContext: jest.fn(), +})); + +jest.mock('@/application/services/domains/view', () => ({ + getMultiple: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + }), +})); + +jest.mock('@/components/_shared/modal', () => ({ + NormalModal: ({ children, open }: { children: ReactNode; open: boolean }) => + open ?
{children}
: null, +})); + +jest.mock('@/components/database/components/property/relation/RelationView', () => ({ + RelationView: ({ view }: { view: View }) => {view.name}, +})); + +function makeView({ + viewId, + name, + databaseId, + parentViewId, + isContainer = false, +}: { + viewId: string; + name: string; + databaseId: string; + parentViewId?: string; + isContainer?: boolean; +}): View { + return { + view_id: viewId, + parent_view_id: parentViewId, + name, + layout: ViewLayout.Grid, + children: [], + icon: null, + extra: { + database_id: databaseId, + is_database_container: isContainer, + }, + is_published: false, + is_private: false, + }; +} + +describe('RelationCreationDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders and searches relation targets by database container name', async () => { + const currentGrid = makeView({ + viewId: 'current-grid', + name: 'Grid', + databaseId: 'current-database', + parentViewId: 'current-container', + }); + const currentContainer = makeView({ + viewId: 'current-container', + name: 'To-dos', + databaseId: 'current-database', + isContainer: true, + }); + const relatedGrid = makeView({ + viewId: 'related-grid', + name: 'Grid', + databaseId: 'related-database', + parentViewId: 'related-container', + }); + const relatedContainer = makeView({ + viewId: 'related-container', + name: 'Product roadmap', + databaseId: 'related-database', + isContainer: true, + }); + const viewsById: Record = { + [currentGrid.view_id]: currentGrid, + [currentContainer.view_id]: currentContainer, + [relatedGrid.view_id]: relatedGrid, + [relatedContainer.view_id]: relatedContainer, + }; + const loadViewMeta = jest.fn(async (viewId: string) => viewsById[viewId] ?? null); + + (getViews as jest.MockedFunction).mockImplementation(async (_workspaceId, viewIds) => { + return viewIds.map((viewId) => viewsById[viewId]).filter((view): view is View => Boolean(view)); + }); + + (useDatabaseContext as jest.Mock).mockReturnValue({ + workspaceId: 'workspace-1', + databaseDoc: { guid: 'current-database' }, + databasePageId: currentGrid.view_id, + loadDatabaseRelations: jest.fn().mockResolvedValue({ + 'current-database': currentGrid.view_id, + 'related-database': relatedGrid.view_id, + }), + loadViewMeta, + }); + + render( + + ); + + const currentCandidate = await screen.findByTestId('relation-candidate-current-database'); + const relatedCandidate = await screen.findByTestId('relation-candidate-related-database'); + + expect(currentCandidate.textContent).toContain('To-dos'); + expect(relatedCandidate.textContent).toContain('Product roadmap'); + expect(currentCandidate.textContent).not.toContain('Grid'); + expect(relatedCandidate.textContent).not.toContain('Grid'); + + fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'roadmap' } }); + + await waitFor(() => { + expect(screen.queryByTestId('relation-candidate-current-database')).toBeNull(); + expect(screen.queryByTestId('relation-candidate-related-database')).not.toBeNull(); + }); + + expect(getViews).toHaveBeenNthCalledWith( + 1, + 'workspace-1', + [currentGrid.view_id, relatedGrid.view_id], + 0 + ); + expect(getViews).toHaveBeenNthCalledWith( + 2, + 'workspace-1', + [currentContainer.view_id, relatedContainer.view_id], + 0 + ); + expect(loadViewMeta).not.toHaveBeenCalled(); + }); + + it('uses the database container name when opened from a secondary view', async () => { + const currentGrid = makeView({ + viewId: 'current-grid', + name: 'Grid', + databaseId: 'current-database', + parentViewId: 'current-container', + }); + const currentBoard = makeView({ + viewId: 'current-board', + name: 'Board', + databaseId: 'current-database', + parentViewId: 'current-container', + }); + const currentContainer = makeView({ + viewId: 'current-container', + name: 'To-dos', + databaseId: 'current-database', + isContainer: true, + }); + const relatedGrid = makeView({ + viewId: 'related-grid', + name: 'Grid', + databaseId: 'related-database', + parentViewId: 'related-container', + }); + const relatedContainer = makeView({ + viewId: 'related-container', + name: 'Product roadmap', + databaseId: 'related-database', + isContainer: true, + }); + const viewsById: Record = { + [currentGrid.view_id]: currentGrid, + [currentBoard.view_id]: currentBoard, + [currentContainer.view_id]: currentContainer, + [relatedGrid.view_id]: relatedGrid, + [relatedContainer.view_id]: relatedContainer, + }; + + (getViews as jest.MockedFunction).mockImplementation(async (_workspaceId, viewIds) => { + return viewIds.map((viewId) => viewsById[viewId]).filter((view): view is View => Boolean(view)); + }); + + (useDatabaseContext as jest.Mock).mockReturnValue({ + workspaceId: 'workspace-1', + databaseDoc: { guid: 'current-database' }, + databasePageId: currentBoard.view_id, + loadDatabaseRelations: jest.fn().mockResolvedValue({ + 'current-database': currentGrid.view_id, + 'related-database': relatedGrid.view_id, + }), + loadViewMeta: jest.fn(async (viewId: string) => viewsById[viewId] ?? null), + }); + + render( + + ); + + await screen.findByTestId('relation-candidate-related-database'); + + expect(screen.queryByText('This database')).toBeNull(); + expect(screen.getAllByText('To-dos')).toHaveLength(2); + }); + + it('falls back to individual view metadata when batch loading is unavailable', async () => { + const currentGrid = makeView({ + viewId: 'current-grid', + name: 'Grid', + databaseId: 'current-database', + parentViewId: 'current-container', + }); + const currentContainer = makeView({ + viewId: 'current-container', + name: 'To-dos', + databaseId: 'current-database', + isContainer: true, + }); + const relatedGrid = makeView({ + viewId: 'related-grid', + name: 'Grid', + databaseId: 'related-database', + parentViewId: 'related-container', + }); + const relatedContainer = makeView({ + viewId: 'related-container', + name: 'Product roadmap', + databaseId: 'related-database', + isContainer: true, + }); + const viewsById: Record = { + [currentGrid.view_id]: currentGrid, + [currentContainer.view_id]: currentContainer, + [relatedGrid.view_id]: relatedGrid, + [relatedContainer.view_id]: relatedContainer, + }; + const loadViewMeta = jest.fn(async (viewId: string) => viewsById[viewId] ?? null); + + (getViews as jest.MockedFunction).mockRejectedValue(new Error('Batch endpoint unavailable')); + (useDatabaseContext as jest.Mock).mockReturnValue({ + workspaceId: 'workspace-1', + databaseDoc: { guid: 'current-database' }, + databasePageId: currentGrid.view_id, + loadDatabaseRelations: jest.fn().mockResolvedValue({ + 'current-database': currentGrid.view_id, + 'related-database': relatedGrid.view_id, + }), + loadViewMeta, + }); + + render( + + ); + + expect((await screen.findByTestId('relation-candidate-current-database')).textContent).toContain('To-dos'); + expect(screen.getByTestId('relation-candidate-related-database').textContent).toContain('Product roadmap'); + expect(loadViewMeta).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/components/database/components/property/relation/RelationCreationDialog.tsx b/src/components/database/components/property/relation/RelationCreationDialog.tsx index 4323aae76..c67426dcf 100644 --- a/src/components/database/components/property/relation/RelationCreationDialog.tsx +++ b/src/components/database/components/property/relation/RelationCreationDialog.tsx @@ -13,7 +13,9 @@ const MODAL_PAPER_PROPS = { import { useDatabaseContext } from '@/application/database-yjs'; import { RelationLimit } from '@/application/database-yjs/fields/relation/relation.type'; -import { View } from '@/application/types'; +import { getMultiple as getViews } from '@/application/services/domains/view'; +import { LoadViewMeta, View } from '@/application/types'; +import { isDatabaseContainer } from '@/application/view-utils'; import { NormalModal } from '@/components/_shared/modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -31,6 +33,59 @@ export type RelationCreationResult = { reciprocalFieldName?: string; }; +type RelationCandidate = { + databaseId: string; + databaseViewId: string; + displayView: View; +}; + +function indexViews(views: View[]): Map { + const indexedViews = new Map(); + const pending = [...views]; + let index = 0; + + while (index < pending.length) { + const view = pending[index]; + + index += 1; + + if (!view || indexedViews.has(view.view_id)) continue; + indexedViews.set(view.view_id, view); + pending.push(...view.children); + } + + return indexedViews; +} + +async function loadViewsById( + workspaceId: string, + viewIds: string[], + loadViewMeta: LoadViewMeta +): Promise> { + const uniqueViewIds = Array.from(new Set(viewIds.filter(Boolean))); + + if (uniqueViewIds.length === 0) return new Map(); + + try { + // The API chunks large lists internally, replacing one request per view + // with one batch per 50 IDs. + return indexViews(await getViews(workspaceId, uniqueViewIds, 0)); + } catch { + // Keep compatibility with servers that do not expose the batch endpoint. + const views = await Promise.all( + uniqueViewIds.map(async (viewId) => { + try { + return await loadViewMeta(viewId); + } catch { + return null; + } + }) + ); + + return indexViews(views.filter((view): view is View => Boolean(view))); + } +} + function relationLimitLabel(t: TFunction, limit: RelationLimit) { return limit === RelationLimit.OneOnly ? t('grid.relation.limitOnePage', { defaultValue: 'One page' }) @@ -55,7 +110,7 @@ export function RelationCreationDialog({ onCreate: (result: RelationCreationResult) => void; }) { const { t } = useTranslation(); - const { databasePageId, loadDatabaseRelations, loadViewMeta } = useDatabaseContext(); + const { databaseDoc, databasePageId, loadDatabaseRelations, loadViewMeta, workspaceId } = useDatabaseContext(); const [fieldName, setFieldName] = useState(initialFieldName); const [reciprocalFieldName, setReciprocalFieldName] = useState(''); const [selectedDatabaseId, setSelectedDatabaseId] = useState(''); @@ -63,9 +118,9 @@ export function RelationCreationDialog({ const [isTwoWay, setIsTwoWay] = useState(false); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); - // Each candidate carries both view metadata (for display) and the database_id - // that needs to be persisted on the relation property. - const [candidates, setCandidates] = useState>([]); + // Keep the registered database view ID for identity, while rendering the + // database container so users see the database name instead of "Grid". + const [candidates, setCandidates] = useState([]); // RelationCreationDialog itself stays mounted under PropertyMenu — only the // MUI Dialog subtree unmounts via `keepMounted={false}`. The useState above @@ -114,34 +169,46 @@ export function RelationCreationDialog({ // Mirror the desktop flow (RelationDatabaseListCubit): // 1. Ask the workspace for every registered database via // DatabaseEventGetDatabases (here: `loadDatabaseRelations`). - // 2. For each `(databaseId, viewId)`, fetch the view metadata so we - // get the database name. Desktop calls ViewBackendService.getView - // per id; we call `loadViewMeta` per id. + // 2. For each `(databaseId, viewId)`, fetch the registered database + // view and its container. The workspace map points to the first + // internal view (usually named "Grid"), while the container owns + // the user-facing database name. // 3. Drop entries whose view fetch failed. // Force a refresh so a database created earlier in this session shows // up — the workspace cache is otherwise only invalidated on workspace // switch. const databaseRelations = (await loadDatabaseRelationsFn({ refresh: true })) ?? {}; - const entries = Object.entries(databaseRelations); + const entries = Object.entries(databaseRelations).filter((entry): entry is [string, string] => Boolean(entry[1])); + const databaseViews = await loadViewsById( + workspaceId, + entries.map(([, viewId]) => viewId), + loadViewMetaFn + ); + const parentViews = await loadViewsById( + workspaceId, + Array.from(databaseViews.values()) + .filter((view) => !isDatabaseContainer(view)) + .map((view) => view.parent_view_id) + .filter((viewId): viewId is string => Boolean(viewId)), + loadViewMetaFn + ); + const fetched = entries.map(([databaseId, viewId]) => { + const databaseView = databaseViews.get(viewId); - const fetched = await Promise.all( - entries.map(async ([databaseId, viewId]) => { - if (!viewId) return null; + if (!databaseView) return null; - try { - const view = await loadViewMetaFn(viewId); + const parentView = databaseView.parent_view_id + ? parentViews.get(databaseView.parent_view_id) + : undefined; + const displayView = isDatabaseContainer(parentView) ? parentView : databaseView; - return view ? { databaseId, view } : null; - } catch { - return null; - } - }) - ); + return { databaseId, databaseViewId: databaseView.view_id, displayView }; + }); if (cancelled) return; const seen = new Set(); - const resolved: Array<{ databaseId: string; view: View }> = []; + const resolved: RelationCandidate[] = []; for (const entry of fetched) { if (!entry || seen.has(entry.databaseId)) continue; @@ -164,13 +231,13 @@ export function RelationCreationDialog({ return () => { cancelled = true; }; - }, [open]); + }, [open, workspaceId]); const filteredCandidates = useMemo(() => { if (!query.trim()) return candidates; const lowered = query.trim().toLowerCase(); - return candidates.filter(({ view }) => (view.name || '').toLowerCase().includes(lowered)); + return candidates.filter(({ displayView }) => (displayView.name || '').toLowerCase().includes(lowered)); }, [candidates, query]); const selectedCandidate = useMemo( @@ -179,13 +246,20 @@ export function RelationCreationDialog({ ); const currentCandidate = useMemo( - () => candidates.find((entry) => entry.view.view_id === databasePageId), - [candidates, databasePageId] + () => + candidates.find( + (entry) => + entry.databaseId === databaseDoc.guid || + entry.databaseViewId === databasePageId || + entry.displayView.view_id === databasePageId + ), + [candidates, databaseDoc.guid, databasePageId] ); - const relatedDatabaseName = selectedCandidate?.view.name || t('grid.relation.relatedDatabasePlaceholder'); + const relatedDatabaseName = + selectedCandidate?.displayView.name || t('grid.relation.relatedDatabasePlaceholder'); const sourceDatabaseName = - currentCandidate?.view.name || t('grid.relation.thisDatabase', { defaultValue: 'This database' }); + currentCandidate?.displayView.name || t('grid.relation.thisDatabase', { defaultValue: 'This database' }); // Memoize the disabled flag so MUI's Button can bail out when only // unrelated state (search query, two-way toggle, …) changes. @@ -242,7 +316,7 @@ export function RelationCreationDialog({ {t('grid.relation.emptySearchResult')} ) : ( - filteredCandidates.map(({ databaseId, view }) => { + filteredCandidates.map(({ databaseId, displayView }) => { const selected = databaseId === selectedDatabaseId; return ( @@ -256,7 +330,7 @@ export function RelationCreationDialog({ )} onClick={() => setSelectedDatabaseId(databaseId)} > - + ); }) diff --git a/src/components/publish/header/duplicate/DuplicateModal.tsx b/src/components/publish/header/duplicate/DuplicateModal.tsx index b5401173c..b50b54461 100644 --- a/src/components/publish/header/duplicate/DuplicateModal.tsx +++ b/src/components/publish/header/duplicate/DuplicateModal.tsx @@ -42,6 +42,7 @@ function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void selectedSpaceId, workspaceLoading, spaceLoading, + spaceError, loadWorkspaces, loadSpaces, } = useLoadWorkspaces(); @@ -110,9 +111,15 @@ function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void /> { + if (selectedWorkspaceId) { + void loadSpaces(selectedWorkspaceId); + } + }} /> diff --git a/src/components/publish/header/duplicate/SelectWorkspace.tsx b/src/components/publish/header/duplicate/SelectWorkspace.tsx index 6054ec18b..107ff5d75 100644 --- a/src/components/publish/header/duplicate/SelectWorkspace.tsx +++ b/src/components/publish/header/duplicate/SelectWorkspace.tsx @@ -9,6 +9,8 @@ import { Popover } from '@/components/_shared/popover'; import { useCurrentUserOptional } from '@/components/main/app.hooks'; import { stringToColor } from '@/utils/color'; +import { saveDuplicateSelectedWorkspaceId } from './storage'; + export interface SelectWorkspaceProps { value: string; onChange?: (value: string) => void; @@ -121,7 +123,7 @@ function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWork onClick={() => { onChange?.(workspace.id); setSelectOpen(false); - localStorage.setItem('duplicate_selected_workspace', workspace.id); + saveDuplicateSelectedWorkspaceId(workspace.id); }} className={'w-full px-3 py-2'} variant={'text'} diff --git a/src/components/publish/header/duplicate/SpaceList.test.tsx b/src/components/publish/header/duplicate/SpaceList.test.tsx new file mode 100644 index 000000000..962ad93fa --- /dev/null +++ b/src/components/publish/header/duplicate/SpaceList.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import SpaceList from '@/components/publish/header/duplicate/SpaceList'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'publish.addTo': 'Add to', + 'publish.loadSpacesFailed': "Couldn't load spaces.", + 'publish.noSpacesAvailable': 'No spaces available.', + 'button.retry': 'Retry', + }; + + return translations[key] || key; + }, + }), +})); + +describe('SpaceList', () => { + it('shows a retry action when loading spaces fails', () => { + const onRetry = jest.fn(); + + render(); + + expect(screen.getByRole('alert').textContent).toContain("Couldn't load spaces."); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('distinguishes an empty workspace from a loading failure', () => { + render(); + + expect(screen.getByText('No spaces available.')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/src/components/publish/header/duplicate/SpaceList.tsx b/src/components/publish/header/duplicate/SpaceList.tsx index 9e474bb4c..d5f1d5d59 100644 --- a/src/components/publish/header/duplicate/SpaceList.tsx +++ b/src/components/publish/header/duplicate/SpaceList.tsx @@ -12,10 +12,12 @@ export interface SpaceListProps { onChange?: (value: string) => void; spaceList: SpaceView[]; loading?: boolean; + error?: boolean; + onRetry?: () => void; title?: React.ReactNode; } -function SpaceList({ loading, spaceList, value, onChange, title }: SpaceListProps) { +function SpaceList({ loading, error, spaceList, value, onChange, onRetry, title }: SpaceListProps) { const { t } = useTranslation(); const getExtraObj = useCallback((extra: string) => { @@ -61,6 +63,17 @@ function SpaceList({ loading, spaceList, value, onChange, title }: SpaceListProp
+ ) : error ? ( +
+ {t('publish.loadSpacesFailed')} + +
+ ) : spaceList.length === 0 ? ( +
+ {t('publish.noSpacesAvailable')} +
) : (
{spaceList.map((space) => { @@ -69,7 +82,7 @@ function SpaceList({ loading, spaceList, value, onChange, title }: SpaceListProp return (