Skip to content

[2.x] feat: resource schema endpoint and generic admin resource table/forms - #5324

Draft
novacuum wants to merge 104 commits into
flarum:2.xfrom
glowingblue:sh/admin-resource-schema-core
Draft

novacuum wants to merge 104 commits into
flarum:2.xfrom
glowingblue:sh/admin-resource-schema-core

Conversation

@novacuum

@novacuum novacuum commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds a resource schema endpoint and a set of generic admin components built on it: a list table and create/edit forms. Core's Users, Access Tokens and Groups admin pages now run on these components, and extensions can use them for their own resources.

RFC: this adds a public API surface. We'd like the core team's feedback on the design before it's considered for a release, since the BC promise applies from then.

Related

Server

  • GET /api/resource-schemas/{type} describes a resource to the current actor: its fields, sorts, filters, pagination and capabilities, plus a translated type label and rowAbilities (the attributes that say whether the actor may edit or delete a record, overridable with Extend\ApiResource::rowAbilities()). The shape is versioned by schemaVersion.
  • Resources opt in with Extend\ApiResource::describable(). Fields carry ->ui() hints (column, hidden, label, widget, placeholder, help, group, priority, visibleWhen, markRequired), also as fluent FlarumField methods.
  • Validation hints come from the rules as they are evaluated for the actor and each write mode (constraints, constraintsOnUpdate), so a rule that only applies to some actors never reaches others. Rule objects (Rule::in, Rule::enum, NotIn) are read; size, range, digit, comparison, not_in, accepted, distinct and format rules map to JSON-Schema-style keys; regexes are translated to JS or left to the server. Value lists built by a closure and default closures are never published; static defaults are. Custom validation messages and translated enum labels are sent along. A field whose rules can't be inspected fails closed: the server decides (requiredConditional).
  • Laravel's conditional required_* rules are sent as requiredWhen and evaluated live by the form; a rule only the server can decide is marked requiredConditional.
  • A filter describes itself through DescribesFilter (label, kind, range, multiple, required, default, negatable, enum labels). Extend\SearchDriver::describeFilter() covers filters an extension doesn't own. FiltersByRange/RangeValue give date and number range filters (from..to); users get joinedAt and lastSeenAt (the latter gated like the lastSeenAt sort).
  • users, access-tokens and groups are describable. Likes and suspend describe their user filters. Access tokens can include their user (owners' groups eager loaded) and sort by their dates.

Client

  • ResourceDefinition builds columns, inputs, filters and actions from a descriptor. Extensions change them through ItemLists and extend(). Core's definitions live at flarum/admin/resource/definitions/* and are registered in app.resources (ResourceRegistry).
  • Actions: ResourceAction declares row and bulk actions (scope, applies, confirm, destructive, input fields, run/open). They run through a BulkRunner with a confirmation modal and a result modal that groups failures by reason. Edit and delete come built in from rowAbilities.
  • List: ResourceTable and ResourceListState follow the IBM Carbon data table pattern: one toolbar (search, a Filter panel with the described filters and the search gambits as chips, refresh, a settings menu with columns and CSV export, the primary action), active filters as tags, a batch action bar that replaces the toolbar while rows are selected, sorting in the column headers, one row overflow menu, core's Pagination below the table as on 2.x's user list, and empty states. Rows no bulk action applies to can't be selected. Filters and sort are kept in the URL (?q= links to a filtered list work) and remembered per type.
  • Forms: ResourceFormModal, ResourceForm and ResourceFormState check every described constraint client-side (never stricter than the server), map server errors back to their fields, and support fieldsets, help, conditional visibility and native input attributes. InputFactory supplies the inputs.
  • Field validation hooks: a field whose form value isn't the attribute's value (a locale map, a composite input) validates it with Field.validate(value, ctx). The context gives the form, descriptor, mode, initial value, form values, includeUnchanged/dirty and ctx.check(), which runs core's standard check (with core's messages) on any part of the value. validate runs for every shown field, changed or not, so a field can check a new part of an unchanged value. It returns messages or FieldErrors ({ message, path }, where path names the part, e.g. a locale). When a save fails client-side, visible fields get onclienterrors(errors, form) with the structured errors, so a field can, for example, bring the erroring locale into view; onerrors stays server-only. form.fieldErrors(key) returns a field's current client errors. check() stays protected; validate is the extension point.
  • Fields saved by their own request: Field.isChanged(value, initial) replaces the snapshot comparison for values that aren't plain data, and Field.afterSave(model, value, ctx) runs once the record is saved (a new one has its id), e.g. to upload a file to its own endpoint; a failure keeps the change so saving again retries it.
  • Server errors: 422/403 errors are resolved to fields by their JSON pointer, decoded (~1/~0), without guessing: a field claims its key (or its snake_case column name, for Extend\Validator rules) and everything below it, plus explicit path claims in errorKeys (e.g. translationMap/de); the most specific claim wins, unmatched errors go to '*'. Each becomes { message, path, pointer, error } (path = the part below the claim, e.g. de/name), shown on the field, returned by form.fieldErrors(key) like client errors, and passed to onerrors(errors, form) by key. isServerError() tells server from client errors.
  • Bundle: admin.js stays at 144 KB gzip; the resource tables, modals and the CSV writer load on demand.
  • Pages:
    • The user list is rebuilt on ResourceTable.
    • New Access Tokens page: lists sessions and tokens and revokes them, one by one or in bulk.
    • New Groups page: create, edit and delete groups, including colour and icon, and the icon image upload from feat: upload images for group and tag icons #5363.

Breaking / behaviour changes

  • The admin user list renders as a native <table>. Its class names and data-* attributes are kept, but CSS that relies on display: grid or --columns breaks. UserListPage.headerItems() is removed; toolbar items go through actionItems() or the definition's toolbar(). The filter chips under the search are now a "Search filters" section in the Filter panel.

Fixes

  • requiredOnUpdate() was inverted. It now requires the field on update.
  • mutateDataBeforeValidation()'s return value was ignored. The mutated data is now what is validated and what is saved.
  • A group PATCH no longer has to send both names.
  • The likes liked user filter only matches posts the actor can see.
  • An unpaginated list is now requested in full instead of being capped at 20 rows.

Tests

  • PHP: 856 unit and 1459 core integration tests pass on SQLite, plus the likes, suspend, gdpr and nicknames suites; phpstan reports no errors. On MariaDB with a table prefix the core integration suite runs in 214 s of test time (was 312 s). The tests cover the descriptor and rule inference, what each actor sees in a descriptor (no stored values, settings or internals, no side effects, no query growth), range filters and their permission gating, the access token user include and the likes filter.
  • JS: 1351 tests (unit and integration) pass, and check-typings is clean. The JS descriptor fixtures are generated by the PHP schema tests, which fail when the two drift.
  • A browser click-through on a dev forum covered the three pages, the batch bar, menus, empty states, a phone width and dark mode. Still to do by hand: opening the exported CSV in a spreadsheet and checking a high-contrast theme.

Includes Task 3b: pattern flags and the delete capability.
Includes Task 6b (client-side validation) and the common parts of
Tasks 7b and 7c (column preferences, CSV, bulk runner).
Includes Tasks 7b (column toggles, CSV export) and 7c (bulk select,
bulk delete), plus the core.lib.resource and core.admin.resource
locale keys.
…delete

capabilities.delete was true for any registered actor. It now runs the
Delete endpoint's own authorization against a blank stored record, so
users need user.delete. Per-row canDelete still decides.
… and validation

Prefix helpers that built keys from a template hid them from search, so
unused or missing keys went unnoticed.
Search, then "{label}: {value}" filter dropdowns (three inline, the rest
under "More filters"), the count and actions in one row; removable
filter chips with "Clear all", Columns and Export in a second row. On
phones the filters fold into one "Filters (n)" sheet.

A boolean "No" is sent as filter[-key]=1, since filters such as
suspend's read only the negation: "Suspended: No" returned suspended
users. Chips mark filters a typed gambit overrides or conflicts with.
The user list renders its header items inside the toolbar, in priority
order, and exports display names without zero-width spaces.
…ilter

While a search or filter narrows the user list, the count reads
"16 matching users of 122" from the list's API total instead of the
forum total. A Filter's new exclude() leaves records out of a
relationship filter's menu; the users group filter excludes the guest
group, which no user is a member of.
ResourceTable's searchCoveredFilters ('text' by default, 'all', 'none')
decides which filters a gambit of the same key leaves to the search box;
a Filter's hideWhenSearchable overrides it. UserListPage exposes the
policy as searchCoveredFilters(), returning 'text' as before.
The table container scrolls sideways, which also clips vertically, so a
row's action menu under a one-row table was cut off. Open menus in the
table are now positioned fixed at their toggle (phones keep core's
sheet). The user list's Delete item dropped its Button class, which
centred it unlike the other menu items.
…covered filters by default

Extensions will build on this API, so it follows the normal backward
compatibility promise: the @experimental tags and the comments that
promised changes in a later minor are gone. ResourceTable's
searchCoveredFilters now defaults to 'all', and the user list uses it:
group and suspended are typed in the search, liked keeps its dropdown.
The descriptor read the database searcher whatever driver the site set
for the resource, so a site on an external index got filters its Index
endpoint does not run.
…future-version claims

The spec gains D12 (filter toolbar), the negation contract and the
search-driver rule, is re-baselined to the next minor after v2.0.2, and
lists its two release-note BC breaks. The plan records what was built.
The guide and CHANGELOG follow the normal BC promise.
Lists every session, remembered session and developer token for actors
with moderateAccessTokens, built on the resource table: user, type,
device, IP address and dates, sorted by last activity, filtered by user
and type, with revoke per row. The actor's current session cannot be
revoked there, and the CSV export (it holds IP addresses) is for admins
only.

access-tokens is describable, gains createdAt/lastActivityAt sorts, an
includable user relationship and type/canDelete attributes, and its user
and type filters describe themselves. ResourceTable no longer shows a
search box for a type without fulltext search.
- A resource another driver searches keeps search (that driver serves q)
  and describes no filters, instead of losing both.
- Describe closures see a request with the actor but without the
  describe request's query, body or route parameters, as documented.
- Drop the never-emitted filterable key and the reserved 'matching'
  selection mode, so neither is promised.
- loadDescriptor rejects a schemaVersion newer than the client supports.
- Mark FieldDescriber, RuleHints and the two container bindings
  @internal; the extenders are the contract.
- Pin that a search-covered filter set from the URL keeps its chip.
The extender guide is now the single reference, and documents the form
components and the access tokens page. The spec keeps decisions and
rationale (D1-D13), open items and the commits as built. The finished
plan is folded in and removed.
It required the field on create and never on update. The one caller,
gdpr's processedMode, therefore accepted an empty mode and the erasure
job failed with a 500; it is now a 422.
/admin#/groups lists the groups (badge, names, hidden) and creates,
edits and deletes them through ResourceFormModal, core's first page on
the generic form. The admin, guest and member groups (1-3) can no
longer be deleted through the API, by anyone (GroupPolicy force-deny);
core depends on them.

GroupResource is describable and serializes canEdit/canDelete; its
fields are writable on create or with edit permission, and an update
may leave the names out (they may not be emptied), so a colour change
no longer writes the translated built-in names. HiddenFilter describes
itself.

Generic additions: ui.widget values beyond 'hidden' ('color', 'icon',
or any registered input type), ui.placeholder (translated on the
server), ResourceDefinition.recordLabel() for modal titles and
confirmations (recordLabel moved to common/, the admin path re-exports
it), and unpaginated lists that request every record instead of the
first 20.
…odal

The API no longer deletes groups 1-3; the modal hid Delete only for the
admin group.
ResourceDefinition.createTitle() lets a definition name its create modal;
null keeps the generic "Create".
@novacuum
novacuum marked this pull request as draft November 2, 2026 08:57
- Relationship pickers request an unpaginated type whole (no page
  parameters), so pickers no longer stop at the first 20 groups; a type's
  pagination is read from its descriptor and memoized per session, a type
  whose descriptor can't be loaded is paged as before
- The access tokens index eager-loads user.groups when owners are included,
  removing one groups query per token owner
- floatMenus watches the dropdowns' open class with a MutationObserver
  instead of jQuery events; the user list's email toggle is Mithril state;
  AdminNav scrolls to the active item with native DOM APIs
…kes filter

- CSV export quotes every cell and guards formulas after leading
  whitespace, so a ";" in a value can no longer start a new cell that
  spreadsheet apps evaluate
- The resource descriptor publishes only static defaults and literal value
  lists; closure defaults are never evaluated, closure-built in/enum lists
  and not_in rules are left to the server (liveValidate)
- A Precognition header other than "true" (any casing, repeated) is
  refused with 400 instead of running the real write
- mutateDataBeforeValidation's data is now what is validated and saved,
  in the normal and the validate-only path
- The likes filter only matches posts the actor can see, so it no longer
  reveals likers of hidden posts

Adds security regression suites for the descriptor, validate-only
requests, list filters/sorts/includes and the admin resource UI.
…leCI fixes

The validate-only side-effect fingerprint listed tables through sqlite_master
and the descriptor write checks matched SQLite quoting without a table prefix,
so they errored on MySQL, MariaDB and PostgreSQL. Tables now come from the
schema builder, rows are compared order-independently, and the per-request
bookkeeping updates are matched in any dialect and with any prefix.
admin.js grew past the 150KB gzip budget with the resource UI. The tables
(ResourceTable, UserListTable, AccessTokenTable and the filter bar) load as
one chunk when a list page opens, the form, action and bulk result modals
when first shown, and the CSV writer when an export starts. admin.js drops
from 159KB to 144KB gzipped.
… label

Since flarum#5341 a dropdown toggle's accessible name starts with its visible
label. The active-filter count was part of that label, giving "Filter1,
Filter, 1 active"; it is now drawn from data-count in CSS.
The list footer is 2.x's page navigation again; drops the page-size select, range text and page select.
Like core's group modal, the groups page leaves the Admin group out of
the delete action, row and bulk: deleting it leaves the forum without
administrators.
A field whose form value is not the attribute's value (a locale map, a
composite input) could not take part in client-side validation: check()
handed the whole value to validateValue(), which skips an object. The
only way in was overriding the protected check().

Field.validate(value, ctx) now replaces the descriptor check for its
field. FieldValidationContext gives key, form, descriptor, mode, initial,
values (form.values()), includeUnchanged, dirty, and check(value,
descriptor?, options?), core's check of one sub-value with core's
messages. Its messages are joined into form.errors[key] like any other
client error, so rendering and errorKeys are unchanged.

Dirty rule: validate() is called for every shown, non-conditional field
whether or not its value changed, with includeUnchanged and dirty in ctx.
An unchanged value can turn invalid through another field (a new locale
row needs a name although the name field itself is untouched), which a
"dirty or opted in" gate would only cover with an extra flag. Fields
without validate() keep the descriptor check of changed values only.
A field without a descriptor can now validate too.

Client errors: save() calls a new Field.onclienterrors(errors, form) on
the visible fields when client validation fails, with the messages by
field key (as ValidationError.errors), after they landed on form.errors.
Synthesizing JsonApiErrors for onerrors was rejected: existing onerrors
implementers read pointers as server pointers (e.g. a locale switcher
maps /data/attributes/{field} to the base row), so a client error on a
non-base locale would send them to the wrong place, and they could not
tell client from server errors.

check() stays protected; its docblock names Field.validate as the
extension point.
mapErrors() now resolves each 422/403 JSON:API error to a ResolvedError
{ message, path?, pointer?, error }, which extends FieldError, so client
and server errors share one shape.

Matching rules, on the pointer below /data/attributes or
/data/relationships (a relationship's /data tail dropped):
- pointer tokens are decoded first (~1 -> /, ~0 -> ~);
- a field claims its key, its snake_case key (kept: Extend\Validator
  rules answer with column names) and each of its errorKeys;
- errorKeys are paths (escaped as in a JSON pointer), not single segment
  names; a claim matches the pointer equal to it or below it, on whole
  segments, and the longest claim across all fields wins;
- path is the decoded rest below the claim, '/'-joined, absent when the
  pointer names the claim itself;
- unmatched errors land on '*'. A '*' in errorKeys is a literal segment.
The first-/last-segment guessing is gone: /data/attributes/meta/url no
longer lands on `url`, and a locale code can no longer collide with a
field key.

Server errors are stored with the client ones, so form.fieldErrors(key)
returns them too; validateField() still leaves a server error alone
until a client error replaces it or the next save clears it.

Field.onerrors(errors, form) now gets Record<string, ResolvedError[]>,
mirroring onclienterrors(). Two hooks rather than one with a source
argument: client and server errors stay distinguishable by which hook
fires (and by isServerError()), and each hook's signature says what it
gets. pointerPath() exposes a pointer's decoded segments for fields that
need more than the path.
The counterpart of pointerPath(): escapes each segment as a JSON pointer
token, so fields can claim paths with '/' or '~' in a segment (e.g. a
locale or map key) without escaping by hand.
The test failed on some runners: Laravel's active_url resolves the host,
and a resolver that answers for '127.0.0.1.' let the loopback avatar URL
through, so the user was created without an avatar (201, not 422).

- An IP literal is now checked against isAllowedIp() before any DNS
  lookup, so a loopback or reserved address is always a 422.
- The nonexistent host uses the reserved .invalid TLD (RFC 6761).
This reverts commit 1674e7c. The flaky avatar URL test is not caused by
this branch; it gets a proper fix in its own PR against 2.x.
inputResolvers() and filterResolvers() still return a fresh ItemList on
every call, so extend()-ing them never mutates state shared with another
definition. But fieldInput() and filterInput() rebuilt the list for each
field and filter. They now take the list as an optional parameter
(defaulting to a fresh build), and fields()/filters() build it once per
pass and pass it through.

filters() no longer builds the whole fields() registry: it reads the
same-named field descriptor directly for the input, the negatable
inference and the label (filter label, else the descriptor's
server-translated label, else the humanized key).
@novacuum
novacuum requested a review from imorland November 3, 2026 12:55
…ma-core

# Conflicts:
#	framework/core/src/Api/Resource/GroupResource.php
Field.afterSave() saves what the record's request does not carry, such as an
upload to its own endpoint, once the record exists; Field.isChanged() compares
form values that are not plain data.
The groups page's form uploads or removes a group's icon image through core's
IconImageInput and IconImageState, once the group is saved, as the group modal
does. A form whose only change is saved through its own request does not save
the record again.

The built-in groups can't be deleted (flarum#5394), so deletion follows canDelete
alone; the groups descriptor fixture now has the icon image fields, read-only
and without a column.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants