Repository navigation
Conversation
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
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).
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Related
unique, closures, custom rule objects) follows in this UI once it lands.canDeletefrom the server.CreateTestavatar case on CI).Server
GET /api/resource-schemas/{type}describes a resource to the current actor: its fields, sorts, filters, pagination and capabilities, plus a translated typelabelandrowAbilities(the attributes that say whether the actor may edit or delete a record, overridable withExtend\ApiResource::rowAbilities()). The shape is versioned byschemaVersion.Extend\ApiResource::describable(). Fields carry->ui()hints (column, hidden, label, widget, placeholder, help, group, priority,visibleWhen,markRequired), also as fluentFlarumFieldmethods.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,distinctand format rules map to JSON-Schema-style keys; regexes are translated to JS or left to the server. Value lists built by a closure anddefaultclosures 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).required_*rules are sent asrequiredWhenand evaluated live by the form; a rule only the server can decide is markedrequiredConditional.DescribesFilter(label, kind,range,multiple,required,default,negatable, enum labels).Extend\SearchDriver::describeFilter()covers filters an extension doesn't own.FiltersByRange/RangeValuegive date and number range filters (from..to); users getjoinedAtandlastSeenAt(the latter gated like thelastSeenAtsort).users,access-tokensandgroupsare describable. Likes and suspend describe their user filters. Access tokens can include theiruser(owners' groups eager loaded) and sort by their dates.Client
ResourceDefinitionbuilds columns, inputs, filters and actions from a descriptor. Extensions change them through ItemLists andextend(). Core's definitions live atflarum/admin/resource/definitions/*and are registered inapp.resources(ResourceRegistry).ResourceActiondeclares row and bulk actions (scope,applies,confirm,destructive, inputfields,run/open). They run through aBulkRunnerwith a confirmation modal and a result modal that groups failures by reason. Edit and delete come built in fromrowAbilities.ResourceTableandResourceListStatefollow 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'sPaginationbelow 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.ResourceFormModal,ResourceFormandResourceFormStatecheck 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.InputFactorysupplies the inputs.Field.validate(value, ctx). The context gives the form, descriptor, mode, initial value, form values,includeUnchanged/dirtyandctx.check(), which runs core's standard check (with core's messages) on any part of the value.validateruns for every shown field, changed or not, so a field can check a new part of an unchanged value. It returns messages orFieldErrors ({ message, path }, wherepathnames the part, e.g. a locale). When a save fails client-side, visible fields getonclienterrors(errors, form)with the structured errors, so a field can, for example, bring the erroring locale into view;onerrorsstays server-only.form.fieldErrors(key)returns a field's current client errors.check()stays protected;validateis the extension point.Field.isChanged(value, initial)replaces the snapshot comparison for values that aren't plain data, andField.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.~1/~0), without guessing: a field claims its key (or its snake_case column name, forExtend\Validatorrules) and everything below it, plus explicit path claims inerrorKeys(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 byform.fieldErrors(key)like client errors, and passed toonerrors(errors, form)by key.isServerError()tells server from client errors.admin.jsstays at 144 KB gzip; the resource tables, modals and the CSV writer load on demand.ResourceTable.Breaking / behaviour changes
<table>. Its class names anddata-*attributes are kept, but CSS that relies ondisplay: gridor--columnsbreaks.UserListPage.headerItems()is removed; toolbar items go throughactionItems()or the definition'stoolbar(). 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.PATCHno longer has to send both names.likeduser filter only matches posts the actor can see.Tests
userinclude and the likes filter.check-typingsis clean. The JS descriptor fixtures are generated by the PHP schema tests, which fail when the two drift.