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 30 commits October 14, 2026 06:22
A view shared by link always came out as a table. The renderer takes the layout
from the view setting it is handed, but nothing ever put one there: the public
page ships its node through an allowlist of six keys, and neither layout nor
viewSettings was among them, so the browser never learned that the view had been
saved as tiles or a gallery.

Let both keys through and carry them into the element the public page builds. A
shared table has no layout of its own and keeps rendering as a table.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
A view embedded in a text or markdown file rendered as a table whatever layout it
had been saved with. The widget mounts the same table component as the app, so it
only lacked the setting: the reference payload carried no layout, and the widget
started from an empty view setting that nothing filled in.

Send layout and viewSettings along with a view reference, and seed the widget
from them. A table reference carries neither and keeps the table layout.

Covered by component tests for both card layouts and for a reference without one.
Splitting the mount helper keeps the row tests aliasing a table while the card
cases mount without assuming one.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Picking a view as content showed a table preview whatever layout the view had
been saved with, so the preview did not match what the inserted widget renders.

The picker assembles its own rich object from the search result, and the search
result is a light shape carrying no layout. It already fetches columns and rows
for the preview, so fetch the layout the same way and hand it to the widget. A
table has no layout of its own and keeps its table preview.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
A rich text column came out as its raw markdown on a tile or a gallery card,
while the table rendered it formatted. The cards took the value as a plain
string, so the asterisks and hashes of the stored text were shown as written.

Render a rich text value as the formatted text it is, in the one place both card
layouts take their values from, so tiles, galleries, the embedded widget, the
picker preview and public links all follow. Markdown brings block elements with
it, so they are held on the text flow inside a title, where the line clamp needs
them, and stripped of their own spacing inside the metadata.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
A preview is served against the viewer's session, so on a public link there is
none to authenticate with and the image can never arrive. Cards still asked for
one, leaving a broken image on every card of a shared tiles or gallery view.

Ask for no preview at all when there is no session. The card then has no
background to show and falls into the layout a view already uses when no image
column is configured, so the shared card needs no styling of its own.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
layout and viewSettings travel in the same viewSetting object as the local
tweaks, so emitting an empty object dropped them and a tiles or gallery view
fell back to the table rendering until the next reload. Only the keys the
reset button reports on are cleared now.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
updateView() rewrote both card sources on every save, so renaming a plain
table view stored a background pointing at the first column, which is rarely
an image. Together with a non clearable select that made "no background
image" unreachable even though the model, the API and the rendering all treat
null as a valid state. Sources are now only written once picked, and the
background can be cleared again.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
An empty candidate list skipped validation altogether, so a column id from an
unrelated table was accepted whenever the view had no columns of its own. With
no candidates every non null source is now invalid, which is what the rest of
the write path already assumes. The docblock describing the assertion also
moves back onto it.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
getViewSettingsObject() parsed the stored column through the strict input
factory, so a single unparsable view_settings row made jsonSerialize() throw
and took down every endpoint that lists that view. Reading now drops a value
it cannot use, matching how the layout column is already normalised on read,
while writes keep rejecting bad input.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
createViewSettingsFromInputData(), createImportedViewSettings() and
remapViewCardSources() each carried a fallback for top level
cardBackgroundSource and cardTitleSource keys. That shape was never released,
so no stored data uses it and the branches were unreachable. The migrator test
covering it now asserts that a view without settings passes through untouched.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The ready made URL branch accepted any path ending in /core/preview and
forwarded the query string verbatim, so a cell author chose part of the path
and all parameters of an authenticated same origin request. The /f/<id> match
also ignored the host, contradicting the comment above it: a link naming a
foreign server resolved to a local file of the same id.

Both spellings now go through one helper that refuses anything cross origin
and rebuilds the preview URL from the extracted id, so neither the path nor
the query can be influenced. The requested size becomes a named constant.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
dropOrphanedCardSources() ran on every update that did not carry
viewSettings, so a rename, an emoji or a sidebar reorder rewrote the card
sources and, for a view without an explicit column selection, paid for a
findAllByManagedView() query to do it. Only a changed column selection can
orphan a source, and deleting a column is already handled on its own path.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
tables:pagination-changed carried no node identity, so once PaginationBlock
subscribed to it as well every pagination control on the page followed every
other. A context renders many tables and views at once, where paging one
paged them all. The payload now names its element and receivers ignore
anything else.

The guard against echoing an adopted value no longer depends on watcher flush
order either: the adopted pair is remembered and compared instead of clearing
a flag on the next tick.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The observer callback wrote the line count that resizes the title it was
observing, which made the browser report an undelivered notification loop.
Measuring on the next frame lets the layout settle first. The observed title
also belonged to a card that paging replaces, leaving the observer on a
detached element, so the layout is observed again whenever the page changes.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The layout request was added after the column and row ones, so inserting a
view as content waited for three round trips in sequence. None of them depends
on another.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
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)
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.

6 participants