From 919c64b8abd577f12f3ab76473d414b751dc4e1f Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Mon, 12 Oct 2026 06:39:57 +0100 Subject: [PATCH 01/48] build(release): v2.3.2 Signed-off-by: Enjeck C. --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ appinfo/info.xml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e793072a9..0aeacf8346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ # Changelog +## 2.3.2 + +### Added +* [Feat: Allow image previews of Files image links (tables#2946)](https://github.com/nextcloud/tables/pull/2946) +* [Feat: Add rector to CI (tables#2949)](https://github.com/nextcloud/tables/pull/2949) + +### Fixed +* [Fix(search): search as the user the provider is given (tables#3011)](https://github.com/nextcloud/tables/pull/3011) +* [Fix(Handlers): use a composable for (un)subscribing to events (tables#3023)](https://github.com/nextcloud/tables/pull/3023) +* [Fix(rows): add id tie-breaker so limit/offset pages are stable (tables#3058)](https://github.com/nextcloud/tables/pull/3058) +* [Fix(migration): use mappers instead of services in db structure repair (tables#3064)](https://github.com/nextcloud/tables/pull/3064) +* [Fix(federation): accept federated requests from instances on non-default ports (tables#3065)](https://github.com/nextcloud/tables/pull/3065) + +### Dependencies +* [Update nextcloud/ocp dependency (tables#3038)](https://github.com/nextcloud/tables/pull/3038) +* [Fix(deps): update dependency @nextcloud/dialogs to ^7.5.0 (main) (tables#2963)](https://github.com/nextcloud/tables/pull/2963) +* [Chore(deps): update dependency psalm/phar to ^6.17.2 (main) (tables#3015)](https://github.com/nextcloud/tables/pull/3015) +* [Fix(deps): update dependency @nextcloud/files to ^4.1.0 (main) (tables#3016)](https://github.com/nextcloud/tables/pull/3016) +* [Fix(deps): update dependency @nextcloud/vue to ^9.13.1 (main) (tables#3017)](https://github.com/nextcloud/tables/pull/3017) +* [Chore(deps): update dependency @rollup/rollup-linux-x64-gnu to ^4.63.3 (main) (tables#3018)](https://github.com/nextcloud/tables/pull/3018) +* [Fix(deps): update dependency @material-symbols/svg-400 to ^0.47.4 (main) (tables#3031)](https://github.com/nextcloud/tables/pull/3031) +* [Chore(deps): update dependency rector/rector to ^2.6.7 (main) (tables#3030)](https://github.com/nextcloud/tables/pull/3030) +* [Chore(deps): update dependency eslint to ^10.11.0 (main) (tables#3032)](https://github.com/nextcloud/tables/pull/3032) +* [Fix(deps): update dependency @nextcloud/router to ^3.2.0 (main) (tables#3033)](https://github.com/nextcloud/tables/pull/3033) +* [Chore(deps): update cypress-io/github-action action to v7.4.4 (main) (tables#3039)](https://github.com/nextcloud/tables/pull/3039) +* [Chore(deps): update dependency @nextcloud/e2e-test-server to ^0.6.1 (main) (tables#3040)](https://github.com/nextcloud/tables/pull/3040) +* [Chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.3 (main) (tables#3041)](https://github.com/nextcloud/tables/pull/3041) +* [Update nextcloud/ocp dependency (tables#3020)](https://github.com/nextcloud/tables/pull/3020) +* [Fix npm audit (tables#3060)](https://github.com/nextcloud/tables/pull/3060) + ## 2.3.1 ### Added diff --git a/appinfo/info.xml b/appinfo/info.xml index 5da5ffd443..61691e0ba0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.3.1 + 2.3.2 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/package-lock.json b/package-lock.json index 8f8f8ba1d4..876db4ff57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.3.1", + "version": "2.3.2", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.47.4", diff --git a/package.json b/package.json index a5df23ef74..62435356a7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.3.1", + "version": "2.3.2", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" From 5cdeaa22ff4f2a0c7ad72d83059e8205fb7f7f4a Mon Sep 17 00:00:00 2001 From: Arthur Schiwon Date: Fri, 4 Sep 2026 22:11:30 +0200 Subject: [PATCH 02/48] fix(sidebar): keep views the table listing does not carry Views come from more source (owned, and shared) and there was a race condition that would reset the list of views instead of extending it. Signed-off-by: Arthur Schiwon Assisted-by: ClaudeCode:claude-opus-5 --- playwright/e2e/navigation-load-order.spec.ts | 88 ++++++++++++++++++++ playwright/support/commands.ts | 58 +++++++++++++ src/store/store.js | 24 ++++-- 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 playwright/e2e/navigation-load-order.spec.ts 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 60d096a2f7..fe66200ba2 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 }) From 91b1bfe4dbb2647ab323e0f51f5a5a7a2c2b649a Mon Sep 17 00:00:00 2001 From: taiebot <13300652+taiebot@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:23:14 +0200 Subject: [PATCH 03/48] Tabview2 (#20) * Feat app Tab layout Add a new view for app. Tab layout --------- Assisted-by: ClaudeCode:claude-sonnet-5-5 --- lib/Controller/ContextController.php | 8 +- lib/Db/Context.php | 7 +- lib/Db/ContextMapper.php | 9 +- .../Version2301Date20260930000000.php | 42 + lib/ResponseDefinitions.php | 1 + lib/Service/ContextService.php | 8 +- .../main/sections/ContextResourceTabs.vue | 724 ++++++++++++++++++ .../main/sections/TableDescription.vue | 77 +- src/modules/modals/CreateContext.vue | 26 +- src/modules/modals/EditContext.vue | 23 +- src/pages/Context.vue | 118 ++- src/store/store.js | 3 + tests/unit/Service/ContextServiceTest.php | 68 ++ 13 files changed, 1062 insertions(+), 52 deletions(-) create mode 100644 lib/Migration/Version2301Date20260930000000.php create mode 100644 src/modules/main/sections/ContextResourceTabs.vue diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index e87f8ca440..d98d73af44 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -100,6 +100,7 @@ public function show(int $contextId): DataResponse { * @param string $iconName Material design icon name of the context * @param string $description Descriptive text of the context * @psalm-param list $nodes optional nodes to be connected to this context + * @param bool $tabViewEnabled Show the resources of the context as tabs instead of stacked * * @return DataResponse|DataResponse * @@ -108,7 +109,7 @@ public function show(int $contextId): DataResponse { * 403: lacking permissions on a resource */ #[NoAdminRequired] - public function create(string $name, string $iconName, string $description = '', array $nodes = []): DataResponse { + public function create(string $name, string $iconName, string $description = '', array $nodes = [], bool $tabViewEnabled = false): DataResponse { try { if (!$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -120,6 +121,7 @@ public function create(string $name, string $iconName, string $description = '', $this->sanitizeInputNodes($nodes), $this->userId, 0, + $tabViewEnabled, )->jsonSerialize()); } catch (Exception $e) { return $this->handleError($e); @@ -138,6 +140,7 @@ public function create(string $name, string $iconName, string $description = '', * @param ?string $iconName provide this parameter to set a new icon * @param ?string $description provide this parameter to set a new description * @param ?array{id: int, type: int, permissions: int, order: int} $nodes provide this parameter to set a new list of nodes. + * @param ?bool $tabViewEnabled provide this parameter to show (or stop showing) the resources as tabs * * @return DataResponse|DataResponse * @@ -149,7 +152,7 @@ public function create(string $name, string $iconName, string $description = '', * @CanManageContext */ #[NoAdminRequired] - public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): DataResponse { + public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?bool $tabViewEnabled = null): DataResponse { try { if ($iconName !== null && !$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -162,6 +165,7 @@ public function update(int $contextId, ?string $name, ?string $iconName, ?string $iconName, $description, $nodes, + $tabViewEnabled, )->jsonSerialize()); } catch (Exception|MultipleObjectsReturnedException $e) { return $this->handleError($e); diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 3fa14f56b5..59c9342582 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -21,6 +21,8 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void + * @method getTabViewEnabled(): ?bool + * @method setTabViewEnabled(?bool $value): void * * @method getSharing(): array * @method setSharing(array $value): void @@ -35,6 +37,7 @@ class Context extends EntitySuper implements JsonSerializable { protected ?string $description = null; protected ?string $ownerId = null; protected ?int $ownerType = null; + protected ?bool $tabViewEnabled = null; // virtual properties protected ?array $sharing = null; @@ -46,6 +49,7 @@ class Context extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('owner_type', 'integer'); + $this->addType('tabViewEnabled', 'boolean'); } public function jsonSerialize(): array { @@ -56,7 +60,8 @@ public function jsonSerialize(): array { 'iconName' => $this->getIcon(), 'description' => $this->getDescription(), 'owner' => $this->getOwnerId(), - 'ownerType' => $this->getOwnerType() + 'ownerType' => $this->getOwnerType(), + 'tabViewEnabled' => (bool)$this->getTabViewEnabled(), ]; // extended data diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..c243276a51 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -84,6 +84,7 @@ protected function formatResultRows(array $rows, ?string $userId) { 'description' => $rows[0]['description'], 'owner_id' => $rows[0]['owner_id'], 'owner_type' => $rows[0]['owner_type'], + 'tab_view_enabled' => $rows[0]['tab_view_enabled'] ?? null, ]; $formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) { @@ -103,7 +104,9 @@ protected function formatResultRows(array $rows, ?string $userId) { 'display_mode_default' => (int)$item['display_mode_default'], ]; if ($userId !== null) { - $item['display_mode'] ??= $item['display_mode_default']; + if ($item['display_mode'] === null) { + $item['display_mode'] = $item['display_mode_default']; + } $carry[$item['share_id']]['display_mode'] = (int)$item['display_mode']; } return $carry; @@ -128,7 +131,9 @@ protected function formatResultRows(array $rows, ?string $userId) { // empty Context return $carry; } - $carry[$item['page_id']] ??= ['content' => []]; + if (!isset($carry[$item['page_id']])) { + $carry[$item['page_id']] = ['content' => []]; + } $carry[$item['page_id']]['id'] = (int)$item['page_id']; $carry[$item['page_id']]['page_type'] = $item['page_type']; if ($item['node_rel_id'] !== null) { diff --git a/lib/Migration/Version2301Date20260930000000.php b/lib/Migration/Version2301Date20260930000000.php new file mode 100644 index 0000000000..c5e79898ba --- /dev/null +++ b/lib/Migration/Version2301Date20260930000000.php @@ -0,0 +1,42 @@ +hasTable('tables_contexts_context')) { + return null; + } + + $contexts = $schema->getTable('tables_contexts_context'); + + if (!$contexts->hasColumn('tab_view_enabled')) { + $contexts->addColumn('tab_view_enabled', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + 'comment' => 'Show the application resources as tabs instead of stacked', + ]); + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c935d72524..25cd7f4ca8 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -233,6 +233,7 @@ * description: string, * owner: string, * ownerType: int, + * tabViewEnabled: bool, * } * * @psalm-type TablesContextNavigation = array{ diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index edfd7d9a6e..1f4739fa28 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -132,13 +132,14 @@ public function findById(int $id, ?string $userId): Context { * @psalm-param list $nodes * @throws Exception|PermissionError|InvalidArgumentException */ - public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType): Context { + public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType, bool $tabViewEnabled = false): Context { $context = new Context(); $context->setName(trim($name)); $context->setIcon(trim($iconName)); $context->setDescription(trim($description)); $context->setOwnerId($ownerId); $context->setOwnerType($ownerType); + $context->setTabViewEnabled($tabViewEnabled); $this->atomic(function () use ($context, $nodes): void { $this->contextMapper->insert($context); @@ -159,7 +160,7 @@ public function create(string $name, string $iconName, string $description, arra * @throws DoesNotExistException * @throws PermissionError|MultipleObjectsReturnedException */ - public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): Context { + public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?bool $tabViewEnabled = null): Context { $context = $this->contextMapper->findById($contextId, $userId); if ($name !== null) { @@ -171,6 +172,9 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i if ($description !== null) { $context->setDescription(trim($description)); } + if ($tabViewEnabled !== null) { + $context->setTabViewEnabled($tabViewEnabled); + } $hasUpdatedNodeInformation = false; if ($nodes !== null) { diff --git a/src/modules/main/sections/ContextResourceTabs.vue b/src/modules/main/sections/ContextResourceTabs.vue new file mode 100644 index 0000000000..c0d8960ae8 --- /dev/null +++ b/src/modules/main/sections/ContextResourceTabs.vue @@ -0,0 +1,724 @@ + + + + + + diff --git a/src/modules/main/sections/TableDescription.vue b/src/modules/main/sections/TableDescription.vue index ce1aff28f8..2ef8311f7f 100644 --- a/src/modules/main/sections/TableDescription.vue +++ b/src/modules/main/sections/TableDescription.vue @@ -4,7 +4,20 @@ -->