diff --git a/playwright/e2e/navigation-load-order.spec.ts b/playwright/e2e/navigation-load-order.spec.ts new file mode 100644 index 0000000000..d9c8179d9f --- /dev/null +++ b/playwright/e2e/navigation-load-order.spec.ts @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { APIRequestContext } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { createRandomUser, ocsRequest, type TestUser } from '../support/api' +import { + createTable, + createTextLineColumn, + createView, + ensureNavigationOpen, + getNavigationNodeId, + loadTableListingLast, +} from '../support/commands' +import { login } from '../support/login' + +async function deleteTable(request: APIRequestContext, owner: TestUser, tableId: number) { + await ocsRequest(request, owner, { + method: 'DELETE', + url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, + }) +} + +async function shareWithUser( + request: APIRequestContext, + owner: TestUser, + nodeType: 'view', + nodeId: number, + receiver: TestUser, +) { + await ocsRequest(request, owner, { + method: 'POST', + // the api/1 routes are plain app routes, not OCS ones + url: '/index.php/apps/tables/api/1/shares', + data: { + nodeId, + nodeType, + receiver: receiver.userId, + receiverType: 'user', + permissionRead: true, + }, + }) +} + +test.describe('Navigation with a slow table listing', () => { + const cleanUpTasks: Array<() => Promise> = [] + + test.afterEach(async () => { + // keep the test server reusable, the tests run against a shared instance + // (the users stay, the fixture owns their lifecycle) + while (cleanUpTasks.length > 0) { + await cleanUpTasks.pop()?.() + } + }) + + test('shows a view shared with me', async ({ userPage: { page, user: owner }, request }) => { + const recipient = await createRandomUser(request) + const tableTitle = 'Table of ' + owner.userId + const viewTitle = 'Shared view' + + await page.goto('/index.php/apps/tables') + await createTable(page, tableTitle) + // the table actions, and with them the create view action, need a column to show up + await createTextLineColumn(page, 'What', '', '', true) + await createView(page, viewTitle) + // deleting the table takes its views and shares with it + const tableId = await getNavigationNodeId(page, 'table', tableTitle) + cleanUpTasks.push(() => deleteTable(request, owner, tableId)) + const viewId = await getNavigationNodeId(page, 'view', viewTitle) + await shareWithUser(request, owner, 'view', viewId, recipient) + + await page.context().clearCookies() + await login(page, recipient) + + // the shared views answer before the table listing, which used to drop them + const { answered: sharedViewsAnswered } = await loadTableListingLast(page, '/apps/tables/view') + await page.goto('/index.php/apps/tables') + await sharedViewsAnswered + await ensureNavigationOpen(page) + + await expect( + page.locator(`[data-cy="navigationViewItem"] a[title="${viewTitle}"]`), + ).toBeVisible() + }) + +}) diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index ab4351d81f..f9e5f6e078 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -136,6 +136,64 @@ function getTableActionLocator(page: Page, optionName: string) { } } +/** + * Reads the id of a navigation entry from the link it points to. + * + * @param page the page showing the navigation + * @param nodeType the kind of entry to look up + * @param title the title of the entry + * @return the id of the table or view + */ +export async function getNavigationNodeId(page: Page, nodeType: 'table' | 'view', title: string) { + await ensureNavigationOpen(page) + + const entry = page + .locator(`[data-cy="navigation${nodeType === 'table' ? 'Table' : 'View'}Item"] a[title="${title}"]`) + .first() + + await entry.waitFor({ state: 'visible', timeout: ACTION_TIMEOUT }) + const id = (await entry.getAttribute('href'))?.match(new RegExp(`/${nodeType}/(\\d+)`))?.[1] + expect(id, `Could not read the id of ${nodeType} "${title}" from the navigation`).toBeTruthy() + + return Number(id) +} + +/** + * Holds the table listing back until the given request has been answered. + * + * The navigation loads tables, views shared with me and applications in parallel. + * Use this to pin down the order in which the table listing is applied to the store. + * Await the returned `answered` to make sure the awaited request really happened, + * otherwise the listing is let through after the timeout and nothing is pinned down. + * + * @param page the page to intercept the requests of + * @param until url fragment of the request that has to answer first + * @param timeout how long to wait for that request at most + * @return `answered` resolves once the awaited request has been answered + */ +export async function loadTableListingLast(page: Page, until: string, timeout = ACTION_TIMEOUT) { + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const expired = new Promise((resolve) => { + setTimeout(resolve, timeout) + }) + + page.on('response', (response) => { + if (response.url().includes(until)) { + release() + } + }) + + await page.route('**/apps/tables/table', async (route) => { + await Promise.race([released, expired]) + await route.continue() + }) + + return { answered: released } +} + export async function ensureNavigationOpen(page: Page) { const openButton = page.getByRole('button', { name: /open navigation/i }).first() if (await openButton.isVisible().catch(() => false)) { diff --git a/src/store/store.js b/src/store/store.js index 3bfa80586c..444eaa5c55 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -152,13 +152,7 @@ export const useTablesStore = defineStore('store', { try { const res = await axios.get(generateUrl('/apps/tables/table')) this.setTables(res.data) - let views = [] - res.data.forEach(table => { - if (table.views) { - views = views.concat(table.views) - } - }) - this.setViews(views) + this.replaceViewsOfListedTables(res.data) } catch (e) { displayError(e, t('tables', 'Could not load tables.')) showError(t('tables', 'Could not fetch tables')) @@ -168,6 +162,22 @@ export const useTablesStore = defineStore('store', { return true }, + /** + * We have two sources of views, this coming from tables endpoint, and those + * that were shared directly or through contexts. + */ + replaceViewsOfListedTables(tables) { + const tableIdsShippingViews = new Set( + tables + .filter(table => !table.isShared || table.onSharePermissions?.manage) + .map(table => table.id), + ) + const viewsOfOtherTables = this.views.filter(view => !tableIdsShippingViews.has(view.tableId)) + const viewsOfListedTables = tables.flatMap(table => table.views ?? []) + + this.setViews([...viewsOfListedTables, ...viewsOfOtherTables]) + }, + async loadViewsSharedWithMeFromBE() { this.setLoading({ key: 'viewsShared', value: true })