From 29934c798d3abab89fe2bf1338aeb728598550aa Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 15 Dec 2025 16:50:03 +0800 Subject: [PATCH 1/2] chore: support database container --- cypress/e2e/chat/chat-input.cy.ts | 19 +- cypress/e2e/chat/create-ai-chat.cy.ts | 37 +- .../database-container-add-linked-views.cy.ts | 130 ++++ .../database/database-container-open.cy.ts | 129 ++++ .../database-container-tab-operations.cy.ts | 169 +++++ cypress/e2e/editor/drag_drop_blocks.cy.ts | 22 +- .../database/database-conditions.cy.ts | 38 +- ...ase-container-embedded-create-delete.cy.ts | 265 ++++++++ .../database-container-link-existing.cy.ts | 174 +++++ .../database/embedded-view-isolation.cy.ts | 213 +++--- .../linked-database-plus-button.cy.ts | 55 +- .../database/linked-database-slash-menu.cy.ts | 416 ++++++------ cypress/e2e/page/template-duplication.cy.ts | 372 +++++------ cypress/support/commands.ts | 28 + cypress/support/cypress.d.ts | 6 +- cypress/support/e2e.ts | 2 +- cypress/support/selectors.ts | 273 ++++---- jest.config.cjs | 3 +- src/__mocks__/svgrMock.tsx | 5 + src/application/__tests__/view-utils.test.ts | 613 ++++++++++++++++++ .../__tests__/useAddDatabaseView.test.tsx | 225 +++++++ .../useDatabaseViewsSelector.test.tsx | 63 ++ src/application/database-yjs/dispatch.ts | 63 +- src/application/database-yjs/selector.ts | 6 +- .../services/js-services/http/http_api.ts | 45 +- src/application/types.ts | 14 +- src/application/view-utils.ts | 140 ++++ src/components/app/DatabaseView.tsx | 55 +- src/components/app/ViewModal.tsx | 52 +- src/components/app/app.hooks.tsx | 32 +- src/components/app/favorite/Favorite.tsx | 4 +- .../ViewItem.databaseContainer.test.tsx | 115 ++++ .../__tests__/databaseTabSidebarSync.test.tsx | 257 ++++++++ .../resolveSidebarSelectedViewId.test.ts | 124 ++++ ...erations.toView.databaseContainer.test.tsx | 99 +++ .../app/hooks/resolveSidebarSelectedViewId.ts | 34 + src/components/app/hooks/useViewOperations.ts | 120 +++- .../app/layers/AppBusinessLayer.tsx | 51 +- src/components/app/outline/ViewItem.tsx | 60 +- .../app/view-actions/AddPageActions.tsx | 19 +- src/components/chat/types/request.ts | 4 + .../components/tabs/AddViewButton.tsx | 10 +- .../components/tabs/DatabaseTabItem.tsx | 306 +++++---- .../database/components/tabs/DatabaseTabs.tsx | 159 ++++- .../components/tabs/DatabaseViewTabs.tsx | 6 +- .../components/tabs/DeleteViewConfirm.tsx | 3 +- .../database/components/tabs/ViewActions.tsx | 2 + src/components/editor/CollaborativeEditor.tsx | 159 ++++- .../behavior/DatabaseBlockLifecycle.cy.tsx | 133 ++++ .../panels/slash-panel/SlashPanel.tsx | 196 ++++-- src/components/main/AppConfig.tsx | 26 +- src/pages/AppPage.tsx | 23 +- .../AppPage.databaseContainer.test.tsx | 114 ++++ .../DatabaseView.databaseContainer.test.tsx | 145 +++++ 54 files changed, 4754 insertions(+), 1079 deletions(-) create mode 100644 cypress/e2e/database/database-container-add-linked-views.cy.ts create mode 100644 cypress/e2e/database/database-container-open.cy.ts create mode 100644 cypress/e2e/database/database-container-tab-operations.cy.ts create mode 100644 cypress/e2e/embeded/database/database-container-embedded-create-delete.cy.ts create mode 100644 cypress/e2e/embeded/database/database-container-link-existing.cy.ts create mode 100644 src/__mocks__/svgrMock.tsx create mode 100644 src/application/__tests__/view-utils.test.ts create mode 100644 src/application/database-yjs/__tests__/useAddDatabaseView.test.tsx create mode 100644 src/application/database-yjs/__tests__/useDatabaseViewsSelector.test.tsx create mode 100644 src/application/view-utils.ts create mode 100644 src/components/app/hooks/__tests__/ViewItem.databaseContainer.test.tsx create mode 100644 src/components/app/hooks/__tests__/databaseTabSidebarSync.test.tsx create mode 100644 src/components/app/hooks/__tests__/resolveSidebarSelectedViewId.test.ts create mode 100644 src/components/app/hooks/__tests__/useViewOperations.toView.databaseContainer.test.tsx create mode 100644 src/components/app/hooks/resolveSidebarSelectedViewId.ts create mode 100644 src/components/editor/__tests__/behavior/DatabaseBlockLifecycle.cy.tsx create mode 100644 src/pages/__tests__/AppPage.databaseContainer.test.tsx create mode 100644 src/pages/__tests__/DatabaseView.databaseContainer.test.tsx diff --git a/cypress/e2e/chat/chat-input.cy.ts b/cypress/e2e/chat/chat-input.cy.ts index d3d660fc3..de7a66f52 100644 --- a/cypress/e2e/chat/chat-input.cy.ts +++ b/cypress/e2e/chat/chat-input.cy.ts @@ -1,6 +1,6 @@ import { AuthTestUtils } from '../../support/auth-utils'; import { TestTool } from '../../support/page-utils'; -import { AddPageSelectors, ModelSelectorSelectors, PageSelectors, SidebarSelectors, ChatSelectors } from '../../support/selectors'; +import { AddPageSelectors, ModelSelectorSelectors, PageSelectors, SidebarSelectors, ChatSelectors, byTestId } from '../../support/selectors'; import { generateRandomEmail, logAppFlowyEnvironment } from '../../support/test-config'; describe('Chat Input Tests', () => { @@ -34,8 +34,8 @@ describe('Chat Input Tests', () => { authUtils.signInWithTestUrl(testEmail).then(() => { cy.url().should('include', '/app'); - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); - PageSelectors.items().should('exist', { timeout: 30000 }); + SidebarSelectors.pageHeader({ timeout: 30000 }).should('be.visible'); + PageSelectors.items({ timeout: 30000 }).should('exist'); cy.wait(2000); TestTool.expandSpace(); @@ -52,17 +52,19 @@ describe('Chat Input Tests', () => { AddPageSelectors.addAIChatButton().should('be.visible').click(); cy.wait(2000); + ChatSelectors.aiChatContainer({ timeout: 30000 }).should('be.visible'); // Test 1: Format toggle cy.log('Testing format toggle'); - ChatSelectors.formatGroup().then($group => { - if ($group.length > 0) { + cy.get('body').then(($body) => { + if ($body.find(byTestId('chat-format-group')).length > 0) { ChatSelectors.formatToggle().click(); ChatSelectors.formatGroup().should('not.exist'); } }); - ChatSelectors.formatToggle().should('be.visible').click(); + ChatSelectors.formatToggle({ timeout: 30000 }).should('be.visible'); + ChatSelectors.formatToggle().click(); ChatSelectors.formatGroup().should('exist'); ChatSelectors.formatGroup().find('button').should('have.length.at.least', 4); ChatSelectors.formatToggle().click(); @@ -111,8 +113,8 @@ describe('Chat Input Tests', () => { authUtils.signInWithTestUrl(testEmail).then(() => { cy.url().should('include', '/app'); - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); - PageSelectors.items().should('exist', { timeout: 30000 }); + SidebarSelectors.pageHeader({ timeout: 30000 }).should('be.visible'); + PageSelectors.items({ timeout: 30000 }).should('exist'); cy.wait(2000); TestTool.expandSpace(); @@ -129,6 +131,7 @@ describe('Chat Input Tests', () => { AddPageSelectors.addAIChatButton().should('be.visible').click(); cy.wait(3000); // Wait for chat to fully load + ChatSelectors.aiChatContainer({ timeout: 30000 }).should('be.visible'); // Mock API endpoints with more realistic responses cy.intercept('POST', '**/api/chat/**/message/question', (req) => { diff --git a/cypress/e2e/chat/create-ai-chat.cy.ts b/cypress/e2e/chat/create-ai-chat.cy.ts index a366f85de..35d355fa1 100644 --- a/cypress/e2e/chat/create-ai-chat.cy.ts +++ b/cypress/e2e/chat/create-ai-chat.cy.ts @@ -53,18 +53,18 @@ describe('AI Chat Creation and Navigation Tests', () => { cy.get('body', { timeout: 30000 }).should('not.contain', 'Welcome!'); // Wait for the sidebar to be visible (indicates app is loaded) - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + SidebarSelectors.pageHeader({ timeout: 30000 }).should('be.visible'); // Wait for at least one page to exist in the sidebar - PageSelectors.names().should('exist', { timeout: 30000 }); + PageSelectors.names({ timeout: 30000 }).should('exist'); // Additional wait for stability cy.wait(2000); // Now wait for the new page button to be available testLog.info( 'Looking for new page button...'); - PageSelectors.newPageButton() - .should('exist', { timeout: 20000 }) + PageSelectors.newPageButton({ timeout: 20000 }) + .should('exist') .then(() => { testLog.info( 'New page button found!'); }); @@ -121,33 +121,12 @@ describe('AI Chat Creation and Navigation Tests', () => { testLog.info( '=== Step 4: Verifying AI Chat page loaded ==='); // Check that the URL contains a view ID (indicating navigation to chat) - cy.url().should('match', /\/app\/[a-f0-9-]+\/[a-f0-9-]+/, { timeout: 10000 }); + cy.url({ timeout: 20000 }).should('match', /\/app\/[^/]+\/[^/?#]+/); testLog.info( '✓ Navigated to AI Chat page'); - // Check if the AI Chat container exists (but don't fail if it doesn't load immediately) - ChatSelectors.aiChatContainer().then($container => { - if ($container.length > 0) { - testLog.info( '✓ AI Chat container exists'); - } else { - testLog.info( 'AI Chat container not immediately visible, checking for navigation success...'); - } - }); - - // Wait a bit for the chat to fully load - cy.wait(2000); - - // Check for AI Chat specific elements (the chat interface) - // The AI chat library loads its own components - cy.get('body').then($body => { - ChatSelectors.aiChatContainer().then($container => { - const hasChatElements = $body.find('.ai-chat').length > 0 || $container.length > 0; - if (hasChatElements) { - testLog.info( '✓ AI Chat interface loaded'); - } else { - testLog.info( 'Warning: AI Chat elements not immediately visible, but container exists'); - } - }); - }); + // Verify AI Chat container renders (chat UI may load async after container is mounted) + ChatSelectors.aiChatContainer({ timeout: 30000 }).should('be.visible'); + testLog.info( '✓ AI Chat container exists'); // Verify no error messages are displayed cy.get('body').then($body => { diff --git a/cypress/e2e/database/database-container-add-linked-views.cy.ts b/cypress/e2e/database/database-container-add-linked-views.cy.ts new file mode 100644 index 000000000..31bf8e1d1 --- /dev/null +++ b/cypress/e2e/database/database-container-add-linked-views.cy.ts @@ -0,0 +1,130 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { AuthTestUtils } from '../../support/auth-utils'; +import { closeModalsIfOpen, testLog } from '../../support/test-helpers'; +import { + AddPageSelectors, + DatabaseGridSelectors, + DatabaseViewSelectors, + DropdownSelectors, + PageSelectors, + SpaceSelectors, + waitForReactUpdate, +} from '../../support/selectors'; + +describe('Database Container - Add Linked Views via Tab Bar', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + const dbName = 'New Database'; + const spaceName = 'General'; + + const ensureSpaceExpanded = (name: string) => { + SpaceSelectors.itemByName(name).should('exist'); + SpaceSelectors.itemByName(name).then(($space) => { + const expandedIndicator = $space.find('[data-testid="space-expanded"]'); + const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; + + if (!isExpanded) { + SpaceSelectors.itemByName(name).find('[data-testid="space-name"]').click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const ensurePageExpanded = (name: string) => { + PageSelectors.itemByName(name).should('exist'); + PageSelectors.itemByName(name).then(($page) => { + const isExpanded = $page.find('[data-testid="outline-toggle-collapse"]').length > 0; + + if (!isExpanded) { + PageSelectors.itemByName(name).find('[data-testid="outline-toggle-expand"]').first().click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const addViewViaPlus = (viewTypeLabel: 'Board' | 'Calendar') => { + DatabaseViewSelectors.addViewButton().should('be.visible').scrollIntoView().click({ force: true }); + + DropdownSelectors.content({ timeout: 10000 }) + .should('be.visible') + .within(() => { + cy.contains('[role="menuitem"]', viewTypeLabel).should('be.visible').click({ force: true }); + }); + + waitForReactUpdate(3000); + cy.contains('[data-testid^="view-tab-"]', viewTypeLabel, { timeout: 20000 }) + .should('exist') + .and('have.attr', 'data-state', 'active'); + }; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('ResizeObserver loop') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('adds Board and Calendar views and reflects them in tabs and sidebar', () => { + const testEmail = generateRandomEmail(); + + testLog.testStart('Database container add linked views'); + testLog.info(`Test email: ${testEmail}`); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // 1) Create standalone Grid database (container + first child view) + testLog.step(1, 'Create standalone Grid database'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').click({ force: true }); + // The grid container may exist before it has a stable height; wait for cells to render. + cy.wait(7000); + DatabaseGridSelectors.grid().should('exist'); + DatabaseGridSelectors.cells().should('have.length.greaterThan', 0); + + // Scenario 4 parity: tab bar "+" adds linked views to the same container + testLog.step(2, 'Verify initial tabs (single tab)'); + DatabaseViewSelectors.viewTab() + .should('have.length', 1) + .first() + .should('have.attr', 'data-state', 'active') + .and('contain.text', dbName); + + testLog.step(3, 'Add Board view via tab bar "+"'); + addViewViaPlus('Board'); + DatabaseViewSelectors.viewTab().should('have.length', 2); + + testLog.step(4, 'Add Calendar view via tab bar "+"'); + addViewViaPlus('Calendar'); + DatabaseViewSelectors.viewTab().should('have.length', 3); + + testLog.step(5, 'Verify sidebar container children updated'); + closeModalsIfOpen(); + ensureSpaceExpanded(spaceName); + ensurePageExpanded(dbName); + + PageSelectors.itemByName(dbName).within(() => { + PageSelectors.nameContaining('Board').should('be.visible'); + PageSelectors.nameContaining('Calendar').should('be.visible'); + PageSelectors.items().should('have.length', 3); + }); + + testLog.testEnd('Database container add linked views'); + }); + }); +}); diff --git a/cypress/e2e/database/database-container-open.cy.ts b/cypress/e2e/database/database-container-open.cy.ts new file mode 100644 index 000000000..49172cd42 --- /dev/null +++ b/cypress/e2e/database/database-container-open.cy.ts @@ -0,0 +1,129 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { AuthTestUtils } from '../../support/auth-utils'; +import { closeModalsIfOpen, testLog } from '../../support/test-helpers'; +import { + AddPageSelectors, + DatabaseGridSelectors, + DatabaseViewSelectors, + ModalSelectors, + PageSelectors, + SpaceSelectors, + waitForReactUpdate, +} from '../../support/selectors'; + +describe('Database Container Open Behavior', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + const dbName = 'New Database'; + const spaceName = 'General'; + + const currentViewIdFromUrl = () => + cy.location('pathname').then((pathname) => { + const maybeId = pathname.split('/').filter(Boolean).pop() || ''; + return maybeId; + }); + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('ResizeObserver loop') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('opens the first child view when clicking a database container', () => { + const testEmail = generateRandomEmail(); + testLog.testStart('Database container opens first child'); + testLog.info(`Test email: ${testEmail}`); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // Create a standalone database (container + first child view) + testLog.step(1, 'Create standalone Grid database'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').click({ force: true }); + + // Wait for the database UI to appear + // The grid container may exist before it has a stable height; wait for cells to render. + cy.wait(7000); + DatabaseGridSelectors.grid().should('exist'); + DatabaseGridSelectors.cells().should('have.length.greaterThan', 0); + + // Scenario 1 parity: a newly created container has exactly 1 child view + DatabaseViewSelectors.viewTab() + .should('have.length', 1) + .first() + .should('have.attr', 'data-state', 'active') + .and('contain.text', dbName); + + // Ensure sidebar is visible and space expanded + SpaceSelectors.itemByName(spaceName).should('exist'); + SpaceSelectors.itemByName(spaceName).then(($space) => { + const expandedIndicator = $space.find('[data-testid="space-expanded"]'); + const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; + + if (!isExpanded) { + SpaceSelectors.itemByName(spaceName).find('[data-testid="space-name"]').click({ force: true }); + waitForReactUpdate(500); + } + }); + + // Capture the currently active viewId (the first child view opened after container creation) + testLog.step(2, 'Capture first child view id'); + currentViewIdFromUrl().then((firstChildViewId) => { + expect(firstChildViewId).to.not.equal(''); + cy.wrap(firstChildViewId).as('firstChildViewId'); + }); + + // Navigate away to a document page so we can click the container again + testLog.step(3, 'Navigate away to a new document'); + closeModalsIfOpen(); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().click({ force: true }); + waitForReactUpdate(1000); + + cy.get('body').then(($body) => { + if ($body.find('[data-testid="new-page-modal"]').length > 0) { + ModalSelectors.newPageModal() + .should('be.visible') + .within(() => { + ModalSelectors.spaceItemInModal().first().click({ force: true }); + waitForReactUpdate(500); + cy.contains('button', 'Add').click({ force: true }); + }); + } + }); + waitForReactUpdate(2000); + + // Click on the database container in the sidebar and ensure we land on its first child view id + testLog.step(4, 'Click container and verify redirect'); + PageSelectors.nameContaining(dbName).first().click({ force: true }); + + cy.get('@firstChildViewId').then((firstChildViewId) => { + cy.location('pathname', { timeout: 20000 }).should('include', `/${firstChildViewId}`); + DatabaseViewSelectors.viewTab(firstChildViewId).should('have.attr', 'data-state', 'active'); + }); + + DatabaseGridSelectors.grid().should('exist'); + DatabaseGridSelectors.cells().should('have.length.greaterThan', 0); + + testLog.testEnd('Database container opens first child'); + }); + }); +}); diff --git a/cypress/e2e/database/database-container-tab-operations.cy.ts b/cypress/e2e/database/database-container-tab-operations.cy.ts new file mode 100644 index 000000000..a0164bbbc --- /dev/null +++ b/cypress/e2e/database/database-container-tab-operations.cy.ts @@ -0,0 +1,169 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { AuthTestUtils } from '../../support/auth-utils'; +import { closeModalsIfOpen, testLog } from '../../support/test-helpers'; +import { + AddPageSelectors, + DatabaseGridSelectors, + DatabaseViewSelectors, + ModalSelectors, + PageSelectors, + SpaceSelectors, + waitForReactUpdate, +} from '../../support/selectors'; + +describe('Database Container - Tab Operations', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + const dbName = 'New Database'; + const spaceName = 'General'; + + const ensureSpaceExpanded = (name: string) => { + SpaceSelectors.itemByName(name).should('exist'); + SpaceSelectors.itemByName(name).then(($space) => { + const expandedIndicator = $space.find('[data-testid="space-expanded"]'); + const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; + + if (!isExpanded) { + SpaceSelectors.itemByName(name).find('[data-testid="space-name"]').click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const ensurePageExpanded = (name: string) => { + PageSelectors.itemByName(name).should('exist'); + PageSelectors.itemByName(name).then(($page) => { + const isExpanded = $page.find('[data-testid="outline-toggle-collapse"]').length > 0; + + if (!isExpanded) { + PageSelectors.itemByName(name).find('[data-testid="outline-toggle-expand"]').first().click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const openTabMenuByLabel = (label: string) => { + // The context-menu handler is attached to the TabLabel inside the trigger. + // Trigger the event on the inner label text so it bubbles to the correct handler. + cy.contains('[data-testid^="view-tab-"] span', label, { timeout: 10000 }) + .should('be.visible') + .trigger('pointerdown', { button: 2, force: true }); + waitForReactUpdate(200); + }; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('ResizeObserver loop') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('renames, creates, deletes views and prevents deleting last view', () => { + const testEmail = generateRandomEmail(); + + testLog.testStart('Database container tab operations'); + testLog.info(`Test email: ${testEmail}`); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // 1) Create standalone Grid database (container + child) + testLog.step(1, 'Create standalone Grid database'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').click({ force: true }); + // The grid container may exist before it has a stable height; wait for cells to render. + cy.wait(7000); + DatabaseGridSelectors.grid().should('exist'); + DatabaseGridSelectors.cells().should('have.length.greaterThan', 0); + + // 2) Rename the first view (New Database -> A) + testLog.step(2, 'Rename first tab to A'); + openTabMenuByLabel(dbName); + DatabaseViewSelectors.tabActionRename().should('be.visible').click({ force: true }); + ModalSelectors.renameInput().should('be.visible').clear().type('A'); + ModalSelectors.renameSaveButton().click({ force: true }); + cy.contains('[data-testid^="view-tab-"]', 'A', { timeout: 10000 }).should('exist'); + + // 3) Add a Board view via tab bar (+) + testLog.step(3, 'Add Board view via + button'); + DatabaseViewSelectors.addViewButton().should('be.visible').scrollIntoView().click({ force: true }); + cy.contains('Board', { timeout: 5000 }).should('be.visible').click({ force: true }); + waitForReactUpdate(3000); + cy.contains('[data-testid^="view-tab-"]', 'Board', { timeout: 10000 }) + .should('exist') + .and('have.attr', 'data-state', 'active'); + + // 4) Rename Board -> B + testLog.step(4, 'Rename Board tab to B'); + openTabMenuByLabel('Board'); + DatabaseViewSelectors.tabActionRename().should('be.visible').click({ force: true }); + ModalSelectors.renameInput().should('be.visible').clear().type('B'); + ModalSelectors.renameSaveButton().click({ force: true }); + cy.contains('[data-testid^="view-tab-"]', 'B', { timeout: 10000 }).should('exist'); + cy.contains('[data-testid^="view-tab-"]', 'A', { timeout: 10000 }).should('exist'); + + // 5) Verify sidebar container still exists and children show A/B + testLog.step(5, 'Verify container children in sidebar'); + closeModalsIfOpen(); + ensureSpaceExpanded(spaceName); + PageSelectors.itemByName(dbName).should('exist'); + ensurePageExpanded(dbName); + PageSelectors.itemByName(dbName).within(() => { + cy.get('[data-testid="page-name"]').contains('A').should('be.visible'); + cy.get('[data-testid="page-name"]').contains('B').should('be.visible'); + }); + + // 6) Delete view A (allowed because there are 2 views) + testLog.step(6, 'Delete tab A and verify it is removed'); + openTabMenuByLabel('A'); + DatabaseViewSelectors.tabActionDelete() + .should('be.visible') + .then(($el) => { + const ariaDisabled = $el.attr('aria-disabled'); + const dataDisabled = $el.attr('data-disabled'); + expect(ariaDisabled === 'true' || dataDisabled !== undefined).to.equal(false); + }); + DatabaseViewSelectors.tabActionDelete().click({ force: true }); + DatabaseViewSelectors.deleteViewConfirmButton().should('be.visible').click({ force: true }); + waitForReactUpdate(3000); + cy.contains('[data-testid^="view-tab-"]', 'A').should('not.exist'); + cy.contains('[data-testid^="view-tab-"]', 'B').should('exist'); + + // Sidebar should no longer list A under the container + ensureSpaceExpanded(spaceName); + ensurePageExpanded(dbName); + PageSelectors.itemByName(dbName).within(() => { + cy.get('[data-testid="page-name"]').contains('A').should('not.exist'); + cy.get('[data-testid="page-name"]').contains('B').should('be.visible'); + }); + + // 7) Cannot delete last view (B) + testLog.step(7, 'Verify delete is disabled for last remaining tab'); + openTabMenuByLabel('B'); + DatabaseViewSelectors.tabActionDelete() + .should('be.visible') + .then(($el) => { + const ariaDisabled = $el.attr('aria-disabled'); + const dataDisabled = $el.attr('data-disabled'); + expect(ariaDisabled === 'true' || dataDisabled !== undefined).to.equal(true); + }); + + testLog.testEnd('Database container tab operations'); + }); + }); +}); diff --git a/cypress/e2e/editor/drag_drop_blocks.cy.ts b/cypress/e2e/editor/drag_drop_blocks.cy.ts index 460a299b7..e97c87b13 100644 --- a/cypress/e2e/editor/drag_drop_blocks.cy.ts +++ b/cypress/e2e/editor/drag_drop_blocks.cy.ts @@ -9,7 +9,8 @@ describe('Editor - Drag and Drop Blocks', () => { err.message.includes('Minified React error') || err.message.includes('View not found') || err.message.includes('No workspace or service found') || - err.message.includes('Cannot resolve a DOM point from Slate point') + err.message.includes('Cannot resolve a DOM point from Slate point') || + err.message.includes('Cannot resolve a DOM node from Slate node') ) { return false; } @@ -32,7 +33,7 @@ describe('Editor - Drag and Drop Blocks', () => { : EditorSelectors.slateEditor().contains(sourceText); }; - getSource().closest('[data-block-type]').scrollIntoView().should('be.visible').click().then(($sourceBlock) => { + getSource().closest('[data-block-type]').scrollIntoView().should('be.visible').then(($sourceBlock) => { // Use realHover to simulate user interaction which updates elementFromPoint cy.wrap($sourceBlock).trigger('mouseover', { force: true }); cy.wrap($sourceBlock).realHover({ position: 'center' }); @@ -41,7 +42,7 @@ describe('Editor - Drag and Drop Blocks', () => { BlockSelectors.hoverControls().invoke('css', 'opacity', '1'); // 2. Get the drag handle - BlockSelectors.dragHandle().should('be.visible').then(($handle) => { + BlockSelectors.dragHandle().should('exist').then(($handle) => { const dataTransfer = new DataTransfer(); // 3. Start dragging @@ -102,6 +103,13 @@ describe('Editor - Drag and Drop Blocks', () => { waitForReactUpdate(1000); }; + const closeViewModal = () => { + cy.get('[role="dialog"]', { timeout: 30000 }).should('be.visible'); + cy.get('body').type('{esc}'); + waitForReactUpdate(800); + cy.get('[role="dialog"]').should('not.exist'); + }; + it.skip('should iteratively reorder items in a list (5 times)', () => { const testEmail = generateRandomEmail(); const authUtils = new AuthTestUtils(); @@ -343,10 +351,8 @@ describe('Editor - Drag and Drop Blocks', () => { BlockSelectors.slashMenuGrid().should('be.visible').click(); waitForReactUpdate(2000); - // Grid creation usually opens a modal. We need to close it to interact with the editor. - // Pressing ESC is a robust way to close modals. - cy.get('body').type('{esc}'); - waitForReactUpdate(1000); + // Grid creation opens a view modal; close it before interacting with the document editor. + closeViewModal(); // Verify grid block exists BlockSelectors.blockByType('grid').should('exist'); @@ -369,4 +375,4 @@ describe('Editor - Drag and Drop Blocks', () => { }); }); -}); \ No newline at end of file +}); diff --git a/cypress/e2e/embeded/database/database-conditions.cy.ts b/cypress/e2e/embeded/database/database-conditions.cy.ts index faeab51dd..7573f3d15 100644 --- a/cypress/e2e/embeded/database/database-conditions.cy.ts +++ b/cypress/e2e/embeded/database/database-conditions.cy.ts @@ -48,7 +48,7 @@ describe('Database Conditions - Filters and Sorts UI', () => { waitForReactUpdate(1000); AddPageSelectors.addGridButton().should('be.visible').click(); cy.wait(3000); - const dbName = 'New Grid'; + const dbName = 'New Database'; cy.task('log', `[STEP 4.1] Using database name: ${dbName}`); cy.wait(1000); @@ -183,7 +183,7 @@ describe('Database Conditions - Filters and Sorts UI', () => { }); waitForReactUpdate(1000); - SlashCommandSelectors.selectDatabase('New Grid'); + SlashCommandSelectors.selectDatabase('New Database'); waitForReactUpdate(2000); cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDBTemp1'); @@ -224,9 +224,11 @@ describe('Database Conditions - Filters and Sorts UI', () => { // Verify filter condition appears cy.task('log', '[STEP 6] Verifying filter condition appears'); - cy.get('[class*="appflowy-database"]').last().within(() => { - DatabaseFilterSelectors.filterCondition().should('exist').and('be.visible'); - }); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + DatabaseFilterSelectors.filterCondition().should('exist').and('be.visible'); + }); cy.task('log', '[TEST COMPLETE] Filter expansion test passed'); }); @@ -280,7 +282,7 @@ describe('Database Conditions - Filters and Sorts UI', () => { }); waitForReactUpdate(1000); - SlashCommandSelectors.selectDatabase('New Grid'); + SlashCommandSelectors.selectDatabase('New Database'); waitForReactUpdate(2000); cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDBTemp2'); @@ -328,17 +330,21 @@ describe('Database Conditions - Filters and Sorts UI', () => { waitForReactUpdate(1000); // Verify filter exists - cy.get('[class*="appflowy-database"]').last().within(() => { - DatabaseFilterSelectors.filterCondition().should('exist'); - }); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + DatabaseFilterSelectors.filterCondition().should('exist'); + }); // Remove filter cy.task('log', '[STEP 3] Removing filter'); // Click the filter condition chip to open the menu - cy.get('[class*="appflowy-database"]').last().within(() => { - DatabaseFilterSelectors.filterCondition().first().click(); - }); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + DatabaseFilterSelectors.filterCondition().first().click(); + }); waitForReactUpdate(500); @@ -354,9 +360,11 @@ describe('Database Conditions - Filters and Sorts UI', () => { waitForReactUpdate(1000); // Verify filter is removed - cy.get('[class*="appflowy-database"]').last().within(() => { - DatabaseFilterSelectors.filterCondition().should('not.exist'); - }); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + DatabaseFilterSelectors.filterCondition().should('not.exist'); + }); cy.task('log', '[TEST COMPLETE] Dynamic height adjustment test passed'); }); diff --git a/cypress/e2e/embeded/database/database-container-embedded-create-delete.cy.ts b/cypress/e2e/embeded/database/database-container-embedded-create-delete.cy.ts new file mode 100644 index 000000000..2310f45a2 --- /dev/null +++ b/cypress/e2e/embeded/database/database-container-embedded-create-delete.cy.ts @@ -0,0 +1,265 @@ +import { v4 as uuidv4 } from 'uuid'; +import { Editor, Element as SlateElement } from 'slate'; + +import { AuthTestUtils } from '../../../support/auth-utils'; +import { getSlashMenuItemName } from '../../../support/i18n-constants'; +import { testLog } from '../../../support/test-helpers'; +import { + AddPageSelectors, + BlockSelectors, + byTestId, + PageSelectors, + SlashCommandSelectors, + SpaceSelectors, + waitForReactUpdate, +} from '../../../support/selectors'; + +describe('Database Container - Embedded Create/Delete', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + const dbName = 'New Database'; + const spaceName = 'General'; + + const currentViewIdFromUrl = () => + cy.location('pathname').then((pathname) => { + const maybeId = pathname.split('/').filter(Boolean).pop() || ''; + return maybeId; + }); + + const ensureSpaceExpanded = (name: string) => { + SpaceSelectors.itemByName(name).should('exist'); + SpaceSelectors.itemByName(name).then(($space) => { + const expandedIndicator = $space.find('[data-testid="space-expanded"]'); + const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; + + if (!isExpanded) { + SpaceSelectors.itemByName(name).find('[data-testid="space-name"]').click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const ensurePageExpanded = (name: string) => { + PageSelectors.itemByName(name).should('exist'); + PageSelectors.itemByName(name) + .find('[data-testid="outline-toggle-collapse"]') + .then(($collapse) => { + if ($collapse.length > 0) return; + + PageSelectors.itemByName(name) + .find('[data-testid="outline-toggle-expand"]') + .should('exist') + .first() + .click({ force: true }); + waitForReactUpdate(500); + }); + }; + + const ensurePageExpandedByViewId = (viewId: string) => { + const pageItem = () => PageSelectors.itemByViewId(viewId, { timeout: 30000 }); + + pageItem().should('exist'); + pageItem().within(() => { + cy.get(byTestId('outline-toggle-collapse')).then(($collapse) => { + if ($collapse.length > 0) return; + + cy.get(byTestId('outline-toggle-expand'), { timeout: 30000 }).should('exist').first().click({ force: true }); + waitForReactUpdate(500); + }); + }); + }; + + const closeTopDialogIfNotDocument = (docViewId: string) => { + cy.get('body').then(($body) => { + const dialogs = $body.find('[role="dialog"]').filter(':visible'); + + if (dialogs.length === 0) return; + + const topDialog = dialogs.last(); + const topContainsDocEditor = $body.find(topDialog).find(`#editor-${docViewId}`).length > 0; + + if (!topContainsDocEditor) { + cy.task('log', '[modal] Closing top dialog (not the document)'); + cy.get('body').type('{esc}'); + waitForReactUpdate(800); + } + }); + }; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('ResizeObserver loop') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('creates an embedded database container and removes it when the block is deleted', () => { + const testEmail = generateRandomEmail(); + + testLog.testStart('Embedded database container create/delete'); + testLog.info(`Test email: ${testEmail}`); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // 1) Create a document page + testLog.step(1, 'Create a document page'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().click({ force: true }); + waitForReactUpdate(1000); + + // Capture the document view id for reliable sidebar targeting (avoid relying on a title edit) + currentViewIdFromUrl().then((viewId) => { + expect(viewId).to.not.equal(''); + cy.wrap(viewId).as('docViewId'); + cy.get(`#editor-${viewId}`, { timeout: 15000 }).should('exist'); + }); + waitForReactUpdate(1000); + + // 2) Insert an embedded Grid database via slash menu (creates container + first child) + testLog.step(2, 'Insert embedded Grid database via slash menu'); + // Avoid chaining .type() directly after .click() since the editor can re-render on focus. + cy.get('@docViewId').then((docViewId) => { + cy.get(`#editor-${docViewId}`).should('exist').click('center', { force: true }); + cy.get(`#editor-${docViewId}`).type('/', { force: true }); + }); + waitForReactUpdate(500); + + SlashCommandSelectors.slashPanel().should('be.visible').within(() => { + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('grid')).first().click({ force: true }); + }); + + // In some flows, inserting a database opens an additional modal; close it while keeping the document open. + cy.get('@docViewId').then((docViewId) => { + closeTopDialogIfNotDocument(docViewId); + }); + + // The embedded database block should exist in the editor + cy.get('@docViewId').then((docViewId) => { + cy.get(`#editor-${docViewId}`).find(BlockSelectors.blockSelector('grid')).should('exist'); + }); + + // 3) Verify sidebar: document has a child database container with a child view + testLog.step(3, 'Verify sidebar hierarchy: document -> container -> child view'); + ensureSpaceExpanded(spaceName); + + cy.get('@docViewId').then((docViewId) => { + // Ensure the document is expanded to reveal its children + ensurePageExpandedByViewId(docViewId); + + // Find the container under the document + const containerPageItem = () => + PageSelectors.itemByViewId(docViewId, { timeout: 30000 }) + .find(byTestId('page-name')) + .contains(dbName) + .first() + .closest(byTestId('page-item')); + + containerPageItem().should('exist'); + + // Expand the container to reveal its first child view + containerPageItem().within(() => { + cy.get(byTestId('outline-toggle-collapse')).then(($collapse) => { + if ($collapse.length > 0) return; + + cy.get(byTestId('outline-toggle-expand'), { timeout: 30000 }).should('exist').first().click({ force: true }); + waitForReactUpdate(500); + }); + }); + + containerPageItem().within(() => { + // When the current page is open in a modal, the sidebar can be covered by the dialog backdrop. + // We only need to assert the hierarchy exists. + PageSelectors.items().should('have.length.at.least', 1); + }); + }); + + // 4) Delete the database block from the document + testLog.step(4, 'Delete the embedded database block'); + cy.get('@docViewId').then((docViewId) => { + // Ensure we're back on the document view before deleting the embedded database block. + PageSelectors.pageByViewId(docViewId, { timeout: 30000 }).click({ force: true }); + waitForReactUpdate(800); + + cy.get(`#editor-${docViewId}`).should('exist').find(BlockSelectors.blockSelector('grid')).should('exist'); + + // Delete the database block using the Slate editor instance exposed for E2E testing. + // Hover-controls are unreliable for embedded database blocks due to portal/overlay rendering. + cy.window().then((win) => { + const testEditors = (win as unknown as { __TEST_EDITORS__?: Record }).__TEST_EDITORS__; + const testEditor = testEditors?.[docViewId]; + const customEditor = (win as unknown as { __TEST_CUSTOM_EDITOR__?: unknown }).__TEST_CUSTOM_EDITOR__; + + expect(testEditor, `window.__TEST_EDITORS__["${docViewId}"]`).to.exist; + expect(customEditor, 'window.__TEST_CUSTOM_EDITOR__').to.exist; + + const editor = testEditor as Parameters[0]; + + const gridEntries = Array.from( + Editor.nodes(editor, { + at: [], + match: (node) => SlateElement.isElement(node) && (node as { type?: string }).type === 'grid', + }) + ); + + expect(gridEntries.length, 'gridEntries.length').to.be.greaterThan(0); + const [gridNode] = gridEntries[0]; + const blockId = (gridNode as unknown as { blockId?: string }).blockId; + + expect(blockId, 'grid blockId').to.be.a('string').and.not.equal(''); + (customEditor as { deleteBlock: (e: unknown, id: string) => void }).deleteBlock(editor, blockId as string); + + const afterEntries = Array.from( + Editor.nodes(editor, { + at: [], + match: (node) => SlateElement.isElement(node) && (node as { type?: string }).type === 'grid', + }) + ); + + expect(afterEntries.length, 'gridEntries after delete').to.equal(0); + }); + }); + + waitForReactUpdate(2000); + + // Verify the database block is removed from the document + cy.get('@docViewId').then((docViewId) => { + cy.get(`#editor-${docViewId}`).find(BlockSelectors.blockSelector('grid')).should('not.exist'); + }); + + // 5) Verify sidebar: document no longer has the database container child + testLog.step(5, 'Verify sidebar no longer contains the embedded container'); + ensureSpaceExpanded(spaceName); + + cy.get('@docViewId').then((docViewId) => { + // Ensure document still exists and is expanded (or try to expand if needed) + cy.get(`[data-testid="page-${docViewId}"]`).first().should('exist'); + ensurePageExpandedByViewId(docViewId); + + cy + .get(`[data-testid="page-${docViewId}"]`) + .first() + .closest('[data-testid="page-item"]') + .within(() => { + PageSelectors.names().should('not.contain.text', dbName); + }); + }); + + testLog.testEnd('Embedded database container create/delete'); + }); + }); +}); diff --git a/cypress/e2e/embeded/database/database-container-link-existing.cy.ts b/cypress/e2e/embeded/database/database-container-link-existing.cy.ts new file mode 100644 index 000000000..47e803cfa --- /dev/null +++ b/cypress/e2e/embeded/database/database-container-link-existing.cy.ts @@ -0,0 +1,174 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { AuthTestUtils } from '../../../support/auth-utils'; +import { getSlashMenuItemName } from '../../../support/i18n-constants'; +import { testLog } from '../../../support/test-helpers'; +import { + AddPageSelectors, + ModalSelectors, + PageSelectors, + SlashCommandSelectors, + SpaceSelectors, + ViewActionSelectors, + waitForReactUpdate, +} from '../../../support/selectors'; + +describe('Database Container - Link Existing Database in Document', () => { + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + const dbName = 'New Database'; + const spaceName = 'General'; + + const currentDocumentViewIdFromDialog = () => + cy + .get('[role="dialog"]:visible', { timeout: 20000 }) + .last() + .find('[id^="editor-"]:not([id^="editor-title-"])', { timeout: 20000 }) + .first() + .invoke('attr', 'id') + .then((id) => (id ?? '').replace('editor-', '')); + + const ensureSpaceExpanded = (name: string) => { + SpaceSelectors.itemByName(name).should('exist'); + SpaceSelectors.itemByName(name).then(($space) => { + const expandedIndicator = $space.find('[data-testid="space-expanded"]'); + const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; + + if (!isExpanded) { + SpaceSelectors.itemByName(name).find('[data-testid="space-name"]').click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const ensurePageExpanded = (name: string) => { + PageSelectors.itemByName(name).should('exist'); + PageSelectors.itemByName(name).then(($page) => { + const isExpanded = $page.find('[data-testid="outline-toggle-collapse"]').length > 0; + + if (!isExpanded) { + PageSelectors.itemByName(name).find('[data-testid="outline-toggle-expand"]').first().click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + const ensurePageExpandedByViewId = (viewId: string) => { + cy.get(`[data-testid="page-${viewId}"]`) + .first() + .closest('[data-testid="page-item"]') + .should('exist') + .then(($page) => { + const isExpanded = $page.find('[data-testid="outline-toggle-collapse"]').length > 0; + + if (!isExpanded) { + cy.wrap($page).find('[data-testid="outline-toggle-expand"]').first().click({ force: true }); + waitForReactUpdate(500); + } + }); + }; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('ResizeObserver loop') + ) { + return false; + } + return true; + }); + + cy.viewport(1280, 720); + }); + + it('creates a linked view under the document (no new container)', () => { + const testEmail = generateRandomEmail(); + const sourceName = `SourceDB_${Date.now()}`; + + testLog.testStart('Link existing database in document'); + testLog.info(`Test email: ${testEmail}`); + + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // 1) Create a standalone database (container exists in the sidebar) + testLog.step(1, 'Create standalone Grid database'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').click({ force: true }); + + // Rename container to a unique name so the linked database picker is deterministic + ensureSpaceExpanded(spaceName); + PageSelectors.itemByName(dbName).should('exist'); + PageSelectors.moreActionsButton(dbName).click({ force: true }); + ViewActionSelectors.renameButton().should('be.visible').click({ force: true }); + ModalSelectors.renameInput().should('be.visible').clear().type(sourceName); + ModalSelectors.renameSaveButton().click({ force: true }); + waitForReactUpdate(2000); + PageSelectors.itemByName(sourceName).should('exist'); + + // 2) Create a document page + testLog.step(2, 'Create document page'); + AddPageSelectors.inlineAddButton().first().click({ force: true }); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().click({ force: true }); + waitForReactUpdate(1000); + + // Capture the document view id for reliable sidebar targeting. + // When creating a document while a database modal is open, the URL may still point to the database view. + currentDocumentViewIdFromDialog().then((viewId) => { + expect(viewId).to.not.equal(''); + cy.wrap(viewId).as('docViewId'); + cy.get(`#editor-${viewId}`, { timeout: 15000 }).should('exist'); + }); + waitForReactUpdate(1000); + + // 3) Insert linked grid via slash menu (should NOT create a new container) + testLog.step(3, 'Insert linked grid via slash menu'); + // Avoid chaining .type() directly after .click() since the editor can re-render on focus. + cy.get('@docViewId').then((docViewId) => { + cy.get(`#editor-${docViewId}`).should('exist').click('center', { force: true }); + cy.get(`#editor-${docViewId}`).type('/', { force: true }); + }); + waitForReactUpdate(500); + + SlashCommandSelectors.slashPanel().should('be.visible').within(() => { + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('linkedGrid')).first().click({ force: true }); + }); + waitForReactUpdate(1000); + + SlashCommandSelectors.selectDatabase(sourceName); + waitForReactUpdate(3000); + + // 4) Verify sidebar: document has a "View of " child, and no container child + testLog.step(4, 'Verify document children in sidebar'); + ensureSpaceExpanded(spaceName); + const referencedName = `View of ${sourceName}`; + + cy.get('@docViewId').then((docViewId) => { + ensurePageExpandedByViewId(docViewId); + + cy + .get(`[data-testid="page-${docViewId}"]`) + .first() + .closest('[data-testid="page-item"]') + .within(() => { + cy.get('[data-testid="page-name"]').then(($els) => { + const names = Array.from($els).map((el) => (el.textContent || '').trim()); + expect(names).to.include(referencedName); + expect(names).not.to.include(dbName); + }); + }); + }); + + testLog.testEnd('Link existing database in document'); + }); + }); +}); diff --git a/cypress/e2e/embeded/database/embedded-view-isolation.cy.ts b/cypress/e2e/embeded/database/embedded-view-isolation.cy.ts index 907a56065..b8a9da948 100644 --- a/cypress/e2e/embeded/database/embedded-view-isolation.cy.ts +++ b/cypress/e2e/embeded/database/embedded-view-isolation.cy.ts @@ -3,28 +3,31 @@ import { AuthTestUtils } from '../../../support/auth-utils'; import { getSlashMenuItemName } from '../../../support/i18n-constants'; import { AddPageSelectors, + byTestId, EditorSelectors, ModalSelectors, PageSelectors, SlashCommandSelectors, SpaceSelectors, - waitForReactUpdate + waitForReactUpdate, } from '../../../support/selectors'; describe('Embedded Database View Isolation', () => { const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; - const dbName = 'New Grid'; - const docName = 'Untitled'; + const dbName = 'New Database'; + const docName = `Doc ${uuidv4()}`; const spaceName = 'General'; // Default space name beforeEach(() => { cy.on('uncaught:exception', (err) => { - if (err.message.includes('Minified React error') || + if ( + err.message.includes('Minified React error') || err.message.includes('View not found') || err.message.includes('No workspace or service found') || err.message.includes('useAppHandlers must be used within') || err.message.includes('Cannot resolve a DOM node from Slate') || - err.message.includes('ResizeObserver loop')) { + err.message.includes('ResizeObserver loop') + ) { return false; } return true; @@ -40,15 +43,15 @@ describe('Embedded Database View Isolation', () => { cy.task('log', `[ACTION] Expanding space "${spaceNameToExpand}" in sidebar`); // Check if space is already expanded - SpaceSelectors.itemByName(spaceNameToExpand).then($space => { + SpaceSelectors.itemByName(spaceNameToExpand, { timeout: 30000 }).then(($space) => { const expandedIndicator = $space.find('[data-testid="space-expanded"]'); const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; if (!isExpanded) { cy.task('log', `[ACTION] Space "${spaceNameToExpand}" is collapsed, clicking to expand`); // Click on the space name to expand it - SpaceSelectors.itemByName(spaceNameToExpand) - .find('[data-testid="space-name"]') + SpaceSelectors.itemByName(spaceNameToExpand, { timeout: 30000 }) + .find(byTestId('space-name')) .click({ force: true }); waitForReactUpdate(500); } else { @@ -62,11 +65,19 @@ describe('Embedded Database View Isolation', () => { */ function expandPageInSidebar(pageName: string) { cy.task('log', `[ACTION] Expanding page "${pageName}" in sidebar`); - PageSelectors.itemByName(pageName) - .find('[data-testid="outline-toggle-expand"]') - .first() - .click({ force: true }); - waitForReactUpdate(500); + PageSelectors.itemByName(pageName, { timeout: 30000 }).should('exist'); + PageSelectors.itemByName(pageName, { timeout: 30000 }) + .find(byTestId('outline-toggle-collapse')) + .then(($collapse) => { + if ($collapse.length > 0) return; + + PageSelectors.itemByName(pageName, { timeout: 30000 }) + .find(byTestId('outline-toggle-expand')) + .should('exist') + .first() + .click({ force: true }); + waitForReactUpdate(500); + }); } /** @@ -74,9 +85,10 @@ describe('Embedded Database View Isolation', () => { */ function assertPageHasExpandToggle(pageName: string) { cy.task('log', `[ASSERT] Checking "${pageName}" has expand toggle in sidebar`); - PageSelectors.itemByName(pageName).within(() => { - cy.get('[data-testid="outline-toggle-expand"], [data-testid="outline-toggle-collapse"]') - .should('exist'); + PageSelectors.itemByName(pageName, { timeout: 30000 }).within(() => { + cy.get(`${byTestId('outline-toggle-expand')}, ${byTestId('outline-toggle-collapse')}`, { timeout: 30000 }).should( + 'exist' + ); }); } @@ -85,8 +97,9 @@ describe('Embedded Database View Isolation', () => { */ function assertPageHasNoExpandToggle(pageName: string) { cy.task('log', `[ASSERT] Checking "${pageName}" has NO expand toggle in sidebar`); - PageSelectors.itemByName(pageName).then($pageItem => { - const hasExpandToggle = $pageItem.find('[data-testid="outline-toggle-expand"], [data-testid="outline-toggle-collapse"]').length > 0; + PageSelectors.itemByName(pageName).then(($pageItem) => { + const hasExpandToggle = + $pageItem.find('[data-testid="outline-toggle-expand"], [data-testid="outline-toggle-collapse"]').length > 0; cy.task('log', `[ASSERT] "${pageName}" has expand toggle: ${hasExpandToggle}`); expect(hasExpandToggle).to.equal(false, `"${pageName}" should NOT have expand toggle (no children)`); }); @@ -98,7 +111,7 @@ describe('Embedded Database View Isolation', () => { */ function assertPageHasNoChildren(pageName: string) { cy.task('log', `[ASSERT] Checking "${pageName}" has NO children in sidebar`); - PageSelectors.itemByName(pageName).then($pageItem => { + PageSelectors.itemByName(pageName).then(($pageItem) => { const childCount = $pageItem.find('[data-testid="page-item"]').length; cy.task('log', `[ASSERT] "${pageName}" has ${childCount} children`); expect(childCount).to.equal(0, `"${pageName}" should have no children in sidebar`); @@ -112,19 +125,22 @@ describe('Embedded Database View Isolation', () => { cy.task('log', `[ASSERT] Checking "${pageName}" HAS children in sidebar`); // First log all page names in sidebar for debugging - PageSelectors.names().then($names => { - const names = Array.from($names).map(el => Cypress.$(el).text().trim()); + PageSelectors.names().then(($names) => { + const names = Array.from($names).map((el) => Cypress.$(el).text().trim()); cy.task('log', `[DEBUG] All page names in sidebar: ${JSON.stringify(names)}`); }); - PageSelectors.itemByName(pageName).then($pageItem => { + PageSelectors.itemByName(pageName).then(($pageItem) => { const childCount = $pageItem.find('[data-testid="page-item"]').length; cy.task('log', `[ASSERT] "${pageName}" has ${childCount} children`); // Log the HTML structure for debugging cy.task('log', `[DEBUG] Page item HTML length: ${$pageItem.html().length}`); - expect(childCount).to.be.at.least(expectedMinCount, `"${pageName}" should have at least ${expectedMinCount} children in sidebar`); + expect(childCount).to.be.at.least( + expectedMinCount, + `"${pageName}" should have at least ${expectedMinCount} children in sidebar` + ); }); } @@ -134,9 +150,28 @@ describe('Embedded Database View Isolation', () => { function assertChildViewExists(parentName: string, childNameContains: string) { cy.task('log', `[ASSERT] Checking "${parentName}" has child containing "${childNameContains}"`); PageSelectors.itemByName(parentName).within(() => { - cy.get('[data-testid="page-name"]') - .contains(childNameContains) - .should('exist'); + cy.get('[data-testid="page-name"]').contains(childNameContains).should('exist'); + }); + } + + /** + * Get the number of descendant page items under a page in the sidebar. + * This counts all nested children rendered within the page item. + */ + function getDescendantPageItemCount(pageName: string) { + return PageSelectors.itemByName(pageName).then(($pageItem) => { + return $pageItem.find('[data-testid="page-item"]').length; + }); + } + + /** + * Assert that none of the descendant views under a page contain specific text. + * Useful to ensure embedded views (e.g. "View of ...") do NOT appear under a standalone database container. + */ + function assertNoChildViewContains(parentName: string, forbiddenText: string) { + cy.task('log', `[ASSERT] Checking "${parentName}" has NO child containing "${forbiddenText}"`); + PageSelectors.itemByName(parentName).within(() => { + cy.get('[data-testid="page-name"]').should('not.contain.text', forbiddenText); }); } @@ -163,9 +198,17 @@ describe('Embedded Database View Isolation', () => { AddPageSelectors.addGridButton().should('be.visible').click({ force: true }); cy.wait(5000); - // Step 4: Verify original database has NO children in sidebar - cy.task('log', '[STEP 4] Verifying original database has NO children'); - assertPageHasNoChildren(dbName); + // Step 4: Capture original database children (database container) + cy.task('log', '[STEP 4] Capturing original database children'); + expandSpaceInSidebar(spaceName); + waitForReactUpdate(1000); + + getDescendantPageItemCount(dbName).then((count) => { + cy.task('log', `[STEP 4.1] Original database descendant view count: ${count}`); + expect(count).to.be.at.least(1); + cy.wrap(count).as('originalDbChildCount'); + }); + assertNoChildViewContains(dbName, 'View of'); // Step 5: Create a new Document page cy.task('log', '[STEP 5] Creating new document page'); @@ -187,16 +230,31 @@ describe('Embedded Database View Isolation', () => { cy.get('body').then(($body) => { if ($body.find('[data-testid="new-page-modal"]').length > 0) { cy.task('log', '[STEP 5.1] Handling new page modal'); - ModalSelectors.newPageModal().should('be.visible').within(() => { - ModalSelectors.spaceItemInModal().first().click({ force: true }); - waitForReactUpdate(500); - cy.contains('button', 'Add').click({ force: true }); - }); + ModalSelectors.newPageModal() + .should('be.visible') + .within(() => { + ModalSelectors.spaceItemInModal().first().click({ force: true }); + waitForReactUpdate(500); + cy.contains('button', 'Add').click({ force: true }); + }); } }); cy.wait(3000); + // Step 5.2: Give the document a unique title to avoid matching other "Untitled" pages in the sidebar + cy.task('log', `[STEP 5.2] Setting document title to "${docName}"`); + PageSelectors.titleInput({ timeout: 30000 }) + .first() + .should('be.visible') + .click({ force: true }) + .clear({ force: true }) + .type(docName, { force: true }) + .type('{enter}', { force: true }); + waitForReactUpdate(1000); + expandSpaceInSidebar(spaceName); + PageSelectors.nameContaining(docName, { timeout: 30000 }).should('exist'); + // Step 6: Verify document initially has NO children cy.task('log', '[STEP 6] Verifying document initially has NO children'); assertPageHasNoChildren(docName); @@ -229,23 +287,20 @@ describe('Embedded Database View Isolation', () => { // 8.2: Wait for embedded database container to appear cy.task('log', '[STEP 8.2] Waiting for embedded database container'); - cy.get('[class*="appflowy-database"]', { timeout: 15000 }) - .should('exist') - .last() - .should('be.visible'); + cy.get('[class*="appflowy-database"]', { timeout: 15000 }).should('exist').last().should('be.visible'); // 8.3: Verify the embedded view tab shows "View of" prefix (indicates successful creation) cy.task('log', '[STEP 8.3] Verifying embedded view has correct name with "View of" prefix'); - cy.get('[role="tab"]', { timeout: 10000 }) - .should('be.visible') - .and('contain.text', 'View of'); + cy.get('[role="tab"]', { timeout: 10000 }).should('be.visible').and('contain.text', 'View of'); // 8.4: Verify the grid structure is visible (columns exist) cy.task('log', '[STEP 8.4] Verifying grid structure is visible'); - cy.get('[class*="appflowy-database"]').last().within(() => { - // Check for column headers or grid structure - cy.get('button').should('have.length.at.least', 1); - }); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + // Check for column headers or grid structure + cy.get('button').should('have.length.at.least', 1); + }); cy.task('log', '[STEP 8.5] Embedded database successfully created and visible'); @@ -277,9 +332,16 @@ describe('Embedded Database View Isolation', () => { // Step 10: Verify original database STILL has NO children // This is the KEY assertion - embedded views should NOT appear as children of the original database - cy.task('log', '[STEP 10] Verifying original database has NO children (no expand toggle)'); - assertPageHasNoExpandToggle(dbName); - assertPageHasNoChildren(dbName); + cy.task('log', '[STEP 10] Verifying original database did NOT gain embedded children'); + expandSpaceInSidebar(spaceName); + waitForReactUpdate(500); + + cy.get('@originalDbChildCount').then((initialCount) => { + getDescendantPageItemCount(dbName).then((count) => { + expect(count).to.equal(initialCount as number); + }); + }); + assertNoChildViewContains(dbName, 'View of'); // Step 11: Create a SECOND view in the embedded database (using + button) // This view will also be a child of the document, not the original database @@ -292,16 +354,11 @@ describe('Embedded Database View Isolation', () => { // Get the embedded database container cy.task('log', '[STEP 11.2] Finding embedded database in document'); - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last() - .as('embeddedDBInDoc'); + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDBInDoc'); // Click the + button to add a new view cy.task('log', '[STEP 11.3] Clicking + button to add second view'); - cy.get('@embeddedDBInDoc').find('[data-testid="add-view-button"]') - .scrollIntoView() - .click({ force: true }); + cy.get('@embeddedDBInDoc').find('[data-testid="add-view-button"]').scrollIntoView().click({ force: true }); waitForReactUpdate(500); @@ -319,9 +376,10 @@ describe('Embedded Database View Isolation', () => { // Step 12: Verify second view was created (now 2 tabs in the embedded database) cy.task('log', '[STEP 12] Verifying second view was created (2 tabs)'); - cy.get('@embeddedDBInDoc').within(() => { - cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }) - .should('have.length', 2); + // Re-query the embedded database block to avoid stale alias issues after view creation. + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDBInDocFresh'); + cy.get('@embeddedDBInDocFresh').within(() => { + cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }).should('have.length', 2); }); // Step 13: Verify document now has TWO children, database still has ZERO @@ -334,7 +392,7 @@ describe('Embedded Database View Isolation', () => { // Expand document to see children cy.task('log', '[STEP 13.1] Expanding document to verify children'); - PageSelectors.itemByName(docName).then($docItem => { + PageSelectors.itemByName(docName).then(($docItem) => { // Check if already expanded by looking for collapse toggle const isExpanded = $docItem.find('[data-testid="outline-toggle-collapse"]').length > 0; if (!isExpanded) { @@ -346,9 +404,13 @@ describe('Embedded Database View Isolation', () => { cy.task('log', '[STEP 13.2] Verifying document has 2 children'); assertPageHasChildren(docName, 2); - cy.task('log', '[STEP 13.3] Verifying database still has NO children'); - assertPageHasNoExpandToggle(dbName); - assertPageHasNoChildren(dbName); + cy.task('log', '[STEP 13.3] Verifying database did NOT gain embedded children'); + cy.get('@originalDbChildCount').then((initialCount) => { + getDescendantPageItemCount(dbName).then((count) => { + expect(count).to.equal(initialCount as number); + }); + }); + assertNoChildViewContains(dbName, 'View of'); // Step 14: Navigate to the original database and create a NEW view directly in it cy.task('log', '[STEP 14] Navigating to original database to create a direct view'); @@ -359,15 +421,10 @@ describe('Embedded Database View Isolation', () => { cy.task('log', '[STEP 15] Creating new view directly in database'); // Get the database view and find the add-view-button - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .first() - .as('standaloneDB'); + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').first().as('standaloneDB'); cy.task('log', '[STEP 15.1] Clicking + button to add view'); - cy.get('@standaloneDB').find('[data-testid="add-view-button"]') - .scrollIntoView() - .click({ force: true }); + cy.get('@standaloneDB').find('[data-testid="add-view-button"]').scrollIntoView().click({ force: true }); waitForReactUpdate(500); @@ -383,8 +440,8 @@ describe('Embedded Database View Isolation', () => { // Wait for view to be created waitForReactUpdate(3000); - // Step 16: Verify database NOW has ONE child (the directly created view) - cy.task('log', '[STEP 16] Verifying database now has 1 child'); + // Step 16: Verify database gained a direct child view (created in standalone DB) + cy.task('log', '[STEP 16] Verifying database gained a direct child view'); // Expand space again if needed expandSpaceInSidebar(spaceName); @@ -399,15 +456,20 @@ describe('Embedded Database View Isolation', () => { expandPageInSidebar(dbName); waitForReactUpdate(500); - // Verify database has exactly 1 child - cy.task('log', '[STEP 16.3] Verifying database has 1 child'); - assertPageHasChildren(dbName, 1); + // Verify database child count increased (directly created view appears under the database container) + cy.task('log', '[STEP 16.3] Verifying database child count increased'); + cy.get('@originalDbChildCount').then((initialCount) => { + getDescendantPageItemCount(dbName).then((count) => { + expect(count).to.be.greaterThan(initialCount as number); + }); + }); + assertChildViewExists(dbName, 'Board'); // Step 17: Verify document STILL has exactly 2 children (unchanged) cy.task('log', '[STEP 17] Verifying document still has 2 children'); // Expand document if needed - PageSelectors.itemByName(docName).then($docItem => { + PageSelectors.itemByName(docName).then(($docItem) => { const isExpanded = $docItem.find('[data-testid="outline-toggle-collapse"]').length > 0; if (!isExpanded) { expandPageInSidebar(docName); @@ -423,5 +485,4 @@ describe('Embedded Database View Isolation', () => { cy.task('log', ' - Embedded views do NOT appear as children of their source database'); }); }); - }); diff --git a/cypress/e2e/embeded/database/linked-database-plus-button.cy.ts b/cypress/e2e/embeded/database/linked-database-plus-button.cy.ts index bac3ac679..16344e989 100644 --- a/cypress/e2e/embeded/database/linked-database-plus-button.cy.ts +++ b/cypress/e2e/embeded/database/linked-database-plus-button.cy.ts @@ -5,7 +5,7 @@ import { AddPageSelectors, EditorSelectors, SlashCommandSelectors, - waitForReactUpdate + waitForReactUpdate, } from '../../../support/selectors'; describe('Embedded Database - Plus Button View Creation', () => { @@ -16,11 +16,9 @@ describe('Embedded Database - Plus Button View Creation', () => { cy.wait(1000); // Use .then() to force fresh query and avoid DOM detachment - cy.get('@embeddedDB').then($db => { + cy.get('@embeddedDB').then(($db) => { // Re-query the button to get a fresh reference - cy.wrap($db).find('[data-testid="add-view-button"]') - .scrollIntoView() - .click({ force: true }); // force click to avoid detachment issues + cy.wrap($db).find('[data-testid="add-view-button"]').scrollIntoView().click({ force: true }); // force click to avoid detachment issues }); }; @@ -32,10 +30,12 @@ describe('Embedded Database - Plus Button View Creation', () => { beforeEach(() => { cy.on('uncaught:exception', (err) => { - if (err.message.includes('Minified React error') || + if ( + err.message.includes('Minified React error') || err.message.includes('View not found') || err.message.includes('No workspace or service found') || - err.message.includes('ResizeObserver loop')) { + err.message.includes('ResizeObserver loop') + ) { return false; } return true; @@ -69,7 +69,7 @@ describe('Embedded Database - Plus Button View Creation', () => { AddPageSelectors.addGridButton().should('be.visible').as('gridBtnPlus'); cy.get('@gridBtnPlus').click(); cy.wait(5000); - const dbName = 'New Grid'; + const dbName = 'New Database'; // Step 2: Create document at same level as database cy.task('log', '[STEP 5] Creating document at same level as database'); @@ -100,19 +100,14 @@ describe('Embedded Database - Plus Button View Creation', () => { waitForReactUpdate(2000); // Get the embedded database (should be the LAST one, not the first) - // The first is the standalone "New Grid" page, the last is the embedded database in the document - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last() - .as('embeddedDB'); + // The first is the standalone "New Database" page, the last is the embedded database in the document + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDB'); // Step 4: Verify embedded database shows 1 tab (the reference view itself) cy.task('log', '[STEP 7] Verifying embedded database shows reference view tab'); cy.get('@embeddedDB').within(() => { - // Embedded database should show 1 tab: the reference view itself (like "View of New Grid") - cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }) - .should('have.length', 1) - .and('be.visible'); + // Embedded database should show 1 tab: the reference view itself (like "View of New Database") + cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }).should('have.length', 1).and('be.visible'); cy.task('log', '[STEP 7.1] Confirmed: 1 tab in embedded database (reference view)'); }); @@ -147,8 +142,10 @@ describe('Embedded Database - Plus Button View Creation', () => { // Debug: Log all existing tabs with fresh query cy.get('@embeddedDBFresh').within(() => { - cy.get('[data-testid^="view-tab-"]').then($tabs => { - const tabNames = Array.from($tabs).map((t: any) => t.textContent).join(', '); + cy.get('[data-testid^="view-tab-"]').then(($tabs) => { + const tabNames = Array.from($tabs) + .map((t: any) => t.textContent) + .join(', '); cy.task('log', `[DEBUG FRESH] All tabs after Board creation: ${tabNames} (count: ${$tabs.length})`); }); }); @@ -221,7 +218,7 @@ describe('Embedded Database - Plus Button View Creation', () => { AddPageSelectors.addGridButton().should('be.visible').as('gridBtnPlus'); cy.get('@gridBtnPlus').click(); cy.wait(5000); - const dbName = 'New Grid'; + const dbName = 'New Database'; // Step 2: Create document at same level as database cy.task('log', '[STEP 2] Creating document at same level as database'); @@ -251,16 +248,11 @@ describe('Embedded Database - Plus Button View Creation', () => { waitForReactUpdate(2000); - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last() - .as('embeddedDB'); + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDB'); // Wait for initial view to load cy.get('@embeddedDB').within(() => { - cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }) - .should('exist') - .and('be.visible'); + cy.get('[data-testid^="view-tab-"]', { timeout: 10000 }).should('exist').and('be.visible'); }); // Record start time for performance measurement @@ -329,7 +321,7 @@ describe('Embedded Database - Plus Button View Creation', () => { AddPageSelectors.addGridButton().should('be.visible').as('gridBtnPlus'); cy.get('@gridBtnPlus').click(); cy.wait(5000); - const dbName = 'New Grid'; + const dbName = 'New Database'; // Step 2: Create document at same level as database cy.task('log', '[STEP 2] Creating document at same level as database'); @@ -359,10 +351,7 @@ describe('Embedded Database - Plus Button View Creation', () => { waitForReactUpdate(2000); - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last() - .as('embeddedDB'); + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last().as('embeddedDB'); // Create multiple views to trigger horizontal scrolling // Using 2 views for reliability (Board, Calendar) @@ -390,7 +379,7 @@ describe('Embedded Database - Plus Button View Creation', () => { // Log current tab count for debugging cy.get(`@embeddedDBScroll${index}`).within(() => { - cy.get('[data-testid^="view-tab-"]').then($tabs => { + cy.get('[data-testid^="view-tab-"]').then(($tabs) => { cy.task('log', `[DEBUG] Current tab count after creating view ${index + 1}: ${$tabs.length}`); }); }); diff --git a/cypress/e2e/embeded/database/linked-database-slash-menu.cy.ts b/cypress/e2e/embeded/database/linked-database-slash-menu.cy.ts index 2883e75fe..a4a145fc5 100644 --- a/cypress/e2e/embeded/database/linked-database-slash-menu.cy.ts +++ b/cypress/e2e/embeded/database/linked-database-slash-menu.cy.ts @@ -2,232 +2,236 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthTestUtils } from '../../../support/auth-utils'; import { getSlashMenuItemName } from '../../../support/i18n-constants'; import { - AddPageSelectors, - DatabaseGridSelectors, - EditorSelectors, - ModalSelectors, - SlashCommandSelectors, - waitForReactUpdate + AddPageSelectors, + DatabaseGridSelectors, + EditorSelectors, + ModalSelectors, + SlashCommandSelectors, + waitForReactUpdate, } from '../../../support/selectors'; describe('Embedded Database - Slash Menu Creation', () => { - const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; - - beforeEach(() => { - cy.on('uncaught:exception', (err) => { - if (err.message.includes('Minified React error') || - err.message.includes('View not found') || - err.message.includes('No workspace or service found')) { - return false; - } - return true; - }); - - cy.viewport(1280, 720); + const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; + + beforeEach(() => { + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') + ) { + return false; + } + return true; }); - it('should create linked database view via slash menu within 500ms', () => { - const testEmail = generateRandomEmail(); - - cy.task('log', `[TEST START] Testing slash menu creation - Test email: ${testEmail}`); - - // Step 1: Login - cy.task('log', '[STEP 1] Visiting login page'); - cy.visit('/login', { failOnStatusCode: false }); - cy.wait(2000); - - const authUtils = new AuthTestUtils(); - cy.task('log', '[STEP 2] Starting authentication'); - authUtils.signInWithTestUrl(testEmail).then(() => { - cy.task('log', '[STEP 3] Authentication successful'); - cy.url({ timeout: 30000 }).should('include', '/app'); - cy.wait(3000); - - // Create a source database to link to - cy.task('log', '[STEP 4] Creating source database to link to'); - AddPageSelectors.inlineAddButton().first().as('addBtn1'); - cy.get('@addBtn1').should('be.visible').click(); - waitForReactUpdate(1000); - AddPageSelectors.addGridButton().should('be.visible').as('gridBtn1'); - cy.get('@gridBtn1').click(); - cy.wait(5000); - - // Get the database name from the view tab (default is "New Grid") - const dbName = 'New Grid'; - cy.task('log', `[STEP 4.1] Using database name: ${dbName}`); - - // Create a new document at same level as database - cy.task('log', '[STEP 5] Creating new document at same level as database'); - AddPageSelectors.inlineAddButton().first().as('addDocBtnSlash'); - cy.get('@addDocBtnSlash').should('be.visible').click(); - waitForReactUpdate(1000); - cy.get('[role="menuitem"]').first().as('menuItemSlash'); - cy.get('@menuItemSlash').click(); - waitForReactUpdate(1000); - - // Handle the new page modal if it appears - cy.get('body').then(($body) => { - if ($body.find('[data-testid="new-page-modal"]').length > 0) { - cy.task('log', '[STEP 5.1] Handling new page modal'); - ModalSelectors.newPageModal().should('be.visible').within(() => { - ModalSelectors.spaceItemInModal().first().as('spaceItem1'); - cy.get('@spaceItem1').click(); - waitForReactUpdate(500); - cy.contains('button', 'Add').click(); - }); - cy.wait(3000); - } else { - cy.wait(3000); - } + cy.viewport(1280, 720); + }); + + it('should create linked database view via slash menu within 500ms', () => { + const testEmail = generateRandomEmail(); + + cy.task('log', `[TEST START] Testing slash menu creation - Test email: ${testEmail}`); + + // Step 1: Login + cy.task('log', '[STEP 1] Visiting login page'); + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + cy.task('log', '[STEP 2] Starting authentication'); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.task('log', '[STEP 3] Authentication successful'); + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // Create a source database to link to + cy.task('log', '[STEP 4] Creating source database to link to'); + AddPageSelectors.inlineAddButton().first().as('addBtn1'); + cy.get('@addBtn1').should('be.visible').click(); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').as('gridBtn1'); + cy.get('@gridBtn1').click(); + cy.wait(5000); + + // Get the database name from the view tab (default is "New Database") + const dbName = 'New Database'; + cy.task('log', `[STEP 4.1] Using database name: ${dbName}`); + + // Create a new document at same level as database + cy.task('log', '[STEP 5] Creating new document at same level as database'); + AddPageSelectors.inlineAddButton().first().as('addDocBtnSlash'); + cy.get('@addDocBtnSlash').should('be.visible').click(); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().as('menuItemSlash'); + cy.get('@menuItemSlash').click(); + waitForReactUpdate(1000); + + // Handle the new page modal if it appears + cy.get('body').then(($body) => { + if ($body.find('[data-testid="new-page-modal"]').length > 0) { + cy.task('log', '[STEP 5.1] Handling new page modal'); + ModalSelectors.newPageModal() + .should('be.visible') + .within(() => { + ModalSelectors.spaceItemInModal().first().as('spaceItem1'); + cy.get('@spaceItem1').click(); + waitForReactUpdate(500); + cy.contains('button', 'Add').click(); }); - - // Wait for editor to be available - cy.task('log', '[STEP 6] Waiting for editor to be available'); - EditorSelectors.firstEditor().should('exist', { timeout: 15000 }); - - // Step 2: Type "/" to open slash menu - cy.task('log', '[STEP 7] Opening slash menu'); - EditorSelectors.firstEditor().click().type('/'); - waitForReactUpdate(500); - - // Step 3: Select "Linked Database" option - cy.task('log', '[STEP 7] Selecting Linked Database option'); - SlashCommandSelectors.slashPanel() - .should('be.visible') - .within(() => { - SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('linkedGrid')).first().as('linkedGridItem1'); - cy.get('@linkedGridItem1').click(); - }); - - waitForReactUpdate(1000); - - // Step 4: Choose the existing database - cy.task('log', `[STEP 8] Selecting source database: ${dbName}`); - SlashCommandSelectors.selectDatabase(dbName); - - waitForReactUpdate(2000); - - // Measure time for database to appear - const startTime = Date.now(); - cy.task('log', '[STEP 9] Waiting for linked database to appear'); - - // Step 5: Verify linked database appears - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last() - .then(() => { - const elapsed = Date.now() - startTime; - cy.task('log', `[PERFORMANCE] Linked database appeared in ${elapsed}ms`); - - // Expected result: < 500ms typically, but allow up to 30s for CI (includes initial load) - expect(elapsed).to.be.lessThan(30000); - - if (elapsed > 500) { - cy.task('log', `[PERFORMANCE WARNING] Creation took ${elapsed}ms (expected < 500ms)`); - } - }); - - // Verify content is displayed - cy.task('log', '[STEP 10] Verifying database content'); - cy.get('[class*="appflowy-database"]') - .last() - .within(() => { - DatabaseGridSelectors.grid().should('exist'); - }); - - cy.task('log', '[TEST COMPLETE] Linked database slash menu creation test passed'); + cy.wait(3000); + } else { + cy.wait(3000); + } + }); + + // Wait for editor to be available + cy.task('log', '[STEP 6] Waiting for editor to be available'); + EditorSelectors.firstEditor().should('exist', { timeout: 15000 }); + + // Step 2: Type "/" to open slash menu + cy.task('log', '[STEP 7] Opening slash menu'); + EditorSelectors.firstEditor().click().type('/'); + waitForReactUpdate(500); + + // Step 3: Select "Linked Database" option + cy.task('log', '[STEP 7] Selecting Linked Database option'); + SlashCommandSelectors.slashPanel() + .should('be.visible') + .within(() => { + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('linkedGrid')).first().as('linkedGridItem1'); + cy.get('@linkedGridItem1').click(); }); - }); - it('should retry loading if sync is slow', () => { - const testEmail = generateRandomEmail(); + waitForReactUpdate(1000); + + // Step 4: Choose the existing database + cy.task('log', `[STEP 8] Selecting source database: ${dbName}`); + SlashCommandSelectors.selectDatabase(dbName); + + waitForReactUpdate(2000); + + // Measure time for database to appear + const startTime = Date.now(); + cy.task('log', '[STEP 9] Waiting for linked database to appear'); + + // Step 5: Verify linked database appears + cy.get('[class*="appflowy-database"]', { timeout: 10000 }) + .should('exist') + .last() + .then(() => { + const elapsed = Date.now() - startTime; + cy.task('log', `[PERFORMANCE] Linked database appeared in ${elapsed}ms`); + + // Expected result: < 500ms typically, but allow up to 30s for CI (includes initial load) + expect(elapsed).to.be.lessThan(30000); - // Spy on console logs to check for retry messages - cy.on('window:before:load', (win) => { - cy.spy(win.console, 'log').as('consoleLog'); - cy.spy(win.console, 'warn').as('consoleWarn'); + if (elapsed > 500) { + cy.task('log', `[PERFORMANCE WARNING] Creation took ${elapsed}ms (expected < 500ms)`); + } }); - cy.task('log', `[TEST START] Testing retry mechanism - Test email: ${testEmail}`); - - // Login and setup (similar to previous test) - cy.visit('/login', { failOnStatusCode: false }); - cy.wait(2000); - - const authUtils = new AuthTestUtils(); - authUtils.signInWithTestUrl(testEmail).then(() => { - cy.url({ timeout: 30000 }).should('include', '/app'); - cy.wait(3000); - - // Create source DB - AddPageSelectors.inlineAddButton().first().as('addBtn2'); - cy.get('@addBtn2').click(); - waitForReactUpdate(1000); - AddPageSelectors.addGridButton().should('be.visible').as('gridBtn2'); - cy.get('@gridBtn2').click(); - cy.wait(5000); - const dbName = 'New Grid'; - cy.task('log', `[STEP] Using database name: ${dbName}`); - - // Create doc at same level as database - AddPageSelectors.inlineAddButton().first().as('addDocBtnSlash2'); - cy.get('@addDocBtnSlash2').should('be.visible').click(); - waitForReactUpdate(1000); - cy.get('[role="menuitem"]').first().as('menuItemSlash2'); - cy.get('@menuItemSlash2').click(); - waitForReactUpdate(1000); - - // Handle the new page modal if it appears - cy.get('body').then(($body) => { - if ($body.find('[data-testid="new-page-modal"]').length > 0) { - ModalSelectors.newPageModal().should('be.visible').within(() => { - ModalSelectors.spaceItemInModal().first().as('spaceItem2'); - cy.get('@spaceItem2').click(); - waitForReactUpdate(500); - cy.contains('button', 'Add').click(); - }); - cy.wait(3000); - } else { - cy.wait(3000); - } - }); + // Verify content is displayed + cy.task('log', '[STEP 10] Verifying database content'); + cy.get('[class*="appflowy-database"]') + .last() + .within(() => { + DatabaseGridSelectors.grid().should('exist'); + }); + + cy.task('log', '[TEST COMPLETE] Linked database slash menu creation test passed'); + }); + }); - // Wait for editor to be available - EditorSelectors.firstEditor().should('exist', { timeout: 15000 }); + it('should retry loading if sync is slow', () => { + const testEmail = generateRandomEmail(); - // Insert linked DB - EditorSelectors.firstEditor().click().type('/'); - waitForReactUpdate(500); + // Spy on console logs to check for retry messages + cy.on('window:before:load', (win) => { + cy.spy(win.console, 'log').as('consoleLog'); + cy.spy(win.console, 'warn').as('consoleWarn'); + }); - SlashCommandSelectors.slashPanel() - .should('be.visible') - .within(() => { - SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('linkedGrid')).first().as('linkedGridItem2'); - cy.get('@linkedGridItem2').click(); - }); + cy.task('log', `[TEST START] Testing retry mechanism - Test email: ${testEmail}`); + + // Login and setup (similar to previous test) + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url({ timeout: 30000 }).should('include', '/app'); + cy.wait(3000); + + // Create source DB + AddPageSelectors.inlineAddButton().first().as('addBtn2'); + cy.get('@addBtn2').click(); + waitForReactUpdate(1000); + AddPageSelectors.addGridButton().should('be.visible').as('gridBtn2'); + cy.get('@gridBtn2').click(); + cy.wait(5000); + const dbName = 'New Database'; + cy.task('log', `[STEP] Using database name: ${dbName}`); + + // Create doc at same level as database + AddPageSelectors.inlineAddButton().first().as('addDocBtnSlash2'); + cy.get('@addDocBtnSlash2').should('be.visible').click(); + waitForReactUpdate(1000); + cy.get('[role="menuitem"]').first().as('menuItemSlash2'); + cy.get('@menuItemSlash2').click(); + waitForReactUpdate(1000); + + // Handle the new page modal if it appears + cy.get('body').then(($body) => { + if ($body.find('[data-testid="new-page-modal"]').length > 0) { + ModalSelectors.newPageModal() + .should('be.visible') + .within(() => { + ModalSelectors.spaceItemInModal().first().as('spaceItem2'); + cy.get('@spaceItem2').click(); + waitForReactUpdate(500); + cy.contains('button', 'Add').click(); + }); + cy.wait(3000); + } else { + cy.wait(3000); + } + }); + + // Wait for editor to be available + EditorSelectors.firstEditor().should('exist', { timeout: 15000 }); + + // Insert linked DB + EditorSelectors.firstEditor().click().type('/'); + waitForReactUpdate(500); + + SlashCommandSelectors.slashPanel() + .should('be.visible') + .within(() => { + SlashCommandSelectors.slashMenuItem(getSlashMenuItemName('linkedGrid')).first().as('linkedGridItem2'); + cy.get('@linkedGridItem2').click(); + }); - waitForReactUpdate(1000); + waitForReactUpdate(1000); - // Select DB - cy.task('log', `[STEP] Selecting database: ${dbName}`); - SlashCommandSelectors.selectDatabase(dbName); + // Select DB + cy.task('log', `[STEP] Selecting database: ${dbName}`); + SlashCommandSelectors.selectDatabase(dbName); - waitForReactUpdate(2000); + waitForReactUpdate(2000); - // Wait for it to load - cy.get('[class*="appflowy-database"]', { timeout: 10000 }) - .should('exist') - .last(); + // Wait for it to load + cy.get('[class*="appflowy-database"]', { timeout: 10000 }).should('exist').last(); - // Check logs for retry attempts (this is a bit heuristic as we can't easily force a slow network) - // But we can check if the retry logic code path is at least active/logging - cy.task('log', '[STEP] Checking for retry/loading logs'); + // Check logs for retry attempts (this is a bit heuristic as we can't easily force a slow network) + // But we can check if the retry logic code path is at least active/logging + cy.task('log', '[STEP] Checking for retry/loading logs'); - // We might not see actual retries if it's fast, but we can verify the component loaded successfully - // If we wanted to force retries, we'd need to mock the backend to delay the response - // For now, we just ensure it eventually loads successfully + // We might not see actual retries if it's fast, but we can verify the component loaded successfully + // If we wanted to force retries, we'd need to mock the backend to delay the response + // For now, we just ensure it eventually loads successfully - cy.task('log', '[TEST COMPLETE] Retry mechanism test passed (implicit verification via successful load)'); - }); + cy.task('log', '[TEST COMPLETE] Retry mechanism test passed (implicit verification via successful load)'); }); + }); }); diff --git a/cypress/e2e/page/template-duplication.cy.ts b/cypress/e2e/page/template-duplication.cy.ts index 091d99c6e..35f5c42d9 100644 --- a/cypress/e2e/page/template-duplication.cy.ts +++ b/cypress/e2e/page/template-duplication.cy.ts @@ -12,29 +12,31 @@ import { SlashCommandSelectors, SpaceSelectors, WorkspaceSelectors, - waitForReactUpdate + waitForReactUpdate, } from '../../support/selectors'; import { testLog } from '../../support/test-helpers'; describe('Template Duplication Test - Document with Embedded Database', () => { const generateRandomEmail = () => `${uuidv4()}@appflowy.io`; - const dbName = 'New Grid'; + const dbName = 'New Database'; const docName = 'Untitled'; const spaceName = 'General'; const pageContent = 'This is test content for template duplication'; beforeEach(() => { cy.on('uncaught:exception', (err: Error) => { - if (err.message.includes('No workspace or service found') || + if ( + err.message.includes('No workspace or service found') || err.message.includes('createThemeNoVars_default is not a function') || err.message.includes('View not found') || - err.message.includes('Failed to execute \'writeText\' on \'Clipboard\': Document is not focused') || + err.message.includes("Failed to execute 'writeText' on 'Clipboard': Document is not focused") || err.message.includes('databaseId not found') || err.message.includes('Minified React error') || err.message.includes('useAppHandlers must be used within') || err.message.includes('Cannot resolve a DOM node from Slate') || err.message.includes('ResizeObserver loop') || - err.name === 'NotAllowedError') { + err.name === 'NotAllowedError' + ) { return false; } return true; @@ -49,14 +51,12 @@ describe('Template Duplication Test - Document with Embedded Database', () => { function expandSpaceInSidebar(spaceNameToExpand: string) { testLog.info(`Expanding space "${spaceNameToExpand}" in sidebar`); - SpaceSelectors.itemByName(spaceNameToExpand).then($space => { + SpaceSelectors.itemByName(spaceNameToExpand).then(($space) => { const expandedIndicator = $space.find('[data-testid="space-expanded"]'); const isExpanded = expandedIndicator.attr('data-expanded') === 'true'; if (!isExpanded) { - SpaceSelectors.itemByName(spaceNameToExpand) - .find('[data-testid="space-name"]') - .click({ force: true }); + SpaceSelectors.itemByName(spaceNameToExpand).find('[data-testid="space-name"]').click({ force: true }); waitForReactUpdate(500); } }); @@ -137,11 +137,13 @@ describe('Template Duplication Test - Document with Embedded Database', () => { cy.get('body').then(($body) => { if ($body.find('[data-testid="new-page-modal"]').length > 0) { testLog.info('Handling new page modal'); - ModalSelectors.newPageModal().should('be.visible').within(() => { - ModalSelectors.spaceItemInModal().first().click({ force: true }); - waitForReactUpdate(500); - cy.contains('button', 'Add').click({ force: true }); - }); + ModalSelectors.newPageModal() + .should('be.visible') + .within(() => { + ModalSelectors.spaceItemInModal().first().click({ force: true }); + waitForReactUpdate(500); + cy.contains('button', 'Add').click({ force: true }); + }); } }); @@ -181,15 +183,10 @@ describe('Template Duplication Test - Document with Embedded Database', () => { cy.get('[data-sonner-toast][data-type="error"]', { timeout: 2000 }).should('not.exist'); // Wait for embedded database container - cy.get('[class*="appflowy-database"]', { timeout: 15000 }) - .should('exist') - .last() - .should('be.visible'); + cy.get('[class*="appflowy-database"]', { timeout: 15000 }).should('exist').last().should('be.visible'); // Verify the embedded view tab shows "View of" prefix - cy.get('[role="tab"]', { timeout: 10000 }) - .should('be.visible') - .and('contain.text', 'View of'); + cy.get('[role="tab"]', { timeout: 10000 }).should('be.visible').and('contain.text', 'View of'); testLog.info('Embedded database successfully created'); @@ -219,192 +216,195 @@ describe('Template Duplication Test - Document with Embedded Database', () => { // Step 9: Get the published URL cy.window().then((win) => { const origin = win.location.origin; - ShareSelectors.publishNamespace().invoke('text').then((namespace) => { - ShareSelectors.publishNameInput().invoke('val').then((publishName) => { - const namespaceText = namespace.trim(); - const publishNameText = String(publishName).trim(); - const publishedUrl = `${origin}/${namespaceText}/${publishNameText}`; - testLog.info(`Published URL: ${publishedUrl}`); - - // Close share popover - cy.get('body').type('{esc}'); - cy.wait(1000); - - // Step 10: Create a NEW workspace to duplicate into - // This is important to test the db_mappings fix - the new workspace - // won't have the database mappings until they're synced - testLog.info('[STEP 10] Creating new workspace for duplication'); - createNewWorkspace(); - - // Verify we're now in the new workspace - testLog.info('Verifying switched to new workspace'); - SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); - cy.wait(2000); - - // Step 11: Visit the published page - testLog.info('[STEP 11] Visiting published page'); - cy.visit(publishedUrl, { failOnStatusCode: false }); - cy.wait(5000); - - // Verify published page loaded - cy.url().should('include', `/${namespaceText}/${publishNameText}`); - cy.get('body').should('contain.text', pageContent); - testLog.info('Published page loaded successfully'); - - // Step 12: Click "Start with this template" button - testLog.info('[STEP 12] Looking for "Start with this template" button'); - cy.contains('Start with this template', { timeout: 10000 }) - .should('be.visible') - .click({ force: true }); - testLog.info('Clicked "Start with this template" button'); - cy.wait(2000); - - // Step 13: Handle the duplicate modal - testLog.info('[STEP 13] Handling duplicate modal'); - - // Check if login modal appeared (user session might be different on publish page) - cy.get('body').then(($body) => { - const bodyText = $body.text(); - - if (bodyText.includes('Sign in') || bodyText.includes('Continue with Email')) { - testLog.info('Login required on publish page, signing in...'); - // Click continue with email - cy.contains('Continue with Email').click({ force: true }); + ShareSelectors.publishNamespace() + .invoke('text') + .then((namespace) => { + ShareSelectors.publishNameInput() + .invoke('val') + .then((publishName) => { + const namespaceText = namespace.trim(); + const publishNameText = String(publishName).trim(); + const publishedUrl = `${origin}/${namespaceText}/${publishNameText}`; + testLog.info(`Published URL: ${publishedUrl}`); + + // Close share popover + cy.get('body').type('{esc}'); cy.wait(1000); - // Enter email - cy.get('input[type="email"]').type(testEmail); - cy.contains('button', 'Continue').click({ force: true }); - cy.wait(5000); + // Step 10: Create a NEW workspace to duplicate into + // This is important to test the db_mappings fix - the new workspace + // won't have the database mappings until they're synced + testLog.info('[STEP 10] Creating new workspace for duplication'); + createNewWorkspace(); - // After login, the duplicate modal should appear - cy.wait(3000); - } - - // Now handle the duplicate modal - cy.get('body').then(($bodyAfterLogin) => { - if ($bodyAfterLogin.find('[role="dialog"]').length > 0 || - $bodyAfterLogin.text().includes('Add')) { - testLog.info('Duplicate modal is open'); - - // Wait for workspace list to load - testLog.info('Waiting for workspace list to load'); - cy.get('[role="dialog"]').should('be.visible'); - waitForReactUpdate(2000); - - // The workspace should show - wait for loading to complete - cy.get('[role="dialog"]').within(() => { - // Wait for the space list to appear (spaces under "Add to" section) - cy.contains('Add to').should('be.visible'); - waitForReactUpdate(1000); - - // Select the first available space (General or any other space) - testLog.info('Selecting a space in the new workspace'); - cy.get('[data-testid="space-item"]').first().should('be.visible').click({ force: true }); - waitForReactUpdate(500); - testLog.info('Space selected'); - }); + // Verify we're now in the new workspace + testLog.info('Verifying switched to new workspace'); + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + cy.wait(2000); - // Click Add button to duplicate - cy.contains('button', 'Add').should('be.visible').should('not.be.disabled').click({ force: true }); - testLog.info('Clicked Add button to duplicate'); - cy.wait(5000); + // Step 11: Visit the published page + testLog.info('[STEP 11] Visiting published page'); + cy.visit(publishedUrl, { failOnStatusCode: false }); + cy.wait(5000); - // Step 14: Handle success modal - testLog.info('[STEP 14] Handling success modal'); - cy.get('body').then(($bodyAfterDup) => { - if ($bodyAfterDup.text().includes('Open in Browser')) { - testLog.info('Success modal appeared'); + // Verify published page loaded + cy.url().should('include', `/${namespaceText}/${publishNameText}`); + cy.get('body').should('contain.text', pageContent); + testLog.info('Published page loaded successfully'); + + // Step 12: Click "Start with this template" button + testLog.info('[STEP 12] Looking for "Start with this template" button'); + cy.contains('Start with this template', { timeout: 10000 }).should('be.visible').click({ force: true }); + testLog.info('Clicked "Start with this template" button'); + cy.wait(2000); + + // Step 13: Handle the duplicate modal + testLog.info('[STEP 13] Handling duplicate modal'); + + // Check if login modal appeared (user session might be different on publish page) + cy.get('body').then(($body) => { + const bodyText = $body.text(); + + if (bodyText.includes('Sign in') || bodyText.includes('Continue with Email')) { + testLog.info('Login required on publish page, signing in...'); + // Click continue with email + cy.contains('Continue with Email').click({ force: true }); + cy.wait(1000); + + // Enter email + cy.get('input[type="email"]').type(testEmail); + cy.contains('button', 'Continue').click({ force: true }); + cy.wait(5000); + + // After login, the duplicate modal should appear + cy.wait(3000); + } + + // Now handle the duplicate modal + cy.get('body').then(($bodyAfterLogin) => { + if ($bodyAfterLogin.find('[role="dialog"]').length > 0 || $bodyAfterLogin.text().includes('Add')) { + testLog.info('Duplicate modal is open'); + + // Wait for workspace list to load + testLog.info('Waiting for workspace list to load'); + cy.get('[role="dialog"]').should('be.visible'); + waitForReactUpdate(2000); + + // The workspace should show - wait for loading to complete + cy.get('[role="dialog"]').within(() => { + // Wait for the space list to appear (spaces under "Add to" section) + cy.contains('Add to').should('be.visible'); + waitForReactUpdate(1000); + + // Select the first available space (General or any other space) + testLog.info('Selecting a space in the new workspace'); + cy.get('[data-testid="space-item"]').first().should('be.visible').click({ force: true }); + waitForReactUpdate(500); + testLog.info('Space selected'); + }); - // Click "Open in Browser" to navigate to the duplicated view - cy.contains('Open in Browser').should('be.visible').click({ force: true }); - testLog.info('Clicked "Open in Browser"'); + // Click Add button to duplicate + cy.contains('button', 'Add').should('be.visible').should('not.be.disabled').click({ force: true }); + testLog.info('Clicked Add button to duplicate'); cy.wait(5000); - // Step 15: Verify we're on the app with the duplicated view in the NEW workspace - cy.url().then((finalUrl) => { - testLog.info(`Final URL: ${finalUrl}`); - - // Check for db_mappings in URL (our fix) - if (finalUrl.includes('db_mappings=')) { - testLog.info('SUCCESS: db_mappings parameter found in URL'); - const urlObj = new URL(finalUrl); - const dbMappings = urlObj.searchParams.get('db_mappings'); - if (dbMappings) { - testLog.info(`Database mappings: ${decodeURIComponent(dbMappings)}`); - } - } else { - testLog.info('Note: db_mappings not in URL'); - } - - expect(finalUrl).to.include('/app/'); - testLog.info('Navigated to app with duplicated view in new workspace'); - }); + // Step 14: Handle success modal + testLog.info('[STEP 14] Handling success modal'); + cy.get('body').then(($bodyAfterDup) => { + if ($bodyAfterDup.text().includes('Open in Browser')) { + testLog.info('Success modal appeared'); + + // Click "Open in Browser" to navigate to the duplicated view + cy.contains('Open in Browser').should('be.visible').click({ force: true }); + testLog.info('Clicked "Open in Browser"'); + cy.wait(5000); + + // Step 15: Verify we're on the app with the duplicated view in the NEW workspace + cy.url().then((finalUrl) => { + testLog.info(`Final URL: ${finalUrl}`); + + // Check for db_mappings in URL (our fix) + if (finalUrl.includes('db_mappings=')) { + testLog.info('SUCCESS: db_mappings parameter found in URL'); + const urlObj = new URL(finalUrl); + const dbMappings = urlObj.searchParams.get('db_mappings'); + if (dbMappings) { + testLog.info(`Database mappings: ${decodeURIComponent(dbMappings)}`); + } + } else { + testLog.info('Note: db_mappings not in URL'); + } + + expect(finalUrl).to.include('/app/'); + testLog.info('Navigated to app with duplicated view in new workspace'); + }); - // Wait for the view to load - cy.wait(5000); + // Wait for the view to load + cy.wait(5000); + + // Step 16: Verify duplication was successful + testLog.info('[STEP 16] Verifying duplication was successful'); + + // Verify the content is present + cy.get('body').should('contain.text', pageContent); + testLog.info('SUCCESS: Duplicated content verified'); + + // Check that the embedded database is visible + // This is the KEY verification - without our fix, this would fail + // because the new workspace doesn't have the database mappings yet + testLog.info('Checking embedded database is visible...'); + cy.get('[class*="appflowy-database"]', { timeout: 20000 }) + .should('exist') + .should('be.visible'); + testLog.info('SUCCESS: Embedded database container found and visible!'); + + // Verify the embedded database has loaded properly (has tabs/content) + cy.get('[class*="appflowy-database"]').within(() => { + // Check for view tabs (indicates database structure loaded) + cy.get('[role="tab"]').should('exist'); + testLog.info('SUCCESS: Database view tabs present'); + }); - // Step 16: Verify duplication was successful - testLog.info('[STEP 16] Verifying duplication was successful'); - - // Verify the content is present - cy.get('body').should('contain.text', pageContent); - testLog.info('SUCCESS: Duplicated content verified'); - - // Check that the embedded database is visible - // This is the KEY verification - without our fix, this would fail - // because the new workspace doesn't have the database mappings yet - testLog.info('Checking embedded database is visible...'); - cy.get('[class*="appflowy-database"]', { timeout: 20000 }).should('exist').should('be.visible'); - testLog.info('SUCCESS: Embedded database container found and visible!'); - - // Verify the embedded database has loaded properly (has tabs/content) - cy.get('[class*="appflowy-database"]').within(() => { - // Check for view tabs (indicates database structure loaded) - cy.get('[role="tab"]').should('exist'); - testLog.info('SUCCESS: Database view tabs present'); - }); + // Check localStorage for db_mappings (our fix persists them) + cy.window().then((win) => { + const keys = Object.keys(win.localStorage).filter((k) => k.startsWith('db_mappings_')); - // Check localStorage for db_mappings (our fix persists them) - cy.window().then((win) => { - const keys = Object.keys(win.localStorage).filter(k => k.startsWith('db_mappings_')); + if (keys.length > 0) { + testLog.info(`SUCCESS: localStorage db_mappings keys found: ${keys.join(', ')}`); + keys.forEach((key) => { + const value = win.localStorage.getItem(key); - if (keys.length > 0) { - testLog.info(`SUCCESS: localStorage db_mappings keys found: ${keys.join(', ')}`); - keys.forEach(key => { - const value = win.localStorage.getItem(key); + testLog.info(` ${key}: ${value}`); + }); + } else { + testLog.info('Note: No db_mappings in localStorage (may have been consumed)'); + } + }); - testLog.info(` ${key}: ${value}`); + // Final verification - check we're in the new workspace (different workspace ID in URL) + cy.url().then((url) => { + testLog.info(`Final URL: ${url}`); + expect(url).to.include('/app/'); + testLog.info('SUCCESS: Template successfully duplicated to new workspace!'); }); - } else { - testLog.info('Note: No db_mappings in localStorage (may have been consumed)'); + + testLog.info('[TEST COMPLETE] All verifications passed!'); + } else if ($bodyAfterDup.text().includes('Open in App')) { + testLog.info('Success modal with "Open in App" appeared'); + cy.contains('Open in Browser').click({ force: true }); + cy.wait(3000); } }); - - // Final verification - check we're in the new workspace (different workspace ID in URL) - cy.url().then((url) => { - testLog.info(`Final URL: ${url}`); - expect(url).to.include('/app/'); - testLog.info('SUCCESS: Template successfully duplicated to new workspace!'); + } else { + testLog.info('Modal state unclear, checking current URL'); + cy.url().then((currentUrl) => { + testLog.info(`Current URL: ${currentUrl}`); }); - - testLog.info('[TEST COMPLETE] All verifications passed!'); - } else if ($bodyAfterDup.text().includes('Open in App')) { - testLog.info('Success modal with "Open in App" appeared'); - cy.contains('Open in Browser').click({ force: true }); - cy.wait(3000); } }); - } else { - testLog.info('Modal state unclear, checking current URL'); - cy.url().then((currentUrl) => { - testLog.info(`Current URL: ${currentUrl}`); - }); - } + }); }); - }); }); - }); }); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index b018db018..53baa695d 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -21,4 +21,32 @@ Cypress.Commands.add('mockAPI', () => { // Mock the API }); +/** + * Clear all IndexedDB databases to ensure clean test state + * This removes stale document caches from y-indexeddb and the app's Dexie cache + */ +Cypress.Commands.add('clearAllIndexedDB', () => { + return cy.window().then(async (win) => { + try { + const databases = await win.indexedDB.databases(); + const deletePromises = databases.map((db) => { + return new Promise((resolve) => { + if (db.name) { + const request = win.indexedDB.deleteDatabase(db.name); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); // Resolve even on error to not block other deletions + request.onblocked = () => resolve(); + } else { + resolve(); + } + }); + }); + await Promise.all(deletePromises); + cy.log(`Cleared ${databases.length} IndexedDB databases`); + } catch (e) { + cy.log('Failed to clear IndexedDB databases'); + } + }); +}); + export {}; diff --git a/cypress/support/cypress.d.ts b/cypress/support/cypress.d.ts index 6bdf57e5b..8e5485e03 100644 --- a/cypress/support/cypress.d.ts +++ b/cypress/support/cypress.d.ts @@ -6,7 +6,11 @@ declare namespace Cypress { } interface Chainable { - // Add any custom commands here + /** + * Clear all IndexedDB databases to ensure clean test state + * This removes stale document caches from y-indexeddb and the app's Dexie cache + */ + clearAllIndexedDB(): Chainable; } // Fix for uncaught:exception event diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 1adc30598..a9fcc7723 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -60,4 +60,4 @@ Cypress.on('uncaught:exception', (err) => { return false; } return true; -}); \ No newline at end of file +}); diff --git a/cypress/support/selectors.ts b/cypress/support/selectors.ts index 6f870593b..6d48ab034 100644 --- a/cypress/support/selectors.ts +++ b/cypress/support/selectors.ts @@ -21,42 +21,70 @@ export function byTestIdContains(fragment: string): string { return `[data-testid*="${fragment}"]`; } +type CypressGetOptions = Partial; + +/** + * Extracts a viewId from a sidebar page item test id (e.g. "page-"). + */ +export function viewIdFromPageTestId(testId: string | null | undefined): string { + if (!testId || !testId.startsWith('page-')) { + throw new Error(`Expected data-testid to start with "page-" but got: ${String(testId)}`); + } + + return testId.slice('page-'.length); +} + /** * Page-related selectors */ export const PageSelectors = { // Get all page items - items: () => cy.get(byTestId('page-item')), + items: (options?: CypressGetOptions) => cy.get(byTestId('page-item'), options), // Get all page names - names: () => cy.get(byTestId('page-name')), + names: (options?: CypressGetOptions) => cy.get(byTestId('page-name'), options), + + // Get page row by view id (clickable container in the sidebar list) + pageByViewId: (viewId: string, options?: CypressGetOptions) => { + return cy.get(byTestId(`page-${viewId}`), options).first(); + }, + + // Get page item by view id + itemByViewId: (viewId: string, options?: CypressGetOptions) => { + return PageSelectors.pageByViewId(viewId, options).closest(byTestId('page-item')); + }, // Get page name containing specific text - nameContaining: (text: string) => cy.get(byTestId('page-name')).contains(text), + nameContaining: (text: string, options?: CypressGetOptions) => cy.get(byTestId('page-name'), options).contains(text), // Get page item containing specific page name - itemByName: (pageName: string) => { - return cy.get(byTestId('page-name')) - .contains(pageName) - .first() - .closest(byTestId('page-item')); + itemByName: (pageName: string, options?: CypressGetOptions) => { + return cy.get(byTestId('page-name'), options).contains(pageName).first().closest(byTestId('page-item')); }, + // Get the first child viewId for a page (e.g. database container -> first database view) + firstChildViewIdByName: (pageName: string) => + PageSelectors.itemByName(pageName) + .find(byTestId('page-item')) + .first() + .children() + .first() + .invoke('attr', 'data-testid') + .then((testId) => viewIdFromPageTestId(testId)), + // Get more actions button for a specific page moreActionsButton: (pageName?: string) => { if (pageName) { - return PageSelectors.itemByName(pageName) - .find(byTestId('page-more-actions')) - .first(); // Ensure we only get one button even if multiple exist + return PageSelectors.itemByName(pageName).find(byTestId('page-more-actions')).first(); // Ensure we only get one button even if multiple exist } return cy.get(byTestId('page-more-actions')); }, // Get new page button - newPageButton: () => cy.get(byTestId('new-page-button')), + newPageButton: (options?: CypressGetOptions) => cy.get(byTestId('new-page-button'), options), // Get page title input - titleInput: () => cy.get(byTestId('page-title-input')), + titleInput: (options?: CypressGetOptions) => cy.get(byTestId('page-title-input'), options), }; /** @@ -99,10 +127,8 @@ export const SpaceSelectors = { expanded: () => cy.get(byTestId('space-expanded')), // Get space by name - itemByName: (spaceName: string) => { - return cy.get(byTestId('space-name')) - .contains(spaceName) - .closest(byTestId('space-item')); + itemByName: (spaceName: string, options?: CypressGetOptions) => { + return cy.get(byTestId('space-name'), options).contains(spaceName).closest(byTestId('space-item')); }, // Get more actions button for spaces @@ -170,7 +196,7 @@ export const ModalSelectors = { // Rename modal inputs renameInput: () => cy.get(byTestId('rename-modal-input')), renameSaveButton: () => cy.get(byTestId('rename-modal-save')), - + // Generic dialog selectors dialogContainer: () => cy.get('.MuiDialog-container'), dialogRole: () => cy.get('[role="dialog"]'), @@ -190,9 +216,7 @@ export const DropdownSelectors = { * Helper function to trigger hover on an element to show hidden actions */ export function hoverToShowActions(element: Cypress.Chainable) { - return element - .trigger('mouseenter', { force: true }) - .trigger('mouseover', { force: true }); + return element.trigger('mouseenter', { force: true }).trigger('mouseover', { force: true }); } /** @@ -204,7 +228,7 @@ export const ShareSelectors = { // Share popover sharePopover: () => cy.get(byTestId('share-popover')), - + // Share inputs emailTagInput: () => cy.get('[data-slot="email-tag-input"]'), inviteButton: () => cy.contains('button', /invite/i), @@ -278,7 +302,7 @@ export const WorkspaceSelectors = { */ export const SidebarSelectors = { // Sidebar page header - pageHeader: () => cy.get(byTestId('sidebar-page-header')), + pageHeader: (options?: CypressGetOptions) => cy.get(byTestId('sidebar-page-header'), options), }; /** @@ -318,13 +342,13 @@ export const ModelSelectorSelectors = { * Chat UI selectors */ export const ChatSelectors = { - aiChatContainer: () => cy.get(byTestId('ai-chat-container')), - formatToggle: () => cy.get(byTestId('chat-input-format-toggle')), - formatGroup: () => cy.get(byTestId('chat-format-group')), - browsePromptsButton: () => cy.get(byTestId('chat-input-browse-prompts')), - relatedViewsButton: () => cy.get(byTestId('chat-input-related-views')), - relatedViewsPopover: () => cy.get(byTestId('chat-related-views-popover')), - sendButton: () => cy.get(byTestId('chat-input-send')), + aiChatContainer: (options?: CypressGetOptions) => cy.get(byTestId('ai-chat-container'), options), + formatToggle: (options?: CypressGetOptions) => cy.get(byTestId('chat-input-format-toggle'), options), + formatGroup: (options?: CypressGetOptions) => cy.get(byTestId('chat-format-group'), options), + browsePromptsButton: (options?: CypressGetOptions) => cy.get(byTestId('chat-input-browse-prompts'), options), + relatedViewsButton: (options?: CypressGetOptions) => cy.get(byTestId('chat-input-related-views'), options), + relatedViewsPopover: (options?: CypressGetOptions) => cy.get(byTestId('chat-related-views-popover'), options), + sendButton: (options?: CypressGetOptions) => cy.get(byTestId('chat-input-send'), options), }; /** @@ -361,7 +385,9 @@ export const DatabaseGridSelectors = { // Get clickable row cell wrappers for a field (DATA ROWS ONLY) // These have data-column-id={fieldId} and contain the onClick handler dataRowCellsForField: (fieldId: string) => - cy.get(`[data-testid^="grid-row-"]:not([data-testid="grid-row-undefined"]) .grid-row-cell[data-column-id="${fieldId}"]`), + cy.get( + `[data-testid^="grid-row-"]:not([data-testid="grid-row-undefined"]) .grid-row-cell[data-column-id="${fieldId}"]` + ), // Get first cell firstCell: () => cy.get('[data-testid^="grid-cell-"]').first(), @@ -375,11 +401,18 @@ export const DatabaseGridSelectors = { */ export const DatabaseViewSelectors = { // View tabs - viewTab: (viewId?: string) => viewId ? cy.get(byTestId(`view-tab-${viewId}`)) : cy.get('[data-testid^="view-tab-"]'), + viewTab: (viewId?: string) => (viewId ? cy.get(byTestId(`view-tab-${viewId}`)) : cy.get('[data-testid^="view-tab-"]')), // Active view tab activeViewTab: () => cy.get('[data-testid^="view-tab-"][data-state="active"]'), + // Tab context menu actions + tabActionRename: () => cy.get(byTestId('database-view-action-rename')), + tabActionDelete: () => cy.get(byTestId('database-view-action-delete')), + + // Delete view confirm dialog button + deleteViewConfirmButton: () => cy.get(byTestId('database-view-delete-confirm')), + // View name input viewNameInput: () => cy.get(byTestId('view-name-input')), @@ -419,7 +452,12 @@ export const DatabaseFilterSelectors = { sortCondition: () => cy.get(byTestId('database-sort-condition')), // Remove filter button (inside condition) - removeFilterButton: () => cy.get('button[aria-label*="remove"], button[aria-label*="delete"], button:contains("×"), svg[class*="close"], svg[class*="x"]').first(), + removeFilterButton: () => + cy + .get( + 'button[aria-label*="remove"], button[aria-label*="delete"], button:contains("×"), svg[class*="close"], svg[class*="x"]' + ) + .first(), // Filter input filterInput: () => cy.get(byTestId('text-filter-input')), @@ -457,92 +495,105 @@ export const SlashCommandSelectors = { }); // Find the MUI Popover paper element and interact with it - cy.get('.MuiPopover-paper').last().should('be.visible').within(() => { - if (dbName) { - // Try to search for specific database with retry logic - cy.get('input[placeholder*="Search"]').should('be.visible').clear().type(dbName); - - // Retry mechanism: wait and check if database appears (up to 5 attempts) - let attempts = 0; - const maxAttempts = 5; - const checkDatabase = () => { - attempts++; - cy.task('log', `[selectDatabase] Attempt ${attempts}/${maxAttempts} to find database "${dbName}"`); - - waitForReactUpdate(2000); - - cy.get('[class*="appflowy-scrollbar"]').then(($area) => { - const areaText = $area.text(); - - // Check if we have "No databases found" message - if (areaText.includes('No databases found')) { - if (attempts < maxAttempts) { - cy.task('log', `[selectDatabase] No databases found, retrying... (attempt ${attempts})`); - waitForReactUpdate(3000); // Wait longer before retry - checkDatabase(); - return; - } else { - cy.task('log', '[selectDatabase] No databases found after all retries'); - throw new Error('No databases available to select'); + cy.get('.MuiPopover-paper') + .last() + .should('be.visible') + .within(() => { + if (dbName) { + // Try to search for specific database with retry logic + cy.get('input[placeholder*="Search"]').should('be.visible').clear().type(dbName); + + // Retry mechanism: wait and check if database appears (up to 5 attempts) + let attempts = 0; + const maxAttempts = 5; + const checkDatabase = () => { + attempts++; + cy.task('log', `[selectDatabase] Attempt ${attempts}/${maxAttempts} to find database "${dbName}"`); + + waitForReactUpdate(2000); + + cy.get('[class*="appflowy-scrollbar"]').then(($area) => { + const areaText = $area.text(); + + // Check if we have "No databases found" message + if (areaText.includes('No databases found')) { + if (attempts < maxAttempts) { + cy.task('log', `[selectDatabase] No databases found, retrying... (attempt ${attempts})`); + waitForReactUpdate(3000); // Wait longer before retry + checkDatabase(); + return; + } else { + cy.task('log', '[selectDatabase] No databases found after all retries'); + throw new Error('No databases available to select'); + } } - } - - if (areaText.includes(dbName)) { - // Database found by name, find the span and click its parent div - cy.task('log', `[selectDatabase] Database "${dbName}" found, selecting it`); - cy.contains('span', dbName).parent('div').click({ force: true }); - } else { - // Database not found yet, retry if we haven't exceeded max attempts - if (attempts < maxAttempts) { - cy.task('log', `[selectDatabase] Database "${dbName}" not found yet, retrying... (attempt ${attempts})`); - waitForReactUpdate(3000); // Wait longer before retry - checkDatabase(); + + if (areaText.includes(dbName)) { + // Database found by name, find the span and click its parent div + cy.task('log', `[selectDatabase] Database "${dbName}" found, selecting it`); + cy.contains('span', dbName).parent('div').click({ force: true }); } else { - // After all retries, select first available database - cy.task('log', `[selectDatabase] Database "${dbName}" not found after ${maxAttempts} attempts, selecting first available`); - cy.get('[class*="appflowy-scrollbar"]').within(() => { - cy.get('span').then(($spans) => { - const $dbSpan = Array.from($spans).find((span) => { - const text = span.textContent?.trim() || ''; - return /Grid|View|Database|Kanban|Calendar/i.test(text) && - text.length > 0 && - !text.includes('Link to an existing database'); + // Database not found yet, retry if we haven't exceeded max attempts + if (attempts < maxAttempts) { + cy.task( + 'log', + `[selectDatabase] Database "${dbName}" not found yet, retrying... (attempt ${attempts})` + ); + waitForReactUpdate(3000); // Wait longer before retry + checkDatabase(); + } else { + // After all retries, select first available database + cy.task( + 'log', + `[selectDatabase] Database "${dbName}" not found after ${maxAttempts} attempts, selecting first available` + ); + cy.get('[class*="appflowy-scrollbar"]').within(() => { + cy.get('span').then(($spans) => { + const $dbSpan = Array.from($spans).find((span) => { + const text = span.textContent?.trim() || ''; + return ( + /Grid|View|Database|Kanban|Calendar/i.test(text) && + text.length > 0 && + !text.includes('Link to an existing database') + ); + }); + + if ($dbSpan) { + cy.wrap($dbSpan).parent('div').click({ force: true }); + } else { + cy.get('div').first().click({ force: true }); + } }); - - if ($dbSpan) { - cy.wrap($dbSpan).parent('div').click({ force: true }); - } else { - cy.get('div').first().click({ force: true }); - } }); - }); + } } - } - }); - }; - - checkDatabase(); - } else { - // No name provided, select first available database - waitForReactUpdate(2000); - cy.get('[class*="appflowy-scrollbar"]').within(() => { - cy.get('span').then(($spans) => { - const $dbSpan = Array.from($spans).find((span) => { - const text = span.textContent?.trim() || ''; - return /Grid|View|Database|Kanban|Calendar/i.test(text) && - text.length > 0 && - !text.includes('Link to an existing database'); }); + }; - if ($dbSpan) { - cy.wrap($dbSpan).parent('div').click({ force: true }); - } else { - cy.get('div').first().click({ force: true }); - } + checkDatabase(); + } else { + // No name provided, select first available database + waitForReactUpdate(2000); + cy.get('[class*="appflowy-scrollbar"]').within(() => { + cy.get('span').then(($spans) => { + const $dbSpan = Array.from($spans).find((span) => { + const text = span.textContent?.trim() || ''; + return ( + /Grid|View|Database|Kanban|Calendar/i.test(text) && + text.length > 0 && + !text.includes('Link to an existing database') + ); + }); + + if ($dbSpan) { + cy.wrap($dbSpan).parent('div').click({ force: true }); + } else { + cy.get('div').first().click({ force: true }); + } + }); }); - }); - } - }); + } + }); }, }; diff --git a/jest.config.cjs b/jest.config.cjs index 53eddc3a7..1da170c05 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -9,6 +9,7 @@ module.exports = { roots: [''], modulePaths: [compilerOptions.baseUrl], moduleNameMapper: { + '^.+\\.svg$': '/src/__mocks__/svgrMock.tsx', ...pathsToModuleNameMapper(compilerOptions.paths), '^lodash-es(/(.*)|$)': 'lodash$1', '^nanoid(/(.*)|$)': 'nanoid$1', @@ -47,4 +48,4 @@ module.exports = { '/__fixtures__/', '/application/folder-yjs/', ], -}; \ No newline at end of file +}; diff --git a/src/__mocks__/svgrMock.tsx b/src/__mocks__/svgrMock.tsx new file mode 100644 index 000000000..3c9ccf325 --- /dev/null +++ b/src/__mocks__/svgrMock.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const ReactComponent = (props: React.SVGProps) => ; + +export default 'SvgrMock'; diff --git a/src/application/__tests__/view-utils.test.ts b/src/application/__tests__/view-utils.test.ts new file mode 100644 index 000000000..acd4a63d6 --- /dev/null +++ b/src/application/__tests__/view-utils.test.ts @@ -0,0 +1,613 @@ +import { expect } from '@jest/globals'; +import { View, ViewLayout } from '../types'; +import { + isDatabaseLayout, + isDatabaseContainer, + isEmbeddedView, + getDatabaseIdFromExtra, + isReferencedDatabaseView, + getFirstChildView, + getDatabaseTabViewIds, +} from '../view-utils'; + +/** + * Tests for view-utils.ts - Database Container Support + * + * These tests verify the utility functions that implement database container logic + * matching the Desktop/Flutter implementation. + * + * Reference: AppFlowy-Premium/frontend/doc/context/database_container_behavior.md + */ + +// Helper function to create a mock View object +function createMockView(overrides: Partial = {}): View { + return { + view_id: 'test-view-id', + name: 'Test View', + icon: null, + layout: ViewLayout.Document, + extra: null, + children: [], + is_private: false, + ...overrides, + }; +} + +describe('view-utils', () => { + describe('isDatabaseLayout', () => { + it('should return true for Grid layout', () => { + expect(isDatabaseLayout(ViewLayout.Grid)).toBe(true); + }); + + it('should return true for Board layout', () => { + expect(isDatabaseLayout(ViewLayout.Board)).toBe(true); + }); + + it('should return true for Calendar layout', () => { + expect(isDatabaseLayout(ViewLayout.Calendar)).toBe(true); + }); + + it('should return false for Document layout', () => { + expect(isDatabaseLayout(ViewLayout.Document)).toBe(false); + }); + + it('should return false for AIChat layout', () => { + expect(isDatabaseLayout(ViewLayout.AIChat)).toBe(false); + }); + }); + + describe('isDatabaseContainer', () => { + it('should return true when is_database_container is true', () => { + const view = createMockView({ + extra: { is_space: false, is_database_container: true }, + }); + expect(isDatabaseContainer(view)).toBe(true); + }); + + it('should return false when is_database_container is false', () => { + const view = createMockView({ + extra: { is_space: false, is_database_container: false }, + }); + expect(isDatabaseContainer(view)).toBe(false); + }); + + it('should return false when is_database_container is not set', () => { + const view = createMockView({ + extra: { is_space: false }, + }); + expect(isDatabaseContainer(view)).toBe(false); + }); + + it('should return false when extra is null', () => { + const view = createMockView({ extra: null }); + expect(isDatabaseContainer(view)).toBe(false); + }); + + it('should return false for null view', () => { + expect(isDatabaseContainer(null)).toBe(false); + }); + + it('should return false for undefined view', () => { + expect(isDatabaseContainer(undefined)).toBe(false); + }); + }); + + describe('getDatabaseIdFromExtra', () => { + it('should return database_id when set', () => { + const view = createMockView({ + extra: { is_space: false, database_id: 'db-123' }, + }); + expect(getDatabaseIdFromExtra(view)).toBe('db-123'); + }); + + it('should return undefined when database_id is not set', () => { + const view = createMockView({ + extra: { is_space: false }, + }); + expect(getDatabaseIdFromExtra(view)).toBeUndefined(); + }); + + it('should return undefined when extra is null', () => { + const view = createMockView({ extra: null }); + expect(getDatabaseIdFromExtra(view)).toBeUndefined(); + }); + + it('should return undefined for null view', () => { + expect(getDatabaseIdFromExtra(null)).toBeUndefined(); + }); + + it('should return undefined for undefined view', () => { + expect(getDatabaseIdFromExtra(undefined)).toBeUndefined(); + }); + }); + + describe('isReferencedDatabaseView', () => { + /** + * Scenario: Referenced database views show a dot icon instead of expand/collapse. + * This happens when a database view is a child of another database view + * (including database containers, whose layout is also a database layout). + */ + + it('should return true when database view is child of another database view', () => { + // Grid view under another Grid view (linked database) + const childView = createMockView({ + view_id: 'child-grid', + layout: ViewLayout.Grid, + }); + const parentView = createMockView({ + view_id: 'parent-grid', + layout: ViewLayout.Grid, + }); + expect(isReferencedDatabaseView(childView, parentView)).toBe(true); + }); + + it('should return true for Board under Grid', () => { + const childView = createMockView({ + view_id: 'child-board', + layout: ViewLayout.Board, + }); + const parentView = createMockView({ + view_id: 'parent-grid', + layout: ViewLayout.Grid, + }); + expect(isReferencedDatabaseView(childView, parentView)).toBe(true); + }); + + it('should return true for Calendar under Board', () => { + const childView = createMockView({ + view_id: 'child-calendar', + layout: ViewLayout.Calendar, + }); + const parentView = createMockView({ + view_id: 'parent-board', + layout: ViewLayout.Board, + }); + expect(isReferencedDatabaseView(childView, parentView)).toBe(true); + }); + + it('should return true when parent is a database container', () => { + const childView = createMockView({ + view_id: 'child-grid', + layout: ViewLayout.Grid, + }); + const containerView = createMockView({ + view_id: 'container', + layout: ViewLayout.Grid, // Container might have any layout + extra: { is_space: false, is_database_container: true }, + }); + expect(isReferencedDatabaseView(childView, containerView)).toBe(true); + }); + + it('should return false when database view is under Document', () => { + const childView = createMockView({ + view_id: 'child-grid', + layout: ViewLayout.Grid, + }); + const parentView = createMockView({ + view_id: 'parent-doc', + layout: ViewLayout.Document, + }); + expect(isReferencedDatabaseView(childView, parentView)).toBe(false); + }); + + it('should return false when Document view is under database view', () => { + const childView = createMockView({ + view_id: 'child-doc', + layout: ViewLayout.Document, + }); + const parentView = createMockView({ + view_id: 'parent-grid', + layout: ViewLayout.Grid, + }); + expect(isReferencedDatabaseView(childView, parentView)).toBe(false); + }); + + it('should return false when parent is null', () => { + const childView = createMockView({ + view_id: 'child-grid', + layout: ViewLayout.Grid, + }); + expect(isReferencedDatabaseView(childView, null)).toBe(false); + }); + + it('should return false when view is null', () => { + const parentView = createMockView({ + view_id: 'parent-grid', + layout: ViewLayout.Grid, + }); + expect(isReferencedDatabaseView(null, parentView)).toBe(false); + }); + + it('should return false when both are null', () => { + expect(isReferencedDatabaseView(null, null)).toBe(false); + }); + + it('should return false when both are undefined', () => { + expect(isReferencedDatabaseView(undefined, undefined)).toBe(false); + }); + }); + + describe('getFirstChildView', () => { + /** + * Scenario 1: Clicking on a database container should auto-open first child. + */ + + it('should return first child of database container', () => { + const childView = createMockView({ + view_id: 'first-child-grid', + layout: ViewLayout.Grid, + }); + const secondChild = createMockView({ + view_id: 'second-child-board', + layout: ViewLayout.Board, + }); + const containerView = createMockView({ + view_id: 'container', + extra: { is_space: false, is_database_container: true }, + children: [childView, secondChild], + }); + + const result = getFirstChildView(containerView); + expect(result).toBeDefined(); + expect(result?.view_id).toBe('first-child-grid'); + }); + + it('should return undefined for container with no children', () => { + const containerView = createMockView({ + view_id: 'container', + extra: { is_space: false, is_database_container: true }, + children: [], + }); + + expect(getFirstChildView(containerView)).toBeUndefined(); + }); + + it('should return undefined for non-container view with children', () => { + const childView = createMockView({ + view_id: 'child-doc', + layout: ViewLayout.Document, + }); + const regularView = createMockView({ + view_id: 'regular-doc', + layout: ViewLayout.Document, + children: [childView], + }); + + expect(getFirstChildView(regularView)).toBeUndefined(); + }); + + it('should return undefined for null view', () => { + expect(getFirstChildView(null)).toBeUndefined(); + }); + + it('should return undefined for undefined view', () => { + expect(getFirstChildView(undefined)).toBeUndefined(); + }); + }); + + /** + * Integration tests that verify complete container scenarios + */ + describe('Database Container Scenarios', () => { + /** + * Scenario 1: Standalone database from sidebar + * + * Structure: + * Sidebar + * └── Database Container (is_database_container: true, database_id: xxx) + * └── Grid View (database_id: xxx) + */ + describe('Scenario 1: Standalone database from sidebar', () => { + const gridView = createMockView({ + view_id: 'grid-view-id', + name: 'Grid View', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-123' }, + }); + + const containerView = createMockView({ + view_id: 'container-id', + name: 'My Database', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-123', + }, + children: [gridView], + }); + + it('container should be identified as database container', () => { + expect(isDatabaseContainer(containerView)).toBe(true); + }); + + it('container should have database_id in extra', () => { + expect(getDatabaseIdFromExtra(containerView)).toBe('db-123'); + }); + + it('clicking container should navigate to first child', () => { + const firstChild = getFirstChildView(containerView); + expect(firstChild).toBeDefined(); + expect(firstChild?.view_id).toBe('grid-view-id'); + }); + + it('child view should be referenced database view (dot icon)', () => { + expect(isReferencedDatabaseView(gridView, containerView)).toBe(true); + }); + }); + + /** + * Scenario 2: New database in document (embedded) + * + * Structure: + * Document + * └── [Database Block referencing view_id] + * ↓ + * Sidebar/child of document + * └── Database Container (is_database_container: true) + * └── Grid View (database_id: xxx) + */ + describe('Scenario 2: New database in document', () => { + const embeddedGridView = createMockView({ + view_id: 'embedded-grid-id', + name: 'Embedded Grid', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-456' }, + }); + + const containerForEmbedded = createMockView({ + view_id: 'container-for-embedded', + name: 'Embedded Database', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-456', + }, + children: [embeddedGridView], + }); + + it('container for embedded should be identified correctly', () => { + expect(isDatabaseContainer(containerForEmbedded)).toBe(true); + }); + + it('embedded grid view should have database_id', () => { + expect(getDatabaseIdFromExtra(embeddedGridView)).toBe('db-456'); + }); + + it('getFirstChildView returns the embedded view', () => { + const firstChild = getFirstChildView(containerForEmbedded); + expect(firstChild?.view_id).toBe('embedded-grid-id'); + }); + }); + + /** + * Scenario 3: Link existing database in document + * + * NO container created - linked view is child of document directly. + */ + describe('Scenario 3: Link existing database in document', () => { + const linkedView = createMockView({ + view_id: 'linked-view-id', + name: 'Linked Grid', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'existing-db-789' }, + }); + + const documentView = createMockView({ + view_id: 'doc-id', + name: 'My Document', + layout: ViewLayout.Document, + children: [linkedView], + }); + + it('document is NOT a database container', () => { + expect(isDatabaseContainer(documentView)).toBe(false); + }); + + it('linked view has database_id pointing to existing database', () => { + expect(getDatabaseIdFromExtra(linkedView)).toBe('existing-db-789'); + }); + + it('linked view under document is NOT a referenced database view', () => { + // Because parent is Document, not a database layout + expect(isReferencedDatabaseView(linkedView, documentView)).toBe(false); + }); + }); + + /** + * Scenario 4: Add view via tab bar + * + * New view added to existing container - NO new container created. + * + * Structure: + * Sidebar + * └── Database Container + * ├── Grid View (original) + * └── Board View (new linked view) + */ + describe('Scenario 4: Add view via tab bar', () => { + const originalGridView = createMockView({ + view_id: 'original-grid', + name: 'Original Grid', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-tab-bar' }, + }); + + const newBoardView = createMockView({ + view_id: 'new-board', + name: 'New Board', + layout: ViewLayout.Board, + extra: { is_space: false, database_id: 'db-tab-bar' }, + }); + + const containerWithMultipleViews = createMockView({ + view_id: 'container-with-tabs', + name: 'Database with Tabs', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-tab-bar', + }, + children: [originalGridView, newBoardView], + }); + + it('container holds multiple child views', () => { + expect(containerWithMultipleViews.children.length).toBe(2); + }); + + it('all children share the same database_id', () => { + expect(getDatabaseIdFromExtra(originalGridView)).toBe('db-tab-bar'); + expect(getDatabaseIdFromExtra(newBoardView)).toBe('db-tab-bar'); + }); + + it('getFirstChildView returns the first view (Grid)', () => { + const firstChild = getFirstChildView(containerWithMultipleViews); + expect(firstChild?.view_id).toBe('original-grid'); + expect(firstChild?.layout).toBe(ViewLayout.Grid); + }); + + it('child views are referenced database views', () => { + expect(isReferencedDatabaseView(originalGridView, containerWithMultipleViews)).toBe(true); + expect(isReferencedDatabaseView(newBoardView, containerWithMultipleViews)).toBe(true); + }); + }); + + describe('getDatabaseTabViewIds', () => { + it('filters embedded views from container tabs', () => { + const gridView = createMockView({ + view_id: 'grid-view', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-1' }, + }); + const embeddedBoardView = createMockView({ + view_id: 'board-view', + layout: ViewLayout.Board, + extra: { is_space: false, database_id: 'db-1', embedded: true }, + }); + const container = createMockView({ + view_id: 'container', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-1', + }, + children: [gridView, embeddedBoardView], + }); + + expect(isEmbeddedView(gridView)).toBe(false); + expect(isEmbeddedView(embeddedBoardView)).toBe(true); + expect(getDatabaseTabViewIds(gridView.view_id, container)).toEqual([gridView.view_id]); + }); + + it('shows only the embedded view when opening it from the sidebar', () => { + const gridView = createMockView({ + view_id: 'grid-view', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-1' }, + }); + const embeddedBoardView = createMockView({ + view_id: 'board-view', + layout: ViewLayout.Board, + extra: { is_space: false, database_id: 'db-1', embedded: true }, + }); + const container = createMockView({ + view_id: 'container', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-1', + }, + children: [gridView, embeddedBoardView], + }); + + expect(getDatabaseTabViewIds(embeddedBoardView.view_id, container)).toEqual([ + embeddedBoardView.view_id, + ]); + }); + + it('falls back to display tabs when opening a container directly', () => { + const gridView = createMockView({ + view_id: 'grid-view', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-1' }, + }); + const embeddedBoardView = createMockView({ + view_id: 'board-view', + layout: ViewLayout.Board, + extra: { is_space: false, database_id: 'db-1', embedded: true }, + }); + const container = createMockView({ + view_id: 'container', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-1', + }, + children: [gridView, embeddedBoardView], + }); + + expect(getDatabaseTabViewIds(container.view_id, container)).toEqual([gridView.view_id]); + }); + + it('keeps all tabs when a database only has embedded views', () => { + const embeddedGridView = createMockView({ + view_id: 'embedded-grid', + layout: ViewLayout.Grid, + extra: { is_space: false, database_id: 'db-1', embedded: true }, + }); + const embeddedBoardView = createMockView({ + view_id: 'embedded-board', + layout: ViewLayout.Board, + extra: { is_space: false, database_id: 'db-1', embedded: true }, + }); + const container = createMockView({ + view_id: 'container', + layout: ViewLayout.Grid, + extra: { + is_space: false, + is_database_container: true, + database_id: 'db-1', + embedded: true, + }, + children: [embeddedGridView, embeddedBoardView], + }); + + expect(getDatabaseTabViewIds(embeddedGridView.view_id, container)).toEqual([ + embeddedGridView.view_id, + embeddedBoardView.view_id, + ]); + }); + }); + + /** + * Backward Compatibility: Views without container support + * + * Older databases without is_database_container should still work. + */ + describe('Backward Compatibility', () => { + const legacyGridView = createMockView({ + view_id: 'legacy-grid', + name: 'Legacy Grid', + layout: ViewLayout.Grid, + extra: { is_space: false }, + }); + + it('legacy view without is_database_container is not a container', () => { + expect(isDatabaseContainer(legacyGridView)).toBe(false); + }); + + it('legacy view without database_id returns undefined', () => { + expect(getDatabaseIdFromExtra(legacyGridView)).toBeUndefined(); + }); + + it('getFirstChildView returns undefined for non-container', () => { + expect(getFirstChildView(legacyGridView)).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/application/database-yjs/__tests__/useAddDatabaseView.test.tsx b/src/application/database-yjs/__tests__/useAddDatabaseView.test.tsx new file mode 100644 index 000000000..8cad5ef66 --- /dev/null +++ b/src/application/database-yjs/__tests__/useAddDatabaseView.test.tsx @@ -0,0 +1,225 @@ +import { act, renderHook } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import * as Y from 'yjs'; + +import { DatabaseContext, DatabaseContextState, useAddDatabaseView } from '@/application/database-yjs'; +import { DatabaseViewLayout, View, ViewLayout, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; + +jest.mock('@/utils/runtime-config', () => ({ + getConfigValue: (_key: string, fallback: string) => fallback, +})); + +function createDatabaseDoc(databaseId: string): YDoc { + const doc = new Y.Doc() as unknown as YDoc; + const sharedRoot = doc.getMap(YjsEditorKey.data_section); + const database = new Y.Map(); + + database.set(YjsDatabaseKey.id, databaseId); + sharedRoot.set(YjsEditorKey.database, database); + return doc; +} + +function createView(overrides: Partial): View { + return { + view_id: 'view-id', + name: 'View', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + ...overrides, + }; +} + +describe('useAddDatabaseView', () => { + it('creates linked view under database container when parent is container', async () => { + const databaseId = 'db-1'; + const baseViewId = 'base-view-id'; + const activeViewId = 'active-view-id'; + const containerId = 'container-view-id'; + + const createDatabaseView = jest.fn().mockResolvedValue({ + view_id: 'new-view-id', + database_id: databaseId, + }); + + const loadViewMeta = jest.fn(async (viewId: string) => { + if (viewId === activeViewId) { + return createView({ + view_id: activeViewId, + layout: ViewLayout.Board, + parent_view_id: containerId, + }); + } + + if (viewId === containerId) { + return createView({ + view_id: containerId, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [ + createView({ view_id: baseViewId, layout: ViewLayout.Grid, parent_view_id: containerId }), + ], + }); + } + + return null; + }); + + const contextValue: DatabaseContextState = { + readOnly: false, + databaseDoc: createDatabaseDoc(databaseId), + databasePageId: baseViewId, + activeViewId, + rowDocMap: {}, + workspaceId: 'workspace-id', + createDatabaseView, + loadViewMeta, + isDocumentBlock: false, + }; + + const { result } = renderHook(() => useAddDatabaseView(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current(DatabaseViewLayout.Calendar, 'Calendar'); + }); + + expect(createDatabaseView).toHaveBeenCalledWith( + activeViewId, + expect.objectContaining({ + parent_view_id: containerId, + database_id: databaseId, + layout: ViewLayout.Calendar, + name: 'Calendar', + embedded: false, + }) + ); + }); + + it('creates embedded linked view under document when no container exists', async () => { + const databaseId = 'db-1'; + const baseViewId = 'linked-view-id'; + const documentId = 'document-id'; + + const createDatabaseView = jest.fn().mockResolvedValue({ + view_id: 'new-view-id', + database_id: databaseId, + }); + + const loadViewMeta = jest.fn(async (viewId: string) => { + if (viewId === baseViewId) { + return createView({ + view_id: baseViewId, + layout: ViewLayout.Grid, + parent_view_id: documentId, + extra: { is_space: false }, + }); + } + + if (viewId === documentId) { + return createView({ + view_id: documentId, + layout: ViewLayout.Document, + }); + } + + return null; + }); + + const contextValue: DatabaseContextState = { + readOnly: false, + databaseDoc: createDatabaseDoc(databaseId), + databasePageId: baseViewId, + activeViewId: baseViewId, + rowDocMap: {}, + workspaceId: 'workspace-id', + createDatabaseView, + loadViewMeta, + isDocumentBlock: true, + }; + + const { result } = renderHook(() => useAddDatabaseView(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current(DatabaseViewLayout.Board, 'Board'); + }); + + expect(createDatabaseView).toHaveBeenCalledWith( + baseViewId, + expect.objectContaining({ + parent_view_id: documentId, + database_id: databaseId, + layout: ViewLayout.Board, + name: 'Board', + embedded: true, + }) + ); + }); + + it('falls back to creating under the current database view for legacy standalone databases', async () => { + const databaseId = 'db-1'; + const baseViewId = 'legacy-db-view-id'; + const parentId = 'root-id'; + + const createDatabaseView = jest.fn().mockResolvedValue({ + view_id: 'new-view-id', + database_id: databaseId, + }); + + const loadViewMeta = jest.fn(async (viewId: string) => { + if (viewId === baseViewId) { + return createView({ + view_id: baseViewId, + layout: ViewLayout.Grid, + parent_view_id: parentId, + }); + } + + if (viewId === parentId) { + return createView({ + view_id: parentId, + layout: ViewLayout.Document, + }); + } + + return null; + }); + + const contextValue: DatabaseContextState = { + readOnly: false, + databaseDoc: createDatabaseDoc(databaseId), + databasePageId: baseViewId, + activeViewId: baseViewId, + rowDocMap: {}, + workspaceId: 'workspace-id', + createDatabaseView, + loadViewMeta, + isDocumentBlock: false, + }; + + const { result } = renderHook(() => useAddDatabaseView(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current(DatabaseViewLayout.Grid, 'Grid'); + }); + + expect(createDatabaseView).toHaveBeenCalledWith( + baseViewId, + expect.objectContaining({ + parent_view_id: baseViewId, + database_id: databaseId, + layout: ViewLayout.Grid, + name: 'Grid', + embedded: false, + }) + ); + }); +}); diff --git a/src/application/database-yjs/__tests__/useDatabaseViewsSelector.test.tsx b/src/application/database-yjs/__tests__/useDatabaseViewsSelector.test.tsx new file mode 100644 index 000000000..114ec9c79 --- /dev/null +++ b/src/application/database-yjs/__tests__/useDatabaseViewsSelector.test.tsx @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import * as Y from 'yjs'; + +import { DatabaseContext, DatabaseContextState } from '@/application/database-yjs'; +import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; +import { useDatabaseViewsSelector } from '@/application/database-yjs/selector'; + +jest.mock('@/utils/runtime-config', () => ({ + getConfigValue: (_key: string, fallback: string) => fallback, +})); + +function createDatabaseDocWithViews(viewIdsInInsertionOrder: string[]): YDoc { + const doc = new Y.Doc() as unknown as YDoc; + const sharedRoot = doc.getMap(YjsEditorKey.data_section); + const database = new Y.Map(); + const views = new Y.Map(); + + viewIdsInInsertionOrder.forEach((viewId) => { + const view = new Y.Map(); + + view.set('created_at', new Date().toISOString()); + views.set(viewId, view); + }); + + database.set(YjsDatabaseKey.views, views); + sharedRoot.set(YjsEditorKey.database, database); + return doc; +} + +describe('useDatabaseViewsSelector', () => { + it('preserves visibleViewIds ordering (folder/outline order)', () => { + const gridId = 'grid-id'; + const boardId = 'board-id'; + const calendarId = 'calendar-id'; + const visibleViewIds = [gridId, boardId, calendarId]; + + // Simulate an underlying Yjs insertion order that differs from the folder/outline order. + const databaseDoc = createDatabaseDocWithViews([boardId, gridId, calendarId]); + + const contextValue: DatabaseContextState = { + readOnly: true, + databaseDoc, + databasePageId: gridId, + activeViewId: gridId, + rowDocMap: null, + workspaceId: 'workspace-id', + }; + + const { result } = renderHook( + () => useDatabaseViewsSelector(gridId, visibleViewIds), + { + wrapper: ({ children }) => ( + + {children} + + ), + } + ); + + expect(result.current.viewIds).toEqual([gridId, boardId, calendarId]); + }); +}); diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 7fb0b978f..3c382b135 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -47,6 +47,7 @@ import { RowId, TimeFormat, UpdatePagePayload, + View, ViewLayout, YDatabase, YDatabaseBoardLayoutSetting, @@ -76,6 +77,7 @@ import { } from '@/application/types'; import { DefaultTimeSetting } from '@/application/user-metadata'; import { applyYDoc } from '@/application/ydoc/apply'; +import { isDatabaseContainer } from '@/application/view-utils'; export function useResizeColumnWidthDispatch() { const database = useDatabase(); @@ -2255,7 +2257,8 @@ function useEnhanceCalendarLayoutByFieldExists() { */ export function useAddDatabaseView() { // databasePageId: The main database page in folder (used as parent for new views) - const { databasePageId, createDatabaseView, databaseDoc } = useDatabaseContext(); + const { databasePageId, activeViewId, createDatabaseView, databaseDoc, loadViewMeta, isDocumentBlock } = + useDatabaseContext(); const sharedRoot = useSharedRoot(); const database = useMemo(() => { @@ -2269,7 +2272,7 @@ export function useAddDatabaseView() { }, [database]); return useCallback( - async (layout: DatabaseViewLayout) => { + async (layout: DatabaseViewLayout, nameOverride?: string) => { if (!createDatabaseView) { throw new Error('createDatabaseView not found'); } @@ -2278,6 +2281,8 @@ export function useAddDatabaseView() { throw new Error('databasePageId not found'); } + const requestViewId = activeViewId || databasePageId; + if (!databaseId) { throw new Error('databaseId not found'); } @@ -2293,12 +2298,56 @@ export function useAddDatabaseView() { [DatabaseViewLayout.Calendar]: 'Calendar', }[layout]; - // Create new view as a child of the main database page - const response = await createDatabaseView(databasePageId, { - parent_view_id: databasePageId, + const tabsParentViewId = await (async (): Promise => { + // Best-effort: fall back to previous behavior if meta lookup isn't available. + if (!loadViewMeta) { + return databasePageId; + } + + const safeLoadViewMeta = async (viewId: string): Promise => { + try { + return await loadViewMeta(viewId); + } catch { + return null; + } + }; + + const currentMeta = await safeLoadViewMeta(requestViewId); + + // If the current view itself is a container, attach under it. + if (currentMeta && isDatabaseContainer(currentMeta)) { + return currentMeta.view_id; + } + + const parentId = currentMeta?.parent_view_id; + + if (!parentId) { + return databasePageId; + } + + // If parent is a database container, attach under the container (Scenario 4). + const parentMeta = await safeLoadViewMeta(parentId); + + if (isDatabaseContainer(parentMeta)) { + return parentId; + } + + // Embedded databases without a container attach under the document (Scenario 3). + if (isDocumentBlock) { + return parentId; + } + + // Backward-compatible fallback: attach under the current database view. + return databasePageId; + })(); + + // Create new view as a child of the database container (or document for embedded linked views). + const response = await createDatabaseView(requestViewId, { + parent_view_id: tabsParentViewId, database_id: databaseId, layout: viewLayout, - name, + name: nameOverride ?? name, + embedded: isDocumentBlock ?? false, }); if (response?.database_update?.length) { @@ -2307,7 +2356,7 @@ export function useAddDatabaseView() { return response.view_id; }, - [createDatabaseView, databaseDoc, databasePageId, databaseId] + [createDatabaseView, databaseDoc, databasePageId, databaseId, activeViewId, loadViewMeta, isDocumentBlock] ); } diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 72244bccc..f1e89e25a 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -94,7 +94,11 @@ export function useDatabaseViewsSelector(databasePageId: string, visibleViewIds? // If visibleViewIds is provided (for embedded databases), filter to only show those views // visibleViewIds is undefined for standalone databases, [] for embedded with no child views yet if (visibleViewIds !== undefined) { - allViewIds = allViewIds.filter((viewId) => visibleViewIds.includes(viewId)); + // Preserve the ordering defined by `visibleViewIds` (folder/outline order), not the + // internal insertion order of the Yjs `views` map. + const availableIds = new Set(allViewIds); + + allViewIds = visibleViewIds.filter((viewId) => availableIds.has(viewId)); } setViewIds(allViewIds); diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 60316fa8b..7d07c1482 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; import { nanoid } from 'nanoid'; @@ -198,22 +198,40 @@ async function executeAPIVoidRequest( const response = await request(); - if (!response?.data) { - console.error('[executeAPIVoidRequest] No response data received', response); + if (!response) { return Promise.reject({ code: -1, - message: 'No response data received', + message: 'No response received from server', }); } - if (response.data.code === 0) { + // Many "void" endpoints return 204 or a 2xx with an empty body. Treat any 2xx as success + // unless the standard APIResponse envelope is present and indicates an error. + if (response.status >= 200 && response.status < 300) { + const responseData: unknown = response.data; + + if ( + responseData && + typeof responseData === 'object' && + 'code' in responseData && + typeof (responseData as { code?: unknown }).code === 'number' + ) { + const data = responseData as APIResponse; + + if (data.code === 0) return; + + return Promise.reject({ + code: data.code, + message: data.message || 'Request failed', + }); + } + return; } - // Server returned an error response return Promise.reject({ - code: response.data.code, - message: response.data.message || 'Request failed', + code: response.status, + message: response.statusText || 'Request failed', }); } catch (error) { return Promise.reject(handleAPIError(error)); @@ -278,8 +296,9 @@ export function initAPIService(config: AFCloudConfig) { } ); - const handleUnauthorized = async (response: AxiosResponse) => { - const status = response.status; + const handleUnauthorized = async (error: unknown) => { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; if (status === 401) { const token = getTokenParsed(); @@ -287,7 +306,7 @@ export function initAPIService(config: AFCloudConfig) { if (!token) { console.warn('[initAPIService][response] 401 without token, emitting invalid token'); invalidToken(); - return response; + return Promise.reject(error); } const refresh_token = token.refresh_token; @@ -297,13 +316,13 @@ export function initAPIService(config: AFCloudConfig) { } catch (e) { console.warn('[initAPIService][response] refresh on 401 failed, emitting invalid token', { message: (e as Error)?.message, - url: response.config?.url, + url: axiosError.config?.url, }); invalidToken(); } } - return response; + return Promise.reject(error); }; axiosInstance.interceptors.response.use((response) => response, handleUnauthorized); diff --git a/src/application/types.ts b/src/application/types.ts index 3b4b2a14e..f2b7f4b56 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -865,7 +865,7 @@ export enum AuthProvider { MAGIC_LINK = 'magic_link', SAML = 'saml', PHONE = 'phone', - EMAIL = 'email' + EMAIL = 'email', } export interface AuthProvidersResponse { @@ -923,6 +923,15 @@ export interface ViewExtra { value: string; }; is_hidden_space?: boolean; + + // Whether this view is embedded inside a document (e.g. a linked database view). + // This is aligned with Desktop/Flutter and server-side `EXTRA_KEY_EMBEDDED`. + embedded?: boolean; + + // Database container support (aligned with Desktop/Flutter) + // Reference: AppFlowy-Premium/frontend/doc/context/database_container_behavior.md + is_database_container?: boolean; // True if this view is a database container + database_id?: string; // The underlying database ID } export interface View { @@ -944,7 +953,6 @@ export interface View { publish_timestamp?: string; parent_view_id?: string; access_level?: AccessLevel; - } export interface UpdatePublishConfigPayload { @@ -1262,7 +1270,7 @@ export interface DatabasePromptRow { export enum MentionPersonRole { Member = 1, Guest = 2, - Contact = 3 + Contact = 3, } export interface MentionablePerson { avatar_url: string | null; diff --git a/src/application/view-utils.ts b/src/application/view-utils.ts new file mode 100644 index 000000000..0d23c64a7 --- /dev/null +++ b/src/application/view-utils.ts @@ -0,0 +1,140 @@ +/** + * View utility functions for database container support. + * + * These utilities mirror the Desktop/Flutter implementation in view_ext.dart + * to ensure consistent behavior across platforms. + * + * Database Container behavior reference: + * - AppFlowy-Premium/frontend/doc/context/database_container_behavior.md + * - Scenario 1: Sidebar create → creates container with child view + * - Scenario 2: New DB in doc → creates container, returns embedded child view + * - Scenario 3: Link existing DB → NO container, embedded=true + * - Scenario 4: Tab bar add view → NO container, adds to existing container + */ + +import { View, ViewLayout } from './types'; + +/** + * Check if a layout is a database layout (Grid, Board, or Calendar) + */ +export function isDatabaseLayout(layout: ViewLayout): boolean { + return ( + layout === ViewLayout.Grid || + layout === ViewLayout.Board || + layout === ViewLayout.Calendar + ); +} + +/** + * Check if a view is marked as embedded in its extra. + * + * Embedded views are created inside documents (e.g. database blocks) and should not + * appear as tabs in the "source" database container page. + */ +export function isEmbeddedView(view: View | null | undefined): boolean { + return view?.extra?.embedded === true; +} + +/** + * Check if view is a database container. + * + * Container views hold database views as children and appear in the sidebar. + * When opening a container, the app should auto-select the first child view. + * + * @param view The view to check + * @returns true if this view is a database container + */ +export function isDatabaseContainer(view: View | null | undefined): boolean { + return view?.extra?.is_database_container === true; +} + +/** + * Get the database_id from a view's extra field. + * + * The database_id is stored in the extra field for both: + * - Database containers (pointing to the underlying database) + * - Database views (pointing to the database they belong to) + * + * @param view The view to get database_id from + * @returns The database_id or undefined if not found + */ +export function getDatabaseIdFromExtra(view: View | null | undefined): string | undefined { + return view?.extra?.database_id; +} + +/** + * Check if a view is a referenced database view (child of another database view). + * + * Referenced database views show a dot icon instead of normal expand/collapse. + * This is used for linked database views that share the same database. + * This mirrors the Flutter implementation: any database view whose parent is + * also a database layout is treated as "referenced" for sidebar rendering. + * + * @param view The view to check + * @param parentView The parent view (optional) + * @returns true if this is a referenced database view + */ +export function isReferencedDatabaseView( + view: View | null | undefined, + parentView: View | null | undefined +): boolean { + if (!parentView || !view) { + return false; + } + + return isDatabaseLayout(view.layout) && isDatabaseLayout(parentView.layout); +} + +/** + * Get the first child view of a container for auto-selection. + * + * When a user clicks on a database container, the app should automatically + * open the first child view (typically a Grid, Board, or Calendar). + * + * @param view The container view + * @returns The first child view or undefined if none exists + */ +export function getFirstChildView(view: View | null | undefined): View | undefined { + if (isDatabaseContainer(view) && view?.children && view.children.length > 0) { + return view.children[0]; + } + + return undefined; +} + +/** + * Returns the list of database view IDs that should be displayed in the tab bar. + * + * Mirrors Desktop/Flutter behavior: + * - Database containers can have both non-embedded "display views" and embedded views. + * - Embedded views should not appear as tabs when viewing the source database container. + * - When navigating directly to an embedded child view from the sidebar, show only that view. + */ +export function getDatabaseTabViewIds(currentViewId: string, containerView: View): string[] { + const children = containerView.children ?? []; + const childViewIds = children.map((child) => child.view_id); + + if (childViewIds.length === 0) { + return [currentViewId]; + } + + const nonEmbeddedChildIds = children + .filter((child) => !isEmbeddedView(child)) + .map((child) => child.view_id); + + const displayViewIds = nonEmbeddedChildIds.length > 0 ? nonEmbeddedChildIds : childViewIds; + + // If the current view is one of the display views, show the full display list. + if (displayViewIds.includes(currentViewId)) { + return displayViewIds; + } + + // If the current view is a child but not a display view, treat it as an embedded + // view opened as a standalone page and only show itself as a single tab. + if (childViewIds.includes(currentViewId)) { + return [currentViewId]; + } + + // Otherwise, treat it as opening the container (or a stale route param). + return displayViewIds; +} diff --git a/src/components/app/DatabaseView.tsx b/src/components/app/DatabaseView.tsx index d36dacaaf..1a275ff37 100644 --- a/src/components/app/DatabaseView.tsx +++ b/src/components/app/DatabaseView.tsx @@ -2,6 +2,7 @@ import { Suspense, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ViewComponentProps, ViewLayout, YDatabase, YjsEditorKey } from '@/application/types'; +import { getDatabaseTabViewIds, isDatabaseContainer } from '@/application/view-utils'; import { findView } from '@/components/_shared/outline/utils'; import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; import CalendarSkeleton from '@/components/_shared/skeleton/CalendarSkeleton'; @@ -9,6 +10,7 @@ import DocumentSkeleton from '@/components/_shared/skeleton/DocumentSkeleton'; import GridSkeleton from '@/components/_shared/skeleton/GridSkeleton'; import KanbanSkeleton from '@/components/_shared/skeleton/KanbanSkeleton'; import { useAppOutline } from '@/components/app/app.hooks'; +import { DATABASE_TAB_VIEW_ID_QUERY_PARAM } from '@/components/app/hooks/resolveSidebarSelectedViewId'; import { Database } from '@/components/database'; import ViewMetaPreview from 'src/components/view-meta/ViewMetaPreview'; @@ -22,30 +24,71 @@ function DatabaseView(props: ViewComponentProps) { * The database's page ID in the folder/outline structure. * This is the main entry point for the database and remains constant. */ - const databasePageId = viewMeta.viewId; + const databasePageId = viewMeta.viewId || ''; const view = useMemo(() => { if (!outline || !databasePageId) return; return findView(outline || [], databasePageId); }, [outline, databasePageId]); + const containerView = useMemo(() => { + if (!outline || !view) return; + + if (isDatabaseContainer(view)) { + return view; + } + + const parentId = view.parent_view_id; + + if (!parentId) { + return; + } + + const parent = findView(outline || [], parentId); + + return isDatabaseContainer(parent) ? parent : undefined; + }, [outline, view]); + + // Use container view (if present) as the "page meta" view for naming/icon operations. + const pageView = containerView || view; + const visibleViewIds = useMemo(() => { + if (containerView) { + return getDatabaseTabViewIds(databasePageId, containerView); + } + if (!view) return []; return [view.view_id, ...(view.children?.map((v) => v.view_id) || [])]; - }, [view]); + }, [containerView, view, databasePageId]); + + const pageMeta = useMemo(() => { + if (!pageView) { + return viewMeta; + } + + return { + ...viewMeta, + viewId: pageView.view_id, + name: pageView.name, + icon: pageView.icon || undefined, + extra: pageView.extra, + cover: pageView.extra?.cover, + layout: pageView.layout, + }; + }, [pageView, viewMeta]); /** * The currently active/selected view tab ID (Grid, Board, or Calendar). * Comes from URL param 'v', defaults to databasePageId when not specified. */ const activeViewId = useMemo(() => { - return search.get('v') || databasePageId; + return search.get(DATABASE_TAB_VIEW_ID_QUERY_PARAM) || databasePageId; }, [search, databasePageId]); const handleChangeView = useCallback( (viewId: string) => { setSearch((prev) => { - prev.set('v', viewId); + prev.set(DATABASE_TAB_VIEW_ID_QUERY_PARAM, viewId); return prev; }); }, @@ -94,7 +137,7 @@ function DatabaseView(props: ViewComponentProps) { > {rowId ? null : ( { + if (!viewId) return viewId; + const meta = findView(outline || [], viewId); + const firstChild = getFirstChildView(meta); + + return firstChild?.view_id ?? viewId; + }, [outline, viewId]); + const loadPageDoc = useCallback( async (id: string) => { setNotFound(false); @@ -81,11 +92,10 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; ); useEffect(() => { - if (open && viewId) { - void loadPageDoc(viewId); + if (open && effectiveViewId) { + void loadPageDoc(effectiveViewId); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, viewId]); + }, [open, effectiveViewId, loadPageDoc]); const handleClose = useCallback(() => { setDoc(undefined); @@ -93,9 +103,9 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; }, [onClose]); const view = useMemo(() => { - if (!outline || !viewId) return; - return findView(outline, viewId); - }, [outline, viewId]); + if (!outline || !effectiveViewId) return; + return findView(outline, effectiveViewId); + }, [outline, effectiveViewId]); const viewMeta: ViewMetaProps | null = useMemo(() => { return view @@ -126,8 +136,8 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; const ref = useRef(null); const [movePageOpen, setMovePageOpen] = React.useState(false); const renderModalTitle = useCallback(() => { - if (!viewId) return null; - const space = findAncestors(outline || [], viewId)?.find((item) => item.extra?.is_space); + if (!effectiveViewId) return null; + const space = findAncestors(outline || [], effectiveViewId)?.find((item) => item.extra?.is_space); return (
{ - await toView(viewId); + await toView(effectiveViewId); handleClose(); }} > @@ -148,7 +158,7 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; {space && ref.current && ( { @@ -175,8 +185,8 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean;
- - + + {ref.current && ( )} @@ -195,15 +205,15 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean;
); - }, [handleClose, movePageOpen, outline, t, toView, viewId]); + }, [effectiveViewId, handleClose, movePageOpen, outline, t, toView]); const layout = view?.layout || ViewLayout.Document; // Check if view is in shareWithMe and determine readonly status const isReadOnly = useMemo(() => { - if (!viewId) return false; - return getViewReadOnlyStatus(viewId, outline); - }, [getViewReadOnlyStatus, viewId, outline]); + if (!effectiveViewId) return false; + return getViewReadOnlyStatus(effectiveViewId, outline); + }, [getViewReadOnlyStatus, effectiveViewId, outline]); const View = useMemo(() => { switch (layout) { @@ -267,7 +277,7 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; useEffect(() => { const handleShareViewsChanged = ({ emails, viewId: id }: { emails: string[]; viewId: string }) => { - if (id === viewId && emails.includes(currentUser?.email || '')) { + if (id === effectiveViewId && emails.includes(currentUser?.email || '')) { toast.success('Permission changed'); } }; @@ -281,7 +291,7 @@ function ViewModal({ viewId, open, onClose }: { viewId?: string; open: boolean; eventEmitter.off(APP_EVENTS.SHARE_VIEWS_CHANGED, handleShareViewsChanged); } }; - }, [eventEmitter, viewId, currentUser?.email]); + }, [eventEmitter, effectiveViewId, currentUser?.email]); return ( {renderModalTitle()} - {notFound ? :
{viewDom}
} + {notFound ? :
{viewDom}
}
); } diff --git a/src/components/app/app.hooks.tsx b/src/components/app/app.hooks.tsx index d8df7db41..1e115d200 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -1,7 +1,8 @@ import EventEmitter from 'events'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; import { Awareness } from 'y-protocols/awareness'; +import { useSearchParams } from 'react-router-dom'; import { AppendBreadcrumb, @@ -31,6 +32,10 @@ import { import LoadingDots from '@/components/_shared/LoadingDots'; import { findView } from '@/components/_shared/outline/utils'; +import { + DATABASE_TAB_VIEW_ID_QUERY_PARAM, + resolveSidebarSelectedViewId, +} from '@/components/app/hooks/resolveSidebarSelectedViewId'; import { AuthInternalContext } from './contexts/AuthInternalContext'; import { AppAuthLayer } from './layers/AppAuthLayer'; import { AppBusinessLayer } from './layers/AppBusinessLayer'; @@ -189,6 +194,31 @@ export function useAppViewId() { return context.viewId; } +/** + * Returns the view id that should be treated as "selected" in the sidebar. + * + * For database pages, the URL can encode the active database tab view id via the + * `v` query param while keeping the route view id stable (to avoid reloading the + * database doc on every tab switch). Desktop keeps the sidebar selection in sync + * with the active tab; this hook provides the equivalent behavior for Web. + */ +export function useSidebarSelectedViewId() { + const routeViewId = useAppViewId(); + const outline = useAppOutline(); + const [searchParams] = useSearchParams(); + const tabViewId = searchParams.get(DATABASE_TAB_VIEW_ID_QUERY_PARAM); + + return useMemo( + () => + resolveSidebarSelectedViewId({ + routeViewId, + tabViewId, + outline, + }), + [outline, routeViewId, tabViewId] + ); +} + export function useAppWordCount(viewId?: string | null) { const context = useContext(AppContext); diff --git a/src/components/app/favorite/Favorite.tsx b/src/components/app/favorite/Favorite.tsx index 54c904f9b..d32a2aded 100644 --- a/src/components/app/favorite/Favorite.tsx +++ b/src/components/app/favorite/Favorite.tsx @@ -11,7 +11,7 @@ import { ReactComponent as MoreIcon } from '@/assets/icons/more.svg'; import OutlineItem from '@/components/_shared/outline/OutlineItem'; import { Popover } from '@/components/_shared/popover'; import RecentListSkeleton from '@/components/_shared/skeleton/RecentListSkeleton'; -import { useAppFavorites, useAppHandlers, useAppViewId } from '@/components/app/app.hooks'; +import { useAppFavorites, useAppHandlers, useSidebarSelectedViewId } from '@/components/app/app.hooks'; const popoverOrigin: Partial = { transformOrigin: { @@ -34,7 +34,7 @@ enum FavoriteGroup { export function Favorite() { const { favoriteViews, loadFavoriteViews } = useAppFavorites(); const navigateToView = useAppHandlers().toView; - const viewId = useAppViewId(); + const viewId = useSidebarSelectedViewId(); const { t } = useTranslation(); const [isExpanded, setIsExpanded] = React.useState(() => { return localStorage.getItem('favorite_expanded') !== 'false'; diff --git a/src/components/app/hooks/__tests__/ViewItem.databaseContainer.test.tsx b/src/components/app/hooks/__tests__/ViewItem.databaseContainer.test.tsx new file mode 100644 index 000000000..4fee13e4a --- /dev/null +++ b/src/components/app/hooks/__tests__/ViewItem.databaseContainer.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { View, ViewLayout } from '@/application/types'; +import ViewItem from '@/components/app/outline/ViewItem'; + +declare global { + // eslint-disable-next-line no-var + var __selectedViewId: string | undefined; +} + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/components/app/app.hooks', () => ({ + useSidebarSelectedViewId: () => global.__selectedViewId, + useAppHandlers: () => ({ + updatePage: jest.fn(), + uploadFile: jest.fn(), + }), +})); + +jest.mock('@/components/_shared/cutsom-icon', () => ({ + CustomIconPopover: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('@/components/_shared/outline/OutlineIcon', () => () => null); +jest.mock('@/components/_shared/view-icon/PageIcon', () => () => null); + +describe('ViewItem database container', () => { + beforeEach(() => { + global.__selectedViewId = undefined; + }); + + it('clicking a container opens its first child', () => { + const childView: View = { + view_id: 'child-view-id', + name: 'Grid', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: 'container-view-id', + }; + + const containerView: View = { + view_id: 'container-view-id', + name: 'New database', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [childView], + is_published: false, + is_private: false, + }; + + const onClickView = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId(`page-${containerView.view_id}`)); + expect(onClickView).toHaveBeenCalledWith(childView.view_id); + }); + + it('marks a container selected when a child is the active view', () => { + const childView: View = { + view_id: 'child-view-id', + name: 'Grid', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: 'container-view-id', + }; + + const containerView: View = { + view_id: 'container-view-id', + name: 'New database', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [childView], + is_published: false, + is_private: false, + }; + + global.__selectedViewId = childView.view_id; + + render( + + ); + + const el = screen.getByTestId(`page-${containerView.view_id}`); + + expect(el.getAttribute('data-selected')).toBe('true'); + }); +}); diff --git a/src/components/app/hooks/__tests__/databaseTabSidebarSync.test.tsx b/src/components/app/hooks/__tests__/databaseTabSidebarSync.test.tsx new file mode 100644 index 000000000..cb57292bc --- /dev/null +++ b/src/components/app/hooks/__tests__/databaseTabSidebarSync.test.tsx @@ -0,0 +1,257 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import React, { useCallback, useMemo, useState } from 'react'; +import * as Y from 'yjs'; + +import { DatabaseViewLayout, View, ViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/types'; +import ViewItem from '@/components/app/outline/ViewItem'; +import { DatabaseViewTabs } from '@/components/database/components/tabs/DatabaseViewTabs'; + +jest.mock('@/utils/runtime-config', () => ({ + getConfigValue: (_key: string, fallback: string) => fallback, +})); + +declare global { + // eslint-disable-next-line no-var + var __routeViewId: string | undefined; + // eslint-disable-next-line no-var + var __tabViewId: string | null | undefined; + // eslint-disable-next-line no-var + var __outline: View[] | undefined; +} + +jest.mock('@/components/app/app.hooks', () => { + const { resolveSidebarSelectedViewId } = jest.requireActual( + '@/components/app/hooks/resolveSidebarSelectedViewId' + ); + + return { + useSidebarSelectedViewId: () => + resolveSidebarSelectedViewId({ + routeViewId: global.__routeViewId, + tabViewId: global.__tabViewId, + outline: global.__outline, + }), + useAppHandlers: () => ({ + updatePage: jest.fn(), + uploadFile: jest.fn(), + }), + }; +}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/components/_shared/cutsom-icon', () => ({ + CustomIconPopover: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('@/components/_shared/outline/OutlineIcon', () => () => null); +jest.mock('@/components/_shared/view-icon/PageIcon', () => () => null); + +jest.mock('@/components/_shared/scroller', () => ({ + AFScroller: React.forwardRef( + ({ children }: { children: React.ReactNode }, ref: React.ForwardedRef) => ( +
{children}
+ ) + ), +})); + +jest.mock('@/components/database/components/tabs/AddViewButton', () => ({ + AddViewButton: () => null, +})); + +beforeAll(() => { + class ResizeObserverMock { + observe() { + return undefined; + } + + unobserve() { + return undefined; + } + + disconnect() { + return undefined; + } + } + + // jsdom doesn't provide ResizeObserver; DatabaseViewTabs uses it for tab scrolling. + Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: ResizeObserverMock, + }); +}); + +function createDatabaseYView(name: string, layout: DatabaseViewLayout): YDatabaseView { + const view = new Y.Map() as unknown as YDatabaseView; + + view.set(YjsDatabaseKey.name, name); + view.set(YjsDatabaseKey.layout, layout); + return view; +} + +describe('Database tab ↔ sidebar selection sync', () => { + it('keeps tab order stable and syncs selection both ways', () => { + const containerId = 'container-id'; + const gridId = 'grid-id'; + const boardId = 'board-id'; + const calendarId = 'calendar-id'; + + const gridView: View = { + view_id: gridId, + name: 'Grid', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerId, + }; + + const boardView: View = { + view_id: boardId, + name: 'Board', + icon: null, + layout: ViewLayout.Board, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerId, + }; + + const calendarView: View = { + view_id: calendarId, + name: 'Calendar', + icon: null, + layout: ViewLayout.Calendar, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerId, + }; + + const containerView: View = { + view_id: containerId, + name: 'New database', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [gridView, boardView, calendarView], + is_published: false, + is_private: false, + }; + + const outline: View[] = [ + { + view_id: 'space-id', + name: 'Space', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: true }, + children: [containerView], + is_published: false, + is_private: false, + }, + ]; + + const yDoc = new Y.Doc(); + const yViews = yDoc.getMap('views'); + + yViews.set(gridId, createDatabaseYView('Grid', DatabaseViewLayout.Grid)); + yViews.set(boardId, createDatabaseYView('Board', DatabaseViewLayout.Board)); + yViews.set(calendarId, createDatabaseYView('Calendar', DatabaseViewLayout.Calendar)); + + expect(yViews.get(gridId)).toBeDefined(); + + function Harness() { + // In Web, the active database view tab is tracked via the `v` query param while the + // route view id stays stable. We model that state explicitly for this unit test. + const [routeViewId, setRouteViewId] = useState(gridId); + const [tabViewId, setTabViewId] = useState(null); + + global.__routeViewId = routeViewId; + global.__tabViewId = tabViewId; + global.__outline = outline; + + const activeViewId = tabViewId || routeViewId; + const viewIds = useMemo(() => [gridId, boardId, calendarId], []); + + const handleSidebarClick = useCallback( + (viewId: string) => { + setRouteViewId(viewId); + setTabViewId(null); + }, + [] + ); + + const handleTabClick = useCallback( + (viewId: string) => { + setTabViewId(viewId); + }, + [] + ); + + return ( + <> + + + + ); + } + + render(); + + // Tab order stays Grid → Board → Calendar regardless of selection source. + const tabs = screen.getAllByTestId(/view-tab-/); + + expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Grid', 'Board', 'Calendar']); + + // Initial state: route view is Grid; both tab and sidebar should select Grid. + expect(screen.getByTestId(`view-tab-${gridId}`).getAttribute('data-state')).toBe('active'); + expect(screen.getByTestId(`page-${gridId}`).getAttribute('data-selected')).toBe('true'); + + // Tab bar → sidebar: selecting Board tab updates sidebar selection. + fireEvent.mouseDown(screen.getByTestId(`view-tab-${boardId}`)); + expect(screen.getByTestId(`view-tab-${boardId}`).getAttribute('data-state')).toBe('active'); + expect(screen.getByTestId(`page-${boardId}`).getAttribute('data-selected')).toBe('true'); + + // Sidebar → tab bar: opening Board from the sidebar selects the Board tab (no `v` param). + fireEvent.click(screen.getByTestId(`page-${boardId}`)); + expect(screen.getByTestId(`view-tab-${boardId}`).getAttribute('data-state')).toBe('active'); + expect(screen.getByTestId(`page-${boardId}`).getAttribute('data-selected')).toBe('true'); + + // Sidebar → tab bar: opening Calendar from the sidebar selects the Calendar tab. + fireEvent.click(screen.getByTestId(`page-${calendarId}`)); + expect(screen.getByTestId(`view-tab-${calendarId}`).getAttribute('data-state')).toBe('active'); + expect(screen.getByTestId(`page-${calendarId}`).getAttribute('data-selected')).toBe('true'); + + // Tab bar → sidebar from a non-Grid route: selecting Grid tab updates sidebar selection. + fireEvent.mouseDown(screen.getByTestId(`view-tab-${gridId}`)); + expect(screen.getByTestId(`view-tab-${gridId}`).getAttribute('data-state')).toBe('active'); + expect(screen.getByTestId(`page-${gridId}`).getAttribute('data-selected')).toBe('true'); + }); +}); diff --git a/src/components/app/hooks/__tests__/resolveSidebarSelectedViewId.test.ts b/src/components/app/hooks/__tests__/resolveSidebarSelectedViewId.test.ts new file mode 100644 index 000000000..b2a8ce863 --- /dev/null +++ b/src/components/app/hooks/__tests__/resolveSidebarSelectedViewId.test.ts @@ -0,0 +1,124 @@ +import { View, ViewLayout } from '@/application/types'; +import { resolveSidebarSelectedViewId } from '@/components/app/hooks/resolveSidebarSelectedViewId'; + +describe('resolveSidebarSelectedViewId', () => { + const containerViewId = 'container-view-id'; + const gridViewId = 'grid-view-id'; + const boardViewId = 'board-view-id'; + const docViewId = 'doc-view-id'; + + const gridView: View = { + view_id: gridViewId, + name: 'Grid', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerViewId, + }; + + const boardView: View = { + view_id: boardViewId, + name: 'Board', + icon: null, + layout: ViewLayout.Board, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: containerViewId, + }; + + const containerView: View = { + view_id: containerViewId, + name: 'Database', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [gridView, boardView], + is_published: false, + is_private: false, + }; + + const documentView: View = { + view_id: docViewId, + name: 'Doc', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + }; + + const outline: View[] = [ + { + view_id: 'space', + name: 'Space', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: true }, + children: [containerView, documentView], + is_published: false, + is_private: false, + }, + ]; + + it('falls back to routeViewId when no tabViewId provided', () => { + expect(resolveSidebarSelectedViewId({ routeViewId: gridViewId, tabViewId: null, outline })).toBe(gridViewId); + }); + + it('falls back to routeViewId when tabViewId is unknown', () => { + expect(resolveSidebarSelectedViewId({ routeViewId: gridViewId, tabViewId: 'missing', outline })).toBe(gridViewId); + }); + + it('uses tabViewId when both views are in same database container', () => { + expect(resolveSidebarSelectedViewId({ routeViewId: gridViewId, tabViewId: boardViewId, outline })).toBe(boardViewId); + }); + + it('ignores tabViewId when route view is not a database view', () => { + expect(resolveSidebarSelectedViewId({ routeViewId: docViewId, tabViewId: boardViewId, outline })).toBe(docViewId); + }); + + it('ignores tabViewId when it points to a view in another database container', () => { + const otherContainerId = 'other-container'; + const otherBoardId = 'other-board'; + + const otherBoard: View = { + view_id: otherBoardId, + name: 'OtherBoard', + icon: null, + layout: ViewLayout.Board, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + parent_view_id: otherContainerId, + }; + + const otherContainer: View = { + view_id: otherContainerId, + name: 'OtherDB', + icon: null, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [otherBoard], + is_published: false, + is_private: false, + }; + + const outlineWithOther: View[] = [ + { + ...outline[0], + children: [...(outline[0].children || []), otherContainer], + }, + ]; + + expect(resolveSidebarSelectedViewId({ routeViewId: gridViewId, tabViewId: otherBoardId, outline: outlineWithOther })).toBe( + gridViewId + ); + }); +}); + diff --git a/src/components/app/hooks/__tests__/useViewOperations.toView.databaseContainer.test.tsx b/src/components/app/hooks/__tests__/useViewOperations.toView.databaseContainer.test.tsx new file mode 100644 index 000000000..9d9ad5737 --- /dev/null +++ b/src/components/app/hooks/__tests__/useViewOperations.toView.databaseContainer.test.tsx @@ -0,0 +1,99 @@ +import { act, renderHook } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import EventEmitter from 'events'; + +import { View, ViewLayout } from '@/application/types'; +import { AFService } from '@/application/services/services.type'; +import { AuthInternalContext, AuthInternalContextType } from '@/components/app/contexts/AuthInternalContext'; +import { SyncInternalContext, SyncInternalContextType } from '@/components/app/contexts/SyncInternalContext'; + +import { useViewOperations } from '../useViewOperations'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +function createView(overrides: Partial): View { + return { + view_id: 'view-id', + name: 'View', + icon: null, + layout: ViewLayout.Document, + extra: { is_space: false }, + children: [], + is_published: false, + is_private: false, + ...overrides, + }; +} + +describe('useViewOperations.toView database container', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to first child even when container children are missing from outline meta', async () => { + const workspaceId = 'workspace-id'; + const containerId = 'container-id'; + const firstChildId = 'first-child-id'; + + const service: Partial = { + getAppView: jest.fn(async (_workspaceId: string, viewId: string) => { + expect(_workspaceId).toBe(workspaceId); + expect(viewId).toBe(containerId); + + return createView({ + view_id: containerId, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [createView({ view_id: firstChildId, layout: ViewLayout.Grid, parent_view_id: containerId })], + }); + }), + }; + + const authContextValue: AuthInternalContextType = { + service: service as AFService, + currentWorkspaceId: workspaceId, + isAuthenticated: true, + onChangeWorkspace: () => Promise.resolve(), + }; + + const syncContextValue: SyncInternalContextType = { + registerSyncContext: () => ({ doc: {} as never }), + eventEmitter: new EventEmitter(), + awarenessMap: {}, + lastUpdatedCollab: null, + } as unknown as SyncInternalContextType; + + const loadViewMeta = jest.fn(async (viewId: string) => { + expect(viewId).toBe(containerId); + + // Simulate shallow outline/meta: container is known but children are missing. + return createView({ + view_id: containerId, + layout: ViewLayout.Grid, + extra: { is_space: false, is_database_container: true }, + children: [], + }); + }); + + const { result } = renderHook(() => useViewOperations(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await act(async () => { + await result.current.toView(containerId, undefined, false, loadViewMeta); + }); + + expect(loadViewMeta).toHaveBeenCalledWith(containerId); + expect(service.getAppView).toHaveBeenCalledWith(workspaceId, containerId); + expect(mockNavigate).toHaveBeenCalledWith(`/app/${workspaceId}/${firstChildId}`); + }); +}); diff --git a/src/components/app/hooks/resolveSidebarSelectedViewId.ts b/src/components/app/hooks/resolveSidebarSelectedViewId.ts new file mode 100644 index 000000000..cde1702a9 --- /dev/null +++ b/src/components/app/hooks/resolveSidebarSelectedViewId.ts @@ -0,0 +1,34 @@ +import { View } from '@/application/types'; +import { isDatabaseContainer, isDatabaseLayout } from '@/application/view-utils'; +import { findView } from '@/components/_shared/outline/utils'; + +export const DATABASE_TAB_VIEW_ID_QUERY_PARAM = 'v'; + +export function resolveSidebarSelectedViewId(params: { + routeViewId?: string; + tabViewId?: string | null; + outline?: View[]; +}): string | undefined { + const { routeViewId, tabViewId, outline } = params; + + if (!routeViewId) return undefined; + if (!tabViewId || tabViewId === routeViewId) return routeViewId; + if (!outline) return routeViewId; + + const routeView = findView(outline, routeViewId); + const tabView = findView(outline, tabViewId); + + if (!routeView || !tabView) return routeViewId; + + const routeIsDatabase = isDatabaseLayout(routeView.layout) || isDatabaseContainer(routeView); + const tabIsDatabase = isDatabaseLayout(tabView.layout); + + if (!routeIsDatabase || !tabIsDatabase) return routeViewId; + + const containerId = isDatabaseContainer(routeView) ? routeView.view_id : routeView.parent_view_id; + + if (!containerId) return routeViewId; + + return tabView.parent_view_id === containerId || tabView.view_id === containerId ? tabViewId : routeViewId; +} + diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index 8a60ec8ed..444ec5f5b 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -4,7 +4,18 @@ import { Awareness } from 'y-protocols/awareness'; import { Log } from '@/utils/log'; import { openCollabDB } from '@/application/db'; -import { AccessLevel, DatabaseId, Types, View, ViewId, ViewLayout, YDoc, YjsEditorKey, YSharedRoot } from '@/application/types'; +import { + AccessLevel, + DatabaseId, + Types, + View, + ViewId, + ViewLayout, + YDoc, + YjsEditorKey, + YSharedRoot, +} from '@/application/types'; +import { getFirstChildView, isDatabaseContainer } from '@/application/view-utils'; import { findView, findViewInShareWithMe } from '@/components/_shared/outline/utils'; import { getPlatform } from '@/utils/platform'; @@ -143,35 +154,36 @@ export function useViewOperations() { return false; }, []); - const getViewIdFromDatabaseId = useCallback(async (databaseId: string) => { - if (!currentWorkspaceId) { - return null; - } + const getViewIdFromDatabaseId = useCallback( + async (databaseId: string) => { + if (!currentWorkspaceId) { + return null; + } - if (databaseIdViewIdMapRef.current.has(databaseId)) { - return databaseIdViewIdMapRef.current.get(databaseId) || null; - } + if (databaseIdViewIdMapRef.current.has(databaseId)) { + return databaseIdViewIdMapRef.current.get(databaseId) || null; + } - const workspaceDatabaseDoc = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId); + const workspaceDatabaseDoc = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId); - if (!workspaceDatabaseDoc) { - return null; - } + if (!workspaceDatabaseDoc) { + return null; + } - const sharedRoot = workspaceDatabaseDoc.getMap(YjsEditorKey.data_section); + const sharedRoot = workspaceDatabaseDoc.getMap(YjsEditorKey.data_section); - const databases = sharedRoot?.toJSON()?.databases; + const databases = sharedRoot?.toJSON()?.databases; - const database = databases?.find((db: { database_id: string; views: string[] }) => - db.database_id === databaseId - ); + const database = databases?.find((db: { database_id: string; views: string[] }) => db.database_id === databaseId); - if (database) { - databaseIdViewIdMapRef.current.set(databaseId, database.views[0]); - } + if (database) { + databaseIdViewIdMapRef.current.set(databaseId, database.views[0]); + } - return databaseIdViewIdMapRef.current.get(databaseId) || null; - }, [currentWorkspaceId]); + return databaseIdViewIdMapRef.current.get(databaseId) || null; + }, + [currentWorkspaceId] + ); // Load view document const loadView = useCallback( @@ -273,7 +285,6 @@ export function useViewOperations() { const { doc } = registerSyncContext({ doc: res, collabType }); return doc; - } catch (e) { return Promise.reject(e); } @@ -281,7 +292,6 @@ export function useViewOperations() { [service, currentWorkspaceId, getDatabaseId, registerSyncContext] // Add dependencies to prevent re-creation of functions ); - // Create row document const createRowDoc = useCallback( async (rowKey: string): Promise => { @@ -320,14 +330,53 @@ export function useViewOperations() { // Navigate to view const toView = useCallback( async (viewId: string, blockId?: string, keepSearch?: boolean, loadViewMeta?: (viewId: string) => Promise) => { - let url = `/app/${currentWorkspaceId}/${viewId}`; - const view = await loadViewMeta?.(viewId); + // Prefer outline/meta when available (fast), but fall back to server fetch for cases + // where the outline does not include container children (e.g. shallow outline fetch). + let view = await loadViewMeta?.(viewId); + + // If this is a database container, navigate to the first child view instead + // This matches Desktop/Flutter behavior where clicking a container opens its first child + let targetViewId = viewId; + let targetView = view; - console.log('view', view); + if (isDatabaseContainer(view)) { + let firstChild = getFirstChildView(view); + + // Fallback: fetch the container subtree from server to resolve first child. + if (!firstChild && currentWorkspaceId && service) { + try { + const remote = await service.getAppView(currentWorkspaceId, viewId); + + // Update local variable so blockId routing below uses the correct layout. + view = remote; + targetView = remote; + + if (isDatabaseContainer(remote)) { + firstChild = getFirstChildView(remote); + } + } catch (e) { + Log.warn('[toView] Failed to fetch container view from server', { + containerId: viewId, + error: e, + }); + } + } + + if (firstChild) { + Log.debug('[toView] Database container detected, navigating to first child', { + containerId: viewId, + firstChildId: firstChild.view_id, + }); + targetViewId = firstChild.view_id; + targetView = firstChild; + } + } + + let url = `/app/${currentWorkspaceId}/${targetViewId}`; const searchParams = new URLSearchParams(keepSearch ? window.location.search : undefined); - if (blockId && view) { - switch (view.layout) { + if (blockId && targetView) { + switch (targetView.layout) { case ViewLayout.Document: searchParams.set('blockId', blockId); break; @@ -345,9 +394,18 @@ export function useViewOperations() { url += `?${searchParams.toString()}`; } + // Avoid pushing duplicate history entries (also prevents loops when a container has no child). + if (typeof window !== 'undefined') { + const currentUrl = `${window.location.pathname}${window.location.search}`; + + if (currentUrl === url) { + return; + } + } + navigate(url); }, - [currentWorkspaceId, navigate] + [currentWorkspaceId, navigate, service] ); // Clean up created row documents when view changes @@ -375,4 +433,4 @@ export function useViewOperations() { getViewIdFromDatabaseId, getViewReadOnlyStatus, }; -} \ No newline at end of file +} diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx index 14ec29aee..cf880f4fb 100644 --- a/src/components/app/layers/AppBusinessLayer.tsx +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -19,6 +19,11 @@ interface AppBusinessLayerProps { children: React.ReactNode; } +const FOLDER_OUTLINE_REFRESH_DEBOUNCE_MS = 1000; +const SKIP_NEXT_FOLDER_OUTLINE_REFRESH_BUFFER_MS = 5000; +const SKIP_NEXT_FOLDER_OUTLINE_REFRESH_TTL_MS = + FOLDER_OUTLINE_REFRESH_DEBOUNCE_MS + SKIP_NEXT_FOLDER_OUTLINE_REFRESH_BUFFER_MS; + // Third layer: Business logic operations // Handles all business operations like outline management, page operations, database operations // Depends on workspace ID and sync context from previous layers @@ -31,6 +36,8 @@ export const AppBusinessLayer: React.FC = ({ children }) const [rendered, setRendered] = useState(false); const [openModalViewId, setOpenModalViewId] = useState(undefined); const wordCountRef = useRef>({}); + const skipNextFolderOutlineRefreshRef = useRef(false); + const skipNextFolderOutlineRefreshUntilRef = useRef(0); // Calculate view ID from params const viewId = useMemo(() => { @@ -63,7 +70,28 @@ export const AppBusinessLayer: React.FC = ({ children }) const { loadView, createRowDoc, toView, awarenessMap, getViewIdFromDatabaseId } = useViewOperations(); // Initialize page operations - const pageOperations = usePageOperations({ outline, loadOutline }); + const loadOutlineAfterLocalMutation = useCallback( + async (workspaceId: string, force?: boolean) => { + // Local mutations typically trigger a folder-collab update echo shortly after we already + // refetched the outline. Skip the next folder-collab-driven refresh once to avoid a + // second, visually noticeable "refresh" of database UI derived from the outline. + skipNextFolderOutlineRefreshRef.current = true; + skipNextFolderOutlineRefreshUntilRef.current = Date.now() + SKIP_NEXT_FOLDER_OUTLINE_REFRESH_TTL_MS; + + try { + return await loadOutline(workspaceId, force); + } catch (e) { + // If our local outline reload failed, allow the next folder refresh to proceed so + // we can still recover when the folder-collab update arrives. + skipNextFolderOutlineRefreshRef.current = false; + skipNextFolderOutlineRefreshUntilRef.current = 0; + throw e; + } + }, + [loadOutline] + ); + + const pageOperations = usePageOperations({ outline, loadOutline: loadOutlineAfterLocalMutation }); // Check if current view has been deleted const viewHasBeenDeleted = useMemo(() => { @@ -156,24 +184,39 @@ export const AppBusinessLayer: React.FC = ({ children }) const refreshOutline = useCallback(async () => { if (!currentWorkspaceId) return; await loadOutline(currentWorkspaceId, false); - console.log(`Refreshed outline for workspace ${currentWorkspaceId}`); }, [currentWorkspaceId, loadOutline]); // Debounced outline refresh for folder updates const debouncedRefreshOutline = useMemo( () => debounce(() => { + // Avoid an extra outline refetch right after a local mutation already requested one. + // This prevents a visible "refresh" of database UI state derived from the outline. + if ( + skipNextFolderOutlineRefreshRef.current && + Date.now() < skipNextFolderOutlineRefreshUntilRef.current + ) { + skipNextFolderOutlineRefreshRef.current = false; + return; + } + void refreshOutline(); - }, 1000), + }, FOLDER_OUTLINE_REFRESH_DEBOUNCE_MS), [refreshOutline] ); + useEffect(() => { + return () => { + debouncedRefreshOutline.cancel(); + }; + }, [debouncedRefreshOutline]); + // Refresh outline when a folder collab update is detected useEffect(() => { if (lastUpdatedCollab?.collabType === Types.Folder) { return debouncedRefreshOutline(); } - }, [lastUpdatedCollab, debouncedRefreshOutline]); + }, [debouncedRefreshOutline, lastUpdatedCollab]); // Load mentionable users on mount useEffect(() => { diff --git a/src/components/app/outline/ViewItem.tsx b/src/components/app/outline/ViewItem.tsx index 93951c0dd..00ee4d2e4 100644 --- a/src/components/app/outline/ViewItem.tsx +++ b/src/components/app/outline/ViewItem.tsx @@ -2,27 +2,17 @@ import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { View, ViewIconType, ViewLayout } from '@/application/types'; +import { View, ViewIconType } from '@/application/types'; +import { + getFirstChildView, + isDatabaseContainer, + isDatabaseLayout, + isReferencedDatabaseView as isRefDbView, +} from '@/application/view-utils'; import { CustomIconPopover } from '@/components/_shared/cutsom-icon'; import OutlineIcon from '@/components/_shared/outline/OutlineIcon'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; -import { useAppHandlers, useAppViewId } from '@/components/app/app.hooks'; - -// Check if a layout is a database view type -function isDatabaseLayout(layout: ViewLayout): boolean { - return layout === ViewLayout.Grid || - layout === ViewLayout.Board || - layout === ViewLayout.Calendar; -} - -// Check if this is a referenced database view (both view and parent are database views) -function isReferencedDatabaseView(view: View, parentLayout?: ViewLayout): boolean { - if (parentLayout === undefined) { - return false; - } - - return isDatabaseLayout(view.layout) && isDatabaseLayout(parentLayout); -} +import { useAppHandlers, useSidebarSelectedViewId } from '@/components/app/app.hooks'; function ViewItem({ view, @@ -32,7 +22,7 @@ function ViewItem({ expandIds, toggleExpand, onClickView, - parentLayout, + parentView, }: { view: View; width: number; @@ -41,12 +31,14 @@ function ViewItem({ expandIds: string[]; toggleExpand: (id: string, isExpand: boolean) => void; onClickView?: (viewId: string) => void; - parentLayout?: ViewLayout; + parentView?: View; }) { const { t } = useTranslation(); - const selectedViewId = useAppViewId(); + const selectedViewId = useSidebarSelectedViewId(); const viewId = view.view_id; - const selected = selectedViewId === viewId; + const selected = + selectedViewId === viewId || + (isDatabaseContainer(view) && Boolean(view.children?.some((child) => child.view_id === selectedViewId))); const { updatePage, uploadFile } = useAppHandlers(); const isExpanded = expandIds.includes(viewId); @@ -90,8 +82,10 @@ function ViewItem({ // Dot icon for referenced database views (like desktop) const getDotIcon = useCallback(() => { return ( - - + + + + ); }, []); @@ -108,7 +102,8 @@ function ViewItem({ if (!view) return null; // Determine which left icon to show - const isRefDatabaseView = isReferencedDatabaseView(view, parentLayout); + // Use the utility function which properly handles database containers + const isRefDatabaseView = isRefDbView(view, parentView); const hasChildren = Boolean(view.children?.length); // Calculate left padding based on icon presence @@ -131,6 +126,7 @@ function ViewItem({ return (
{ - onClickView?.(viewId); + const firstChild = getFirstChildView(view); + + onClickView?.(firstChild?.view_id ?? viewId); }} className={ 'my-[1px] flex min-h-[30px] w-full cursor-pointer select-none items-center gap-1 overflow-hidden rounded-[8px] px-0.5 py-0.5 text-sm hover:bg-fill-content-hover focus:outline-none' @@ -197,7 +195,7 @@ function ViewItem({ level, getIcon, getDotIcon, - parentLayout, + parentView, onUploadFile, handleRemoveIcon, t, @@ -210,8 +208,10 @@ function ViewItem({ const renderChildren = useMemo(() => { // Don't pass renderExtra (more button) to children when parent is a database layout + // or when parent is a database container const parentIsDatabaseLayout = isDatabaseLayout(view.layout); - const childRenderExtra = parentIsDatabaseLayout ? undefined : renderExtra; + const parentIsContainer = isDatabaseContainer(view); + const childRenderExtra = parentIsDatabaseLayout || parentIsContainer ? undefined : renderExtra; return (
))}
); - }, [toggleExpand, onClickView, isExpanded, expandIds, level, renderExtra, view?.children, view.layout, width]); + }, [toggleExpand, onClickView, isExpanded, expandIds, level, renderExtra, view, width]); return (
void; }[] = useMemo( @@ -45,35 +45,37 @@ function AddPageActions({ view }: { view: View }) { label: t('document.menuName'), icon: , onSelect: () => { - void handleAddPage(ViewLayout.Document); + void handleAddPage(ViewLayout.Document, t('menuAppHeader.defaultNewPageName')); }, }, { label: t('grid.menuName'), icon: , + testId: 'add-grid-button', onSelect: () => { - void handleAddPage(ViewLayout.Grid, 'New Grid'); + void handleAddPage(ViewLayout.Grid, t('document.plugins.database.newDatabase')); }, }, { label: t('board.menuName'), icon: , onSelect: () => { - void handleAddPage(ViewLayout.Board, 'New Board'); + void handleAddPage(ViewLayout.Board, t('document.plugins.database.newDatabase')); }, }, { label: t('calendar.menuName'), icon: , onSelect: () => { - void handleAddPage(ViewLayout.Calendar, 'New Calendar'); + void handleAddPage(ViewLayout.Calendar, t('document.plugins.database.newDatabase')); }, }, { label: t('chat.newChat'), icon: , + testId: 'add-ai-chat-button', onSelect: () => { - void handleAddPage(ViewLayout.AIChat); + void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName')); }, }, ], @@ -85,10 +87,7 @@ function AddPageActions({ view }: { view: View }) { {actions.map((action) => ( { action.onSelect(); diff --git a/src/components/chat/types/request.ts b/src/components/chat/types/request.ts index f5e3dde70..037cb4e96 100644 --- a/src/components/chat/types/request.ts +++ b/src/components/chat/types/request.ts @@ -128,6 +128,10 @@ export interface ViewExtra { type: CoverType; value: string; }; + + // Database container support (aligned with Desktop/Flutter) + is_database_container?: boolean; + database_id?: string; } export interface ViewIcon { diff --git a/src/components/database/components/tabs/AddViewButton.tsx b/src/components/database/components/tabs/AddViewButton.tsx index be8ee1c2a..86a49a9ee 100644 --- a/src/components/database/components/tabs/AddViewButton.tsx +++ b/src/components/database/components/tabs/AddViewButton.tsx @@ -24,10 +24,10 @@ export function AddViewButton({ onViewAdded }: AddViewButtonProps) { const onAddView = useAddDatabaseView(); const [addLoading, setAddLoading] = useState(false); - const handleAddView = async (layout: DatabaseViewLayout) => { + const handleAddView = async (layout: DatabaseViewLayout, name: string) => { setAddLoading(true); try { - const viewId = await onAddView(layout); + const viewId = await onAddView(layout, name); onViewAdded(viewId); } catch (e: unknown) { @@ -59,7 +59,7 @@ export function AddViewButton({ onViewAdded }: AddViewButtonProps) { > { - void handleAddView(DatabaseViewLayout.Grid); + void handleAddView(DatabaseViewLayout.Grid, t('grid.menuName')); }} > @@ -67,7 +67,7 @@ export function AddViewButton({ onViewAdded }: AddViewButtonProps) { { - void handleAddView(DatabaseViewLayout.Board); + void handleAddView(DatabaseViewLayout.Board, t('board.menuName')); }} > @@ -75,7 +75,7 @@ export function AddViewButton({ onViewAdded }: AddViewButtonProps) { { - void handleAddView(DatabaseViewLayout.Calendar); + void handleAddView(DatabaseViewLayout.Calendar, t('calendar.menuName')); }} > diff --git a/src/components/database/components/tabs/DatabaseTabItem.tsx b/src/components/database/components/tabs/DatabaseTabItem.tsx index 5f99181d9..e1e5b445f 100644 --- a/src/components/database/components/tabs/DatabaseTabItem.tsx +++ b/src/components/database/components/tabs/DatabaseTabItem.tsx @@ -1,183 +1,171 @@ import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - DatabaseViewLayout, - View, - ViewLayout, - YDatabaseView, - YjsDatabaseKey, -} from '@/application/types'; +import { DatabaseViewLayout, View, ViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/types'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; import { DatabaseViewActions } from '@/components/database/components/tabs/ViewActions'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { TabLabel, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; export interface DatabaseTabItemProps { - viewId: string; - view: YDatabaseView; - /** - * The database's page ID in the folder/outline structure. - * This is the main entry point for the database and remains constant. - */ - databasePageId: string; - menuViewId: string | null; - readOnly: boolean; - visibleViewIds: string[]; - onSetMenuViewId: (id: string | null) => void; - onOpenDeleteModal: (id: string) => void; - onOpenRenameModal: (id: string) => void; - setTabRef: (id: string, el: HTMLElement | null) => void; + viewId: string; + view: YDatabaseView; + /** + * The database's page ID in the folder/outline structure. + * This is the main entry point for the database and remains constant. + */ + databasePageId: string; + /** Optional name coming from outline/meta when Yjs name is empty. */ + nameOverride?: string; + menuViewId: string | null; + readOnly: boolean; + visibleViewIds: string[]; + onSetMenuViewId: (id: string | null) => void; + onOpenDeleteModal: (id: string) => void; + onOpenRenameModal: (id: string) => void; + setTabRef: (id: string, el: HTMLElement | null) => void; } export const DatabaseTabItem = memo( - ({ - viewId, - view, - databasePageId, - menuViewId, - readOnly, - visibleViewIds, - onSetMenuViewId, - onOpenDeleteModal, - onOpenRenameModal, - setTabRef, - }: DatabaseTabItemProps) => { - const { t } = useTranslation(); - const rawLayoutValue = view.get(YjsDatabaseKey.layout); - const databaseLayout = Number(rawLayoutValue) as DatabaseViewLayout; + ({ + viewId, + view, + databasePageId, + nameOverride, + menuViewId, + readOnly, + visibleViewIds, + onSetMenuViewId, + onOpenDeleteModal, + onOpenRenameModal, + setTabRef, + }: DatabaseTabItemProps) => { + const { t } = useTranslation(); + const rawLayoutValue = view.get(YjsDatabaseKey.layout); + const databaseLayout = Number(rawLayoutValue) as DatabaseViewLayout; - // Get the default name based on layout if no name is available - const getDefaultNameByLayout = () => { - switch (databaseLayout) { - case DatabaseViewLayout.Grid: - return 'Grid'; - case DatabaseViewLayout.Board: - return 'Board'; - case DatabaseViewLayout.Calendar: - return 'Calendar'; - default: - return t('untitled'); - } - }; + // Get the default name based on layout if no name is available + const getDefaultNameByLayout = () => { + switch (databaseLayout) { + case DatabaseViewLayout.Grid: + return 'Grid'; + case DatabaseViewLayout.Board: + return 'Board'; + case DatabaseViewLayout.Calendar: + return 'Calendar'; + default: + return t('untitled'); + } + }; - // Get name from YDatabaseView (real-time, always correct) - const name = view.get(YjsDatabaseKey.name) || getDefaultNameByLayout(); + const rawName = view.get(YjsDatabaseKey.name); + const defaultName = getDefaultNameByLayout(); + const yjsName = rawName?.trim(); + const override = nameOverride?.trim(); + const name = yjsName || override || defaultName; - // Compute the layout for PageIcon (icon is based on layout type) - const computedLayout = - databaseLayout === DatabaseViewLayout.Board - ? ViewLayout.Board - : databaseLayout === DatabaseViewLayout.Calendar - ? ViewLayout.Calendar - : ViewLayout.Grid; + // Compute the layout for PageIcon (icon is based on layout type) + const computedLayout = + databaseLayout === DatabaseViewLayout.Board + ? ViewLayout.Board + : databaseLayout === DatabaseViewLayout.Calendar + ? ViewLayout.Calendar + : ViewLayout.Grid; - // Build minimal View object from YDatabaseView for actions menu - // This avoids dependency on meta/folderView for display - const viewForActions: View = useMemo( - () => ({ - view_id: viewId, - name: name, - layout: computedLayout, - parent_view_id: databasePageId, - children: [], - icon: null, - extra: null, - is_published: false, - is_private: false, - }), - [viewId, name, computedLayout, databasePageId] - ); + // Build minimal View object from YDatabaseView for actions menu + // This avoids dependency on meta/folderView for display + const viewForActions: View = useMemo( + () => ({ + view_id: viewId, + name: name, + layout: computedLayout, + parent_view_id: databasePageId, + children: [], + icon: null, + extra: null, + is_published: false, + is_private: false, + }), + [viewId, name, computedLayout, databasePageId] + ); - return ( - { - setTabRef(viewId, el); - }} - > - { - // For left-click, let Radix UI tabs handle it via onValueChange - if (e.button === 0) { - return; - } + return ( + { + setTabRef(viewId, el); + }} + > + { + // For left-click, let Radix UI tabs handle it via onValueChange + if (e.button === 0) { + return; + } - // For right-click and other buttons, prevent default and handle menu - e.preventDefault(); - e.stopPropagation(); + // For right-click and other buttons, prevent default and handle menu + e.preventDefault(); + e.stopPropagation(); - if (readOnly) return; + if (readOnly) return; - if (viewId !== menuViewId) { - onSetMenuViewId(viewId); - } else { - onSetMenuViewId(null); - } - }} - className={'flex items-center gap-1.5 overflow-hidden'} - > - + if (viewId !== menuViewId) { + onSetMenuViewId(viewId); + } else { + onSetMenuViewId(null); + } + }} + className={'flex items-center gap-1.5 overflow-hidden'} + > + - - - { - e.preventDefault(); - }} - className={'flex-1 truncate'} - > - {name || t('grid.title.placeholder')} - - - - {name} - - - - { - if (!open) { - onSetMenuViewId(null); - } - }} - open={menuViewId === viewId} - > - -
- - e.preventDefault()} - > - {menuViewId === viewId && ( - 1} - view={viewForActions} - /> - )} - - - - ); - } + + + { + e.preventDefault(); + }} + className={'flex-1 truncate'} + > + {name || t('grid.title.placeholder')} + + + + {name} + + + + { + if (!open) { + onSetMenuViewId(null); + } + }} + open={menuViewId === viewId} + > + +
+ + e.preventDefault()}> + {menuViewId === viewId && ( + + )} + + + + ); + } ); DatabaseTabItem.displayName = 'DatabaseTabItem'; diff --git a/src/components/database/components/tabs/DatabaseTabs.tsx b/src/components/database/components/tabs/DatabaseTabs.tsx index 0211802f0..a92b9b7be 100644 --- a/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/src/components/database/components/tabs/DatabaseTabs.tsx @@ -3,7 +3,8 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import { APP_EVENTS } from '@/application/constants'; import { useDatabase, useDatabaseContext } from '@/application/database-yjs'; import { useUpdateDatabaseView } from '@/application/database-yjs/dispatch'; -import { View, YDatabaseView, YjsDatabaseKey } from '@/application/types'; +import { DatabaseViewLayout, View, ViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/types'; +import { isDatabaseContainer } from '@/application/view-utils'; import { findView } from '@/components/_shared/outline/utils'; import RenameModal from '@/components/app/view-actions/RenameModal'; import { DatabaseActions } from '@/components/database/components/conditions'; @@ -34,10 +35,13 @@ export interface DatabaseTabBarProps { } export const DatabaseTabs = forwardRef( - ({ viewIds, databasePageId, selectedViewId, setSelectedViewId, onViewAddedToDatabase, onViewIdsChanged }, ref) => { + ( + { viewIds, databasePageId, selectedViewId, setSelectedViewId, viewName: _viewName, onViewAddedToDatabase, onViewIdsChanged }, + ref + ) => { const views = useDatabase()?.get(YjsDatabaseKey.views); const context = useDatabaseContext(); - const { loadViewMeta, readOnly, showActions = true, eventEmitter } = context; + const { loadViewMeta, navigateToView, readOnly, showActions = true, eventEmitter } = context; const updatePage = useUpdateDatabaseView(); const [meta, setMeta] = useState(null); const scrollLeftPadding = context.paddingStart; @@ -49,26 +53,61 @@ export const DatabaseTabs = forwardRef( const [pendingScrollToViewId, setPendingScrollToViewId] = useState(null); const reloadView = useCallback(async () => { - if (loadViewMeta) { - try { - const meta = await loadViewMeta(databasePageId); - - setMeta(meta); - return meta; - } catch (e) { - console.error('[DatabaseTabs] Error loading meta:', e); - // do nothing + if (!loadViewMeta) return; + + try { + const current = await loadViewMeta(databasePageId); + + if (!current) return; + + // Prefer the database container meta when this view is inside a container. + if (isDatabaseContainer(current)) { + setMeta(current); + return current; + } + + const parentId = current.parent_view_id; + + if (parentId) { + const parent = await loadViewMeta(parentId); + + if (isDatabaseContainer(parent)) { + setMeta(parent); + return parent; + } } + + setMeta(current); + return current; + } catch (e) { + console.error('[DatabaseTabs] Error loading meta:', e); + // do nothing } }, [databasePageId, loadViewMeta]); useEffect(() => { const handleOutlineLoaded = (outline: View[]) => { - const view = findView(outline, databasePageId); + const current = findView(outline, databasePageId); - if (view) { - setMeta(view); + if (!current) return; + + if (isDatabaseContainer(current)) { + setMeta(current); + return; } + + const parentId = current.parent_view_id; + + if (parentId) { + const parent = findView(outline, parentId); + + if (isDatabaseContainer(parent)) { + setMeta(parent); + return; + } + } + + setMeta(current); }; if (eventEmitter) { @@ -83,9 +122,41 @@ export const DatabaseTabs = forwardRef( }, [databasePageId, eventEmitter, reloadView]); const renameView = useMemo(() => { - if (renameViewId === databasePageId) return meta; - return meta?.children.find((v) => v.view_id === renameViewId); - }, [databasePageId, meta, renameViewId]); + if (!renameViewId) return null; + + const fromMeta = meta?.view_id === renameViewId ? meta : meta?.children.find((v) => v.view_id === renameViewId); + + if (fromMeta) return fromMeta; + + // Fallback: build a minimal view from Yjs so rename still works even when meta + // doesn't include siblings (e.g., embedded linked views without a container). + const databaseView = views?.get(renameViewId) as YDatabaseView | null; + + if (!databaseView) return null; + + const rawLayoutValue = databaseView.get(YjsDatabaseKey.layout); + const databaseLayout = Number(rawLayoutValue) as DatabaseViewLayout; + const computedLayout = + databaseLayout === DatabaseViewLayout.Board + ? ViewLayout.Board + : databaseLayout === DatabaseViewLayout.Calendar + ? ViewLayout.Calendar + : ViewLayout.Grid; + + const name = databaseView.get(YjsDatabaseKey.name) || ''; + + return { + view_id: renameViewId, + name, + layout: computedLayout, + parent_view_id: meta?.view_id ?? databasePageId, + children: [], + icon: null, + extra: null, + is_published: false, + is_private: false, + } as View; + }, [databasePageId, meta, renameViewId, views]); const visibleViewIds = useMemo(() => { return viewIds.filter((viewId) => { @@ -95,6 +166,25 @@ export const DatabaseTabs = forwardRef( }); }, [viewIds, views]); + const viewNameById = useMemo(() => { + if (!meta) return undefined; + + // Prefer container children when available. + if (isDatabaseContainer(meta)) { + const mapping: Record = {}; + + for (const child of meta.children ?? []) { + mapping[child.view_id] = child.name; + } + + return mapping; + } + + return { + [meta.view_id]: meta.name, + }; + }, [meta]); + useEffect(() => { void reloadView(); }, [reloadView]); @@ -141,6 +231,7 @@ export const DatabaseTabs = forwardRef( selectedViewId={selectedViewId} setSelectedViewId={setSelectedViewId} databasePageId={databasePageId} + viewNameById={viewNameById} views={views} readOnly={!!readOnly} visibleViewIds={visibleViewIds} @@ -205,20 +296,36 @@ export const DatabaseTabs = forwardRef( setDeleteConfirmOpen(null); }} onDeleted={() => { - if (!meta) return; - - if (setSelectedViewId) { - setSelectedViewId(meta.view_id); - } - - void reloadView(); - // Update the block data with the view ID removed if (onViewIdsChanged && deleteConfirmOpen) { const newViewIds = viewIds.filter((id) => id !== deleteConfirmOpen); onViewIdsChanged(newViewIds); } + + if (!deleteConfirmOpen) return; + + const deletedViewId = deleteConfirmOpen; + const remainingViewIds = visibleViewIds.filter((id) => id !== deletedViewId); + const nextViewId = remainingViewIds[0] || null; + + // If the active tab was deleted, switch to the next available view. + if (setSelectedViewId && selectedViewId === deletedViewId && nextViewId) { + setSelectedViewId(nextViewId); + } + + // If the "page view" in the URL was deleted, navigate to a remaining child view. + // Otherwise the route can become a "Page Deleted" placeholder even though the database still has views. + if (navigateToView && deletedViewId === databasePageId) { + const safeTarget = (selectedViewId && selectedViewId !== deletedViewId ? selectedViewId : nextViewId) || null; + + if (safeTarget) { + void navigateToView(safeTarget); + return; + } + } + + void reloadView(); }} />
diff --git a/src/components/database/components/tabs/DatabaseViewTabs.tsx b/src/components/database/components/tabs/DatabaseViewTabs.tsx index dcee9cbce..c037b876b 100644 --- a/src/components/database/components/tabs/DatabaseViewTabs.tsx +++ b/src/components/database/components/tabs/DatabaseViewTabs.tsx @@ -21,6 +21,8 @@ export interface DatabaseViewTabsProps { * This is the main entry point for the database and remains constant. */ databasePageId: string; + /** Optional name overrides from outline/meta by view id. */ + viewNameById?: Record; views: Y.Map | undefined; readOnly: boolean; visibleViewIds: string[]; @@ -38,6 +40,7 @@ export function DatabaseViewTabs({ selectedViewId, setSelectedViewId, databasePageId, + viewNameById, views, readOnly, visibleViewIds, @@ -183,7 +186,7 @@ export function DatabaseViewTabs({ }} > { if (setSelectedViewId) { setSelectedViewId(viewId); @@ -205,6 +208,7 @@ export function DatabaseViewTabs({ viewId={viewId} view={view} databasePageId={databasePageId} + nameOverride={viewNameById?.[viewId]} menuViewId={menuViewId} readOnly={!!readOnly} visibleViewIds={visibleViewIds} diff --git a/src/components/database/components/tabs/DeleteViewConfirm.tsx b/src/components/database/components/tabs/DeleteViewConfirm.tsx index fb763a053..01b8260d5 100644 --- a/src/components/database/components/tabs/DeleteViewConfirm.tsx +++ b/src/components/database/components/tabs/DeleteViewConfirm.tsx @@ -53,6 +53,7 @@ export function DeleteViewConfirm ({ open, onClose, viewId, onDeleted }: {