Skip to content

feat(views): allow views without a table - #13

Open
remko48 wants to merge 58 commits into
mainfrom
stack/g1-views-without-table
Open

remko48 wants to merge 58 commits into
mainfrom
stack/g1-views-without-table

Conversation

@remko48

@remko48 remko48 commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Views can now exist without a table.
With this PR we prepare for the use of pages inside an application.
Views also get a type (table or grid).
Every existing view becomes table, so nothing changes for current users.

What changes

  • Migration: tables_views gets a type column with the default table. table_id was already nullable (for federated views), so a view without a table needs no other schema change.
  • Ownership: a view normally inherits its ownership from its table. A view without a table is owned by the user who created it.
  • Creating: ViewService::create() accepts no table when the type is not table. A table view still needs a table, and a view without a table cannot be changed to table later.
  • Technical name: a view without a table is always named by its id (view_<id>). Setting a technical name on it is rejected, and an import drops it. The unique index on (table_id, technical_name) treats a missing table differently per database.
  • Guards: places that assume every view has a table now handle the missing table:
    • rows: listing returns nothing and the row count is 0; creating rows, importing or adding columns returns a not-found error
    • columns, relations and permission inheritance: nothing to inherit or show
    • activity: activities belong to a table, so a view without a table creates none
  • Listing: the user's own views without a table are now shown in their shared views, because no table shows them.
  • User deletion: When a user is deleted, views without a table are also deleted if the user owns them.
  • API: tableId is now nullable and type is added to the view response (OpenAPI and TS types regenerated). type can already be changed through the existing view update endpoints. There is no new endpoint yet; creating a view without a table through the API comes in a later PR of this stack.

🖼️ Screenshots

Backend only, no UI changes.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔙 Backport requests are created or not needed: /backport to stableX.X
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

enjeck and others added 3 commits October 12, 2026 06:39
Signed-off-by: Enjeck C. <patrathewhiz@gmail.com>
Signed-off-by: Enjeck C. <patrathewhiz@gmail.com>
A view can now exist without a table and carries a type (table or grid),
so an application can hold pages that are not tied to one table.

- tables_views gets a type column and table_id becomes nullable
- a view without a table is owned by its creator and listed for them
- row, column, import, relation, permission and activity code skips
  views without a table instead of failing on the missing table
- views without a table are removed with their owner

Co-authored-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
Comment thread lib/Db/ViewMapper.php
@remko48
remko48 added this pull request to stack #15 October 12, 2026 08:26
Comment thread lib/Db/View.php Outdated
Comment thread lib/Service/RowService.php
Comment thread lib/Model/ViewUpdateInput.php Outdated
Comment thread lib/Migration/Version2400Date20261012000000.php Outdated
Federated views have no table_id either, so a missing table alone does not
mean a view is standalone. View::isStandalone() checks for no table and no
external id, and the guards, the owner fallback and the query for a user's
standalone views now use it.

- a row update, delete or presence check against a view without a table
  no longer ends in a TypeError: the view holds no rows
- the view type is validated with the rest of the input and is typed as
  table or grid in the API
- the migration no longer makes table_id nullable, it already is

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
The unique index on (table_id, technical_name) does not hold for a missing
table on MySQL, PostgreSQL and SQLite, and on Oracle it makes a name unique
across the whole instance. A view without a table is therefore always named
view_<id>: create and update refuse a chosen name, and an import ignores the
name from its source. The page a user names is the application's menu item,
whose technical name is unique within the application.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
@remko48 remko48 closed this Oct 12, 2026
@remko48 remko48 reopened this Oct 12, 2026
chore: remove unused babel config dependency from devDependencies
remko48 and others added 2 commits October 12, 2026 16:41
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 <blizzz@arthur-schiwon.de>
Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot added the AI assisted This PR contains AI-assisted commits label Oct 13, 2026
- the row checks use isStandalone(), like the rest of the row code
- deleting a user's views without a table lives in ViewService; the listener only calls it
- a grid view cannot have a table: refused on create and when changing the type
- changing or deleting a row through a view without a table is a 404 instead of a 500
- a view's uuid is unique on its own: duplicates get a fresh uuid in a migration before the
  index is added, and an import only keeps the source uuid when it is still free

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>

@rjzondervan rjzondervan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked 4a25495 against blizzz's review on nextcloud#3075 and the replies there. Every change promised in those replies is in this commit: isStandalone() in both Row2Mapper checks, the cleanup moved to ViewService, grid views refused with a table on create and update, the 404 for row changes through a view without a table, and the uuid-only index with a migration and an import that keeps a free uuid. Psalm and cs are clean, and the new tests pass. In a mutation check, the new rules are all caught; what survives is moved or older code (the listener call, the loop continuing after one failed delete, the Uuid::isValid() check on import).

Inline: one 🟡 on the new index, plus two smaller points. Two things from the upstream replies are not in this commit:

  • Technical name: blizzz's general question in his review summary (why the technical name changes, since it does not depend on a table) has no answer on nextcloud#3075 yet, and this commit does not touch technical names.
  • TablesMigrator: the reply on grid views not being exported says "we will fix that", but this commit does not touch TablesMigrator. Worth saying on nextcloud#3075 whether it comes in this PR or a later one.

Comment thread lib/Migration/Version2400Date20261013000000.php
Comment thread lib/Service/RowService.php
Comment thread lib/Db/Row2Mapper.php
remko48 and others added 4 commits October 13, 2026 15:41
- a proxy view no longer copies the uuid of the remote view, so one view federated to
  several users on the same instance does not clash on the uuid index of views; tables
  keep the source uuid as on main
- the migration keeps the uuid on the local view when a proxy shares it, and drops the
  old (table_id, uuid) index that the uuid-only index covers
- the row checks say that federated views read their rows through the federation proxy

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
…les-views

fix(sidebar): keep views the table listing does not carry
Signed-off-by: Rello <github@scherello.de>

Signed-off-by: Rello <Rello@users.noreply.github.com>

AI-assistant: Codex (GPT-5)
Co-Authored-By: Andy Scherzinger <info@andy-scherzinger.de>
Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
AndyScherzinger and others added 15 commits October 14, 2026 06:22
View::getLayoutNormalized(), ViewUpdateInput::normalizeLayout() and
importView() each spelled out the set of layouts, and the frontend repeated
the card layouts in two components. A backing enum and matching frontend
constants now hold the values once.

importView() keeps accepting a layout it cannot resolve by dropping it, so a
scheme written by another version still imports, but it says so rather than
leaving the leniency implicit in a second copy of the list.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The template called getPreviewUrl twice per row, once in the v-if and once in
the src binding, each parsing the cell as JSON and looking its column up
again, while the title and metadata helpers repeated the same column lookups
for every row. A page of a hundred cards paid for all of it on each render.

The values are now derived in one computed pass over the page, with the title
and background columns resolved once for the whole set.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
getDisplayValue() guarded against a column without getValueString, but every
column reaching the card layouts is a columnClass instance, so the branch
never ran. The layout picker wrapped a single radio row in a presentation
table, which is a layout table for something flex does, and its title rule
matched no element. The fallback to the second column as a card title now
says why it is the second one.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
layout and viewSettings were copied into viewSetting, the object holding a
viewer's unsaved tweaks, so a manage gated property of the view and a local
adjustment became indistinguishable. That is what let "Reset local
adjustments" drop the layout.

Both now reach the table as their own props, sourced from the view itself,
and viewSetting.layout means only "this viewer switched layout". The rendered
layout is that override falling back to the saved one, so resetting returns
to the view's layout instead of erasing it, and the reset button appears once
a viewer has switched.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Only a manager can open the view settings, so a recipient of a shared view had
no way to read it as anything but the layout it was saved with.

The toolbar gains a menu button at its trailing edge listing the three
layouts, each with its own icon. Choosing one writes the local view setting,
so it applies at once, is never persisted, and is cleared again by "Reset
local adjustments" or by leaving the view. The trigger carries the icon of the
current layout and is labelled "Switch layout", since an icon-only button
would otherwise have no accessible name.

It appears only where the view was set up for cards, meaning a card title
source is configured or the saved layout is already tiles or gallery: cards of
a view without a title source would carry nothing but row numbers. That is a
presentation choice, not a constraint, so nothing on the write path enforces
it. An embedded widget declines the switch outright, being a snapshot of a
view inside someone else's document, and the smart picker preview inherits
that; public links and contexts keep it.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
A card was a <button> holding a div, a list and, wherever a rich text column
carries one, a link. A button may contain none of those: it takes phrasing
content with no interactive descendant, and the parser does not repair the
tree, so the invalid markup reached the DOM. A link inside a card also
bubbled its click to the button, which followed the link and opened the row
editor at the same time.

The card is now a div with the button role, keyboard activation on enter and
space, and a focus ring of its own. Clicks coming from a link are left to the
link.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Card sources drop to null when they no longer resolve, which contradicted the
soft-invalidation rule as written. The rule now says why a reference that
only selects how a row is displayed is treated differently from one that
decides which rows or values are returned: an id that fails to remap on
import may address a live column of an unrelated table, and a view without
card sources falls back to the text-only card layout, so there is no broken
state for a user to repair.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
notifyNodeUpdate() and the share payload carried only title and emoji, so a
federated gallery view rendered as a table on the receiving instance with
nothing to say the layout had been lost.

The layout travels with both, and a value the receiving version does not know
is ignored rather than stored. The card sources stay behind on purpose: they
are column ids of the sending instance and would address unrelated columns
after the hop, so a federated card layout renders text-only. Adding them
later means carrying column uuids, which the payload can gain without
breaking a receiver, since unknown keys are ignored.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Opening an application by its URL, rather than by clicking it in the navigation,
left the page blank: setContext() looked the context up with findIndex() and
assigned straight to that index, so on the first load -- when the list has not
been fetched yet and the lookup returns -1 -- contexts[-1] named a property
instead of appending. The getters iterate the array, so activeContext stayed
undefined and every branch of the template was false. Reading the icon off it
threw first, leaving "An error occurred while loading the application" instead.

Append when the context is not in the list yet, as the store convention for
findIndex lookups requires, and drop the second copy of the icon assignment.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The card layout and the link cell each grew their own way of reading a file id out
of a link and building a preview request, and the two disagreed. The link cell
matched a lowercase fileid only, which is not the spelling the server writes, so a
preview URL never resolved through it; it also read an id out of a link to another
host and then requested that id from this one. The card layout, in turn, anchored
the id to the end of the path and did not know the openfile parameter that a Files
deep link carries.

Move the matcher and the URL builder to a shared helper that takes what both need:
the link is parsed rather than pattern matched, a foreign origin is refused, fileid
and openfile are read whatever their case, and the id may be followed by more path.
Each caller keeps what is its own -- the link cell only previews a files reference,
while a card background may be any column and so probes the fields a stored link
may hold -- and both now prefer an id the picker recorded outright.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Signed-off-by: Enjeck C. <patrathewhiz@gmail.com>
…te related tests

Signed-off-by: Enjeck C. <patrathewhiz@gmail.com>
…istent-layout-modes-in-nextcloud-tables

🖼️ Add persistent per-view layout modes (table / tiles / gallery)
@rubenvdlinde

rubenvdlinde commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

@remko48 a heads-up on what is happening in this fork while Ruben is away.

Why

On nextcloud#3075, enjeck asked why grid pages are views without a table, when Tables already has a page model for applications (tables_contexts_page and tables_contexts_page_content). Ruben agrees: views stay a projection of one table, and pages move onto the existing page model. A dashboard is a grid page. Every new application's start page is its dashboard.

Ruben will post the reply on nextcloud#3075 himself. The full design is in that reply.

What I'm building here

Everything happens in this fork, branched from main-forked. Each step is an open PR here. Nothing gets merged, and nothing goes to nextcloud/tables. The idea is that you build the upstream stack from these PRs.

Menu stack (stack/m1 to m4, same as upstream nextcloud#3086 to nextcloud#3089). New commits on the existing branches, merged forward, no force-push:

  • m1: a page target type. An item is broken when its page is gone or belongs to another application.
  • m2: saving rejects a page of another application (400). Creating an application adds a "Dashboard" menu item pointing at the new start page.
  • m3: only the docblocks it already rewrites.
  • m4: the create dialog shows the dashboard item first. Its label can be changed; it can't be removed.

New page stack (stack/p1 to p4, replaces stack/g1 to g6):

  • p1: page model. New columns on page and page content, node_rel_id nullable, the fix to the context query, and reading of existing rows.
  • p2: page and widget endpoints on api/2, plus the widget types.
  • p3: canvas and widgets, carried over from g4 and g5. The data widget points at a node of the application.
  • p4: grid pages, with the start page as the dashboard. Playwright specs.

Application stack (stack/a1 to d4). After the two stacks above, these get rebuilt on a new stack-4-main (p4 + m4) instead of grid views.

What this means for upstream

The old stack/g* PRs in this fork stay open until you've ported.

I'll update this comment with the PR numbers and test results as each stack finishes.

Prepared with Claude Code on Ruben's instruction.


Status

Menu stack: done (15 Oct)

Branch Fork PR Upstream Head
stack/m1-menu-model #20 nextcloud#3086 fa9a30a
stack/m2-menu-saving #21 nextcloud#3087 cff2554
stack/m3-menu-nesting #22 nextcloud#3088 e19ac36
stack/m4-menu-editor #23 nextcloud#3089 2694a7b

Each branch got its own commit, then the one below it merged in. No rebase, no force-push. Per PR:

  • m1: MenuItem::TARGET_PAGE. An item is broken when its page is gone or belongs to another application. OpenAPI regenerated.
  • m2:
    • An id-less page item is accepted only on create (MenuItemInput flag allowNewStartPage), and it resolves to the new start page.
    • Without one, create puts a "Dashboard" item first, with technical name dashboard.
    • A new page target outside the application gets a 400.
    • A stored page item whose page is gone is kept as broken, the same as views and tables.
    • IL10N is injected into ContextService.
    • ContextControllerTest is new.
  • m3: goes beyond docblocks. Putting the dashboard item first shifts every position, so MenuItemInput::shiftedBy() keeps parentIndex pointing at the right parent.
  • m4: the dashboard item comes first in the create dialog. It can't be removed, moved or indented, and nothing can be placed above it. New playwright/e2e/context-menu.spec.ts.

Tests (PHPUnit inside a throwaway container, one file at a time, on each branch):

  • MenuItemTest, MenuItemMapperTest, MenuItemInputTest, ContextServiceTest (28 tests on m4) and ContextControllerTest: OK on every branch.
  • cs:check clean and psalm "No errors found!" on every branch.
  • On m4:
    • npm run lint: one inherited jsdoc warning in store.js.
    • npm run stylelint: clean.
    • Cypress menuItems.cy.js: 12 passing.
    • Playwright context-menu.spec.ts: 1 passed.
    • Playwright context.spec.ts and context-navigation.spec.ts: 7 of 8 passed. The eighth timed out at the local 60 s and passes alone at CI's 120 s.

Three things for you:

  • GitHub refuses to change the base of feat(contexts): add a menu to applications #20 to main-forked ("part of a stack"). The branch already contains main-forked, so only the base label is off.
  • stack-3-main is untouched, because of your "Do not merge further". The application stack will move to a new stack-4-main instead.
  • The menu isn't shown anywhere in the UI yet on these branches. The dashboard target was checked through the API and the application route.

Page stack: done (15 Oct)

Branch Fork PR Base Head Replaces upstream
stack/p1-page-model #35 main-forked d5e3582 nextcloud#3075
stack/p2-page-api #36 p1 647e4a5 nextcloud#3076, nextcloud#3077
stack/p3-grid-widgets #37 p2 553bdbf nextcloud#3079, nextcloud#3081
stack/p4-grid-pages #38 p3 0c57e65 nextcloud#3082

Where the code differs from the reply on nextcloud#3075:

  • Context query. It is two queries, not a join. If nodes are joined through page content, a node that sits on no page drops out of nodes, and findAllContainingNode stops granting its permissions. Grid pages make that case common. So pages and content come from one query, and the node relations of all found applications come from a second chunked query. findAllContainingNode filters with a subquery. Ruben's reply text is updated to match.
  • Widget data. The configuration column stores {"configuration":{...},"content":{...}}. A data widget's node is the row's node_rel_id.
  • Response shape. The page API returns the same snake_case keys as pages[].content[] in the context response.
  • Routes. PUT /contexts/{id}/pages/{pageId} was already the order endpoint, so renaming a page is PATCH. Saving widgets is PUT .../pages/{pageId}/widgets. Widget types are at GET /api/2/widget-types.
  • Status codes. A data widget for a table or view the user can't manage gets 403, the same as ContextService::update. A foreign nodeRelId gets 400.
  • Version. p1 bumps the app to 2.4.0-dev.1, so the migration runs.
  • Fixes in p4. The edit dialog listed only start-page nodes, so saving it would have dropped nodes that sit only on grid pages. It now lists every node. ContextService::update no longer rewrites order on a start page that has been arranged on the grid.
  • Public shares, Smart Picker, reference widgets. Applications appear in none of them (link shares and references cover only tables and views), so pages have nothing to wire there.

Tests: all on PostgreSQL 16 / NC 34, PHPUnit one file at a time. MySQL, MariaDB and SQLite are left to CI.

  • p1:
    • ContextMapperTest, new, real DB: 7 tests.
    • PageContentHelperTest: 6 tests.
    • ContextServiceTest, PermissionsServiceTest, TablesMigratorTest: OK.
  • p2:
    • PageServiceTest: 28 tests.
    • ContextPageControllerTest: 7 tests, covering 403 for a non-manager, 400 for a foreign node, 400 for deleting the start page, and 404 for another application's page.
    • GridWidgetTypesTest: 12 tests.
  • p3: Cypress gridLayout.cy.js, 9 passing.
  • p4:
    • Playwright context-grid-pages.spec.ts, 4 of 4: start page order before any edit, a header saved and reloaded with order filled, a new page whose data widget adds its table as a node, and a sharee who sees the page read-only and gets a 403 from the API.
    • context.spec.ts and context-navigation.spec.ts pass.
  • cs, psalm, lint and stylelint are clean on every branch.

p3 is about 1,580 lines without the lock file. It wasn't split, because it is one set of components.

Application stack: done (15 Oct)

stack-4-main (a95ec56) is p4 with m4 merged in. There is no PR for it, just like stack-3-main. When merging, the ContextMapper conflict was resolved by keeping p1's two-query design and adding one query for all menus on top. The app version is now 2.4.0-dev.2, and n5 raises it to 2.4.0-dev.3, so an instance on any stack runs every migration.

Branch Fork PR Replaces Head
stack/n1-application-shell #39 #25 0b687f3
stack/n2-application-overview #40 #26 74de0f3
stack/n3-export-import #41 #27 45413ac
stack/n4-overview-extras #42 #28 b9c1a27
stack/n5-application-technical-names #43 #29 49bf61e
stack/n6-applications-page #44 #30 9fe34d3
stack/n7-per-user-layout #45 #31 4460f22

#25 to #31 each have a "superseded by" comment and are still open.

Behaviour that changed compared with the old PRs (each PR body explains it):

Tests: PostgreSQL 16 / NC 34, Playwright at 1600 px with --timeout=120000.

Open for you (and Ruben):

  1. Should a re-import match pages without a technical name by title? For now it doesn't.
  2. Is feat(applications): personal layout for the standalone application #45's shape (personal widths on the shared page grid) what you want?
  3. Inherited, not fixed: create-context returns pages[].pageType in camelCase, while the read endpoint uses page_type.

Suggested upstream order

  1. feat(contexts): add a menu to applications nextcloud/tables#3086 to feat(contexts): edit the menu when creating an application nextcloud/tables#3089 with the menu commits from feat(contexts): add a menu to applications #20 to feat(contexts): edit the menu when creating an application #23.
  2. The page stack from feat(pages): extend the application page model for grid pages #35 to feat(pages): show every application page on the grid #38, replacing feat(views): allow views without a table nextcloud/tables#3075 to feat(grid): show and edit grid views on their own page nextcloud/tables#3082.
  3. The application stack from feat(applications): standalone application shell with its own menu #39 to feat(applications): personal layout for the standalone application #45, once 1 and 2 have landed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted This PR contains AI-assisted commits

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants