Skip to content

Grid pages in applications (data model and API) #3100

Open

Description

@enjeck

Part of #3099. We want to store pages and widgets relationally without changing too much of the structure of views, rows, permissions or existing application content.

Schema

erDiagram
  CONTEXTS_CONTEXT ||--o{ CONTEXTS_REL_CONTEXT_NODE : "has nodes"
  CONTEXTS_CONTEXT ||--o{ CONTEXTS_PAGE : "has pages"
  CONTEXTS_PAGE ||--o{ CONTEXTS_PAGE_CONTENT : "start page rows"
  CONTEXTS_PAGE ||--o{ CONTEXTS_WIDGET : "grid page widgets"
  CONTEXTS_WIDGET ||--o| CONTEXTS_WIDGET_SOURCE : "if it shows data"
  CONTEXTS_REL_CONTEXT_NODE ||--o{ CONTEXTS_WIDGET_SOURCE : "shown by"
  CONTEXTS_CONTEXT {
    int id PK
    string name
    string owner_id
  }
  CONTEXTS_REL_CONTEXT_NODE {
    int id PK
    int context_id FK
    int node_id
    int node_type
    int permissions
  }
  CONTEXTS_PAGE {
    int id PK
    int context_id FK "new index"
    string page_type "startpage or grid"
    string title "new, nullable"
    string emoji "new, nullable"
    text description "new, nullable"
  }
  CONTEXTS_PAGE_CONTENT {
    int id PK
    int page_id FK
    int node_rel_id FK
    int order
  }
  CONTEXTS_WIDGET {
    int id PK
    int page_id FK "indexed"
    string type "header, text or data, defined on the server"
    string title "nullable"
    smallint grid_x
    int grid_y
    smallint grid_width
    smallint grid_height
    json settings "nullable, checked against the type schema"
  }
  CONTEXTS_WIDGET_SOURCE {
    int widget_id PK, FK
    int node_rel_id FK "indexed"
  }
Loading
  • Pages get a title, an emoji, a description and a new grid page type.
  • A widget row holds the type, an optional title and the position and size on the grid.
  • Widget settings are stored as one row per setting. The plan has about 25 widget types, and other apps may add more. A new type then needs no new table. The server checks every setting against the type’s schema on save.
  • A data widget links to the node row of its application in a separate source table. This keeps the link a real column that the server can check and clean up. It also guarantees a widget only shows data of its own application.
  • The start page rows stay as they are.

Behavior

  • Saving a page syncs its widgets by id in one transaction.
  • Deleting a page or an application deletes its widgets, settings and sources.
  • Removing a table or view from the application deletes only the source row. The widget stays and is shown as needing a new table.
  • Scheme export and import copy widgets, settings and sources, and remap the node with the mapping importContext() already builds.

API

  • GET /api/2/contexts/{contextId}/pages/{pageId} returns a grid page with its widgets.
  • POST /api/2/contexts/{contextId}/pages adds a grid page.
  • PATCH /api/2/contexts/{contextId}/pages/{pageId} changes the page or its widgets.
  • DELETE /api/2/contexts/{contextId}/pages/{pageId} deletes a grid page.
  • GET /api/2/widget-types returns the widget types and their schemas.

Reading needs read access to the application. Changes need manage access.

Activity

  1. rubenvdlinde commented on Nov 2, 2026

    @rubenvdlinde

    Thanks for the review on #3131 to #3137, @enjeck.

    You're right that the menu never got the same agreement here as pages and widgets. So here's a proposal for the menu, , with your suggestions in it.

    #3135 to #3138 are on hold until we agree, and then we rebuild them on this, as two lean PRs with a test per behaviour: the model and the API first, then export and import.

    Schema

    erDiagram
      CONTEXTS_CONTEXT ||--o{ CONTEXTS_MENU_ITEM : "has a menu"
      CONTEXTS_PAGE ||--o{ CONTEXTS_MENU_ITEM : "opened by"
      CONTEXTS_REL_CONTEXT_NODE ||--o{ CONTEXTS_MENU_ITEM : "opened by"
      CONTEXTS_MENU_ITEM ||--o{ CONTEXTS_MENU_ITEM : "group holds items, one level"
      CONTEXTS_MENU_ITEM {
        int id PK
        int context_id FK "indexed"
        int parent_id FK "nullable, a group of the same application"
        int order
        string title "nullable, falls back to the title of what it opens"
        string icon "nullable, the icons pages use"
        text description "nullable"
        int page_id FK "nullable, indexed, set for a page item"
        int node_rel_id FK "nullable, indexed, set for a table or view item"
        string url "nullable, set for a link item"
        string technical_name "unique per application, item_<id> by default"
      }
    
    Loading
    • An item opens at most one thing: a page of the application (page_id), a table or view of the application (node_rel_id, as you suggested), or a link (url, http or https only). In the API that's one target: {pageId}, {nodeRelId} or {url}, like a widget's source.
    • An item that opens nothing is a group: it only holds other items, as a section in the navigation. Groups are top-level, and only groups have children, so the menu is never more than one level deep. Nextcloud's own app navigation renders exactly this: items with children. We'd like groups because a menu of more than a handful of tables and pages gets hard to scan, but if you'd rather start flat, parent_id and groups can wait for a later step.
    • Tables, views and links: the menu box in the diagram at the top of Grid pages in applications (frontend) #3099 reads "Pages, tables, views, links", and the menu in the designs lists tables. A table or view item opens that table or view inside the application.
    • Because a table or view item points at the node row, it can only open a table or view that is already in the application, the rule we agreed for data widgets. A menu never adds tables to the application, so nobody gets access through a menu.
    • An item without a title shows the title of what it opens, so a menu follows a renamed page or table and isn't frozen in the language of whoever created it.
    • The technical name gives an item a stable address: for a link into the application that opens a menu item, and for parents on import. Like pages, it's item_<id> unless one is given.

    Behavior

    • Items are added, changed and deleted one at a time. The order and the grouping of the whole menu are saved in one call, in one transaction. No more deleting and re-inserting the whole menu: that came from saving the menu through the application API (we now instead propose a seperate API).
    • An item's target is removed with it everywhere a target can go: when a page is deleted, when a table or view leaves the application (through the application's nodes, or because the table or view itself is deleted, also when its owner is deleted), when the application is deleted, and on scheme import. Items in a deleted group move to the top level. Without foreign keys a race can still leave an item whose target is gone, so reading the menu leaves those out.
    • A new application gets one item for its start page, without a title, so it shows the start page's title. It's an ordinary item: people can retitle, move or remove it, as Grid pages in applications (frontend) #3099 describes. Applications that exist today have no items, and an application without items opens its start page, so no data migration is needed.
    • At most 200 items per application. Every item points at something inside the application, so all readers see the same menu.
    • Permission checks and the application response don't load the menu; only the menu endpoints do.
    • Scheme export writes pages and parents by technical name, and tables and views by uuid (by the table's technical name in the application once Technical name for tables and applications, CRUD routes by application and table #3094 lands). Import maps them back the way importContext() maps nodes, validates every item like the API does, and skips items whose target didn't come along, with a reason. The import preview and moving a user's data carry the menu too.

    API

    The menu gets its own endpoints, like columns: a resource of its own, filtered by application. That also keeps /api/2/contexts/{context}/{table} free for the row routes agreed in #3094.

    • GET /api/2/menu?context={contextId} returns the menu of an application, as a flat list with parentId.
    • POST /api/2/menu adds an item to the end of the menu: context, title, icon, description, target, technicalName.
    • PATCH /api/2/menu/{itemId} changes an item's title, icon, description, target or technical name. Setting a target replaces the previous one.
    • DELETE /api/2/menu/{itemId} deletes an item; the items in a deleted group move to the top level.
    • PUT /api/2/menu/order takes context and items: [{id, parentId}] in display order. It must list every item of the menu exactly once, the parent must be a group, and it either saves all of it or nothing ("changed in between, reload", like page saves). It returns the whole menu. Position and parent change only here, so two edits can't build a deeper tree or a loop.

    Reading needs read access to the application, changes need manage access, checked once through the item's application. Outsiders and items of another application get a 404, readers who try to change something a 403, and a target outside the application a 400. Create and change are rate limited like pages. The application response stays as it is on main.

    Who sees what

    Per-audience visibility of pages and menu items is a separate topic we'll open an issue for. It would hide things, not protect data, which stays with the table permissions, and nothing in this design blocks it.

    Does this match what you had in mind? In particular: removing the item when its table leaves the application (instead of keeping it marked as broken), and groups for one level of nesting.

  2. blizzz commented on Nov 3, 2026

    @blizzz
    Member

    Thank you for the proposal @rubenvdlinde!

    removing the item when its table leaves the application (instead of keeping it marked as broken)

    Yes, definitely, this is what we expect.

    and groups for one level of nesting.

    I do not see a necessity to leave it flat in the first place, probably it makes sense to take this feature in right now. On this functionality and behaviour I have a few questions:

    • when deleting resources that an item refers to, the item will also be deleted. That's good, otherwise we'd create another group if only the target was cleared. Or do I misinterpret your statement?
      • You also say there might be some condition where remnants leave around though? My expectation would be to have this dealt with in a transaction as well. Am I overseeing something? Otherwise a clean up job (can run monthly) would be needed to check for remnants, ideally we can avoid this. Or is this a case solely about items, whose parent was removed (and being pushed top level thus)?
    • In the same regard, how are empty groups envision to being handled?

    And more thoughts and questions:

    • We thave the triplet of page_id, node_rel_id and url of which only one can be set, while others have to be null. This cannot be enforce by DDL, only application logic. Not a problem per se, but something to keep in mind, especially on future work.
      • Have you considered a type and value instead of this triplet?
    • I feel unsure about using UUIDs and technical names for a similar scenario, which given both where not intended for. The UUID-natured values were meant for to re-identify resources even across instances and being able to update them. The technical name is a mutable alias that can be changed by the table owner or manager (at least how it was implemented elsewhere). Using them for URLs is nicer than UUIDs, but they are not necessarily stable, or could clash. I read Technical name for tables and applications, CRUD routes by application and table #3094 and there the plan is to create uniqueness of the technical name (not sure how wide this spans, I favour consistency, given iirc we have an exception already with uuids in context space). Not sure something else was discussed with @enjeck.
    • The route /contexts/{id}/scheme|transfer|pages

    That said, all in all it sounds round, nothing fundamental to change 👍

  3. rubenvdlinde commented on Nov 5, 2026

    @rubenvdlinde

    Thanks @blizzz, good to hear. Then groups go in right away.

    Deleting targets. You read it right: when a page, a table or view in the application, or the application itself is deleted, the items that open it are deleted too, not turned into groups. That happens in the same transaction as the delete. The case I meant was the other way round: someone adds an item for a table at the same moment another person removes that table from the application. Adding or changing an item now checks its target inside its own transaction as well. That narrows the window but doesn't close it, because there are no row locks or foreign keys. If it ever happens, the leftover row is harmless: reading the menu skips it, and it goes when the application is deleted. I don't think that's worth a cleanup job, but tell me if you'd rather have one.

    Items in a deleted group move to the top level; that's the only case where an item survives a delete.

    Empty groups. An empty group stays, so whoever manages the application can fill it again. People who can only read the application don't see it.

    The three target columns. Yes, I considered a type and a value. I'd keep the three: page_id and node_rel_id are indexed integer references, so removing the items of a deleted page or node is a plain indexed delete, and a widget's source uses the same shape. The rule that at most one is set lives in the service, is tested, and is noted on the entity for later work.

    Technical names and uuids. Agreed, so I dropped the technical name on menu items. Within an instance an item is addressed by its id. In a scheme export an item refers to its group by position in the exported menu, to tables and views by uuid (moving between instances is what they're for), and to pages by the page's technical name. Once #3094 settles how technical names work for tables in an application, we'll follow it.

    Routes. One small change from the proposal: the application id is contextId, like on the other application routes, so GET /api/2/menu?contextId=5. Your last point about /contexts/{id}/scheme|transfer|pages seems to have been cut off. What did you mean there?

  4. blizzz commented on Nov 6, 2026

    @blizzz
    Member

    If it ever happens, the leftover row is harmless: reading the menu skips it, and it goes when the application is deleted. I don't think that's worth a cleanup job, but tell me if you'd rather have one.

    As a follow up, possibly can be a repair job that just once after each Nextcloud upgrade. I am good having an issue about it, and otherwise leave it aside for now.

    Your last point about /contexts/{id}/scheme|transfer|pages seems to have been cut off. What did you mean there?

    Oh, I meant to strike that, please ignore.

  5. blizzz commented on Nov 6, 2026

    @blizzz
    Member

    Your last point about /contexts/{id}/scheme|transfer|pages seems to have been cut off. What did you mean there?

    Oh, I meant to strike that, please ignore.

    I remembered wrongly. We have contexts routes collected within: /api/2/contexts/{contextId}/. Menu related endpoints could also be in the same fashion within /api/2/contexts/{contextId}/menu.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions