Skip to content

Add object forms for post meta boxes, user profiles and term edit screens - #34

Merged
ahegyes merged 15 commits into
trunkfrom
feat/object-forms
Oct 20, 2026
Merged

ahegyes merged 15 commits into
trunkfrom
feat/object-forms

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

The settings package could only edit options. A plugin that adds fields to a post's edit screen, a user's profile or a term's edit screen had to add the box or section, print and verify a nonce, lay out rows, read the stored values, sanitize the submission and report rejections itself.

  • ObjectForm takes an id, a definition closure evaluated after init on first use (a title, the capability the form requires and its fields, as a list or a closure given the object id), a host and an optional store, and a plugin calls its register_hooks() itself, with or without the kernel. Other plugins add fields through the {id}_fields filter.
  • A form shows for an object only to a user who may edit it and holds the definition's capability. It prints a nonce bound to the object and one row per field through Forms::render_control(), each control posting under the form's id with the stored value or else the field's default.
  • On save, the form verifies the nonce, the object capability and the definition's capability through Forms::verify_object_form(), passes the unslashed submission through Forms::sanitize_fields() and writes the accepted values to its store:
    • a field the form did not post, because it was not rendered or is disabled, keeps its value, and a posted key outside the definition is ignored;
    • quick edit, bulk edit, REST, cron and autosave carry no form nonce and store nothing, and a published post's Preview saves a revision, which the box skips;
    • the form saves once per object and request, so a nested update of the object neither saves twice nor reports twice.
  • Each rejection keeps the field's stored value and is kept for the user's next admin screen outside the block editor, where it shows once as a notice that names the field.
  • ObjectFormHostInterface decides where a form shows and when it saves, and ObjectStoreInterface where its values live:
    • PostMetaBox adds a meta box to posts of the given types, in the classic editor and the block editor, with stacked rows that fit a side box;
    • UserProfile adds a section to the profile screens, the user's own and another user's;
    • TermForm adds a section to the edit screens of terms of the given taxonomies;
    • MetadataStore keeps the values as the object's meta, telling a stored empty value from a missing key and keeping backslashes.
  • On post screens the form's nonce is refreshed with WordPress's own heartbeat nonces, so an edit screen left open past the nonce lifetime still saves the form.

AGENTS.md records the file map, that an object form field's validate runs only on its form, that a rejection saved from the block editor waits for the next classic screen, and that the profile and the block editor clear a stored number the browser cannot parse.

Affected packages

  • wp-framework-bootstrap
  • wp-framework-shared
  • wp-framework-core
  • wp-framework-settings
  • wp-framework-woocommerce

Checklist

  • Tests added or updated:
    • Integration: tests/Integration/Settings/PostMetaBoxTest.php (52 rows), UserProfileTest.php (10) and TermFormTest.php (8) drive WordPress's own post, profile, term, list and admin-ajax screens, each request in its own PHP process, with forms posted back as a browser serializes them; MetadataStoreTest.php holds 9.
  • composer quality-check passes locally (Unit 204, Integration 937), plus composer test:integration:multisite (29) and PHP 7.4 php -l over packages/bootstrap and the woocommerce uninstall files
  • Public API changes are documented in the affected package's README.md (the package READMEs stay pointer-form; AGENTS.md carries the file map)
  • Breaking changes are explicitly justified below (none: every class is new)

Object forms need a store that reads only the keys an object has, so a stored empty string or empty list never reads as missing, and that writes values as the form accepted them. WordPress's metadata API unslashes every value it is given, so a value written as is loses its backslashes.

MetadataStoreTest runs each row for post, user and term meta:
- only the keys the object has come back, with a stored empty string and empty list among them;
- text and list values keep their backslashes;
- a stored empty string and empty list read back as stored.

Nothing was outdated, so this branch has no package update: composer outdated --direct and npm outdated listed nothing, composer update and npm update left both locks unchanged, and the configs workflows already pin configs trunk 6302f1f.

Assisted-by: Claude Code:claude-opus-5-5
Object forms had nowhere to read and write their values. A form that read stored meta with get_metadata() alone could not tell a stored empty value from a missing key, and one that wrote through update_metadata() lost every backslash, because the metadata API unslashes what it is given.

ObjectStoreInterface reads the values of the keys an object has and writes values for an object in one call, so a store over another API can write once per save. MetadataStore keeps them as the object's metadata of a given type: it reads only keys metadata_exists() finds and slashes each value before update_metadata().

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no form for an object's own data, so a plugin that adds fields to a post's edit screen would wire the meta box, its nonce, its rows and the stored values itself.

PostMetaBoxTest drives WordPress's own post.php in the request runner, with the form registered by tests/Fixtures/Settings/acme-object-forms.php on plugins_loaded:
- an invalid form id throws and every v1 key is accepted; a host serves one form;
- the definition is evaluated once on the edit screen and never elsewhere;
- the box shows, titled, with the form's nonce, on its post types only and in its context, and not for a user lacking the form's capability;
- a single control is labelled by its row, and a radio, checkboxes or a checkbox with a desc is named by the row's visible legend;
- controls carry the stored value, a stored empty string or list included, or else the default;
- per-object fields receive the post id, contributed fields render, and a custom control receives its value, name and id;
- titles print as text, an untitled field is labelled by its id, and fields without an id or a control print no row.

The fixture serves every host of later rows, a recording store and a nested post update.

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no form for an object's own data, so each plugin that adds fields to a post's edit screen would add the meta box, print a nonce, lay out rows, read the stored values and tell a stored empty value from a missing one on its own.

ObjectForm takes an id, a definition closure evaluated on first use after init, a host and an optional store. A form shows for an object only when its fields include one with an id and the current user holds both the host's object capability on it and the definition's capability. It prints a nonce bound to the object and one row per field through the host, each control posting under the form's id with the stored value or else the field's default. A radio, checkboxes or a checkbox with a desc draws no legend of its own, because its row names it, and the fields of one object come from a list or a closure given the object id, filtered through {id}_fields. A rejection that a later save keeps prints once on the user's next admin screen outside the block editor.

ObjectFormHostInterface is the host's side: its hooks, the capability that edits one of its objects, its default store and the layout of the rows. PostMetaBox adds the box on add_meta_boxes for posts of its types, in its context and with its priority, with the rows stacked in the markup of WordPress's own Page Attributes box, so they fit a side box too.

One test row is corrected: PHP's DOM supports no :scope selector.

Assisted-by: Claude Code:claude-opus-5-5
A post meta box form rendered but never saved: the classic edit form's Update stored nothing, and nothing would report a rejected field or keep the edit screen's other saves away from the form's fields.

PostMetaBoxTest posts the edit form back through post.php as a browser serializes it:
- Update stores the accepted values under their field ids, unslashed, with validate's return, an unchecked checkbox as no and cleared lists as empty lists, and validate receives the unslashed submission alone;
- an unposted field and a disabled one keep their values under a tampered post, and a posted key outside the definition is ignored;
- a worded, a silent and a membership rejection keep the value and show once, as text, on the screen the save redirects to, and not on the one after;
- post-new.php renders the defaults and the first Publish stores them;
- a nested update of the same post inside save_post validates and reports once, and a custom store receives the reads and the writes instead of post meta.

Eight rows prove that other saves store nothing: a nonce minted for another post or none, a user without the form's capability, quick edit, bulk edit, an update in code without the form's nonce, a published post's Preview and a draft's heartbeat autosave. They pass already and fail once saving ignores them. One row in this commit needed the form's own empty wp-preview input replaced rather than added to.

Assisted-by: Claude Code:claude-opus-5-5
A post meta box form rendered but never saved, so Update kept every field as it was and a rejected value had no way to reach the user.

PostMetaBox hooks save_post for posts of its types, which leaves out revisions and autosaves, and ObjectForm::save() stores the form's part of the submission:
- a save that carries no form nonce returns before the definition is evaluated, and the rest needs the nonce bound to the object, the object capability and the definition's capability, through Forms::verify_object_form(), so quick edit, bulk edit, REST, cron, Preview and autosave store nothing;
- the unslashed submission under the form's id goes through Forms::sanitize_fields(), and the accepted values go to the store in one call;
- each rejection's message, worded as OptionsPage words it and naming the field by its title or id, is appended to the user's {id}_rejections option on this site, slashed so that it keeps its backslashes, and prints on the next admin screen;
- the form saves once per object and request, because a save_post handler that updates the post again would store and report twice.

Two test rows are corrected to tamper under the form's own array, the only shape PHP parses as the form's fields.

Assisted-by: Claude Code:claude-opus-5-5
A post edit screen left open past the nonce lifetime saves the post with the refreshed core nonce but drops the form's fields, because WordPress's heartbeat refreshes only core's own nonces. Nothing proved that the block editor's meta box save stores the form either, or where its rejections end up.

PostMetaBoxTest adds:
- the block editor's meta box save, posted as its requestMetaBoxUpdates() posts the base form and the box's forms to the meta box loader, stores unslashed values and an unchecked checkbox as the classic save does;
- its rejection keeps the value, stays pending through the edit screen the save is redirected to, and shows on the next classic admin screen;
- a heartbeat that refreshes nonces, for the classic editor and for the block editor's meta box loader, returns the form's nonce for its post beside core's _wpnonce, and returns none for a user without the form's capability or for a post of another type;
- the block editor's screen prints one script that copies the refreshed form nonce into its field, and the classic screen prints none, because core's own script sets every refreshed nonce there.

The two block editor rows and the refusal row pass already: the meta box save runs through post.php, rejections skip block editor screens, and nothing yet adds the form's nonce.

Assisted-by: Claude Code:claude-opus-5-5
The form's nonce verifies for 12 to 24 hours, while WordPress's heartbeat keeps core's nonces of an open post edit screen fresh. Past that, Update saved the post through the refreshed core nonce and silently dropped every change in the form.

ObjectForm::get_refreshed_nonces() returns the form's nonce for an object, keyed by its field's HTML id, for a user who may edit the object with the form. PostMetaBox adds it on wp_refresh_nonces, after core, to the replace maps core fills for a post of the box's types: the classic editor's wp-refresh-post-nonces, whose script sets every field the map names, and the block editor's wp-refresh-metabox-loader-nonces, whose script sets only core's own. On the block editor's screen the box therefore adds a one-line script to heartbeat that copies the form's refreshed nonce into its field.

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no form for a user's own data, so a plugin that adds fields to the profile screens would print their section, nonce and rows and save them itself.

UserProfileTest drives WordPress's own profile.php and user-edit.php in the request runner:
- the host renders on show_user_profile and edit_user_profile and saves on personal_options_update and edit_user_profile_update, and serves one form;
- both screens show the form as a titled section of form-table rows after its nonce, a single control labelled by its row header and a group named by a screen-reader legend, with the stored value, a stored empty string included, or else the default;
- both screens save the accepted fields, unslashed and as lists, keep a rejected field's value and show its rejection once after the redirect;
- when core rejects its own profile field, the page re-renders in the same response with the form's rejection, and the form's accepted fields are stored;
- a user editing themself without the form's capability gets no section, and a post forged with a nonce from a wider definition stores nothing;
- an update in code without the form's nonce stores nothing.

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no host for a user's own data, so object forms could only edit posts.

UserProfile prints the form's section on show_user_profile and edit_user_profile, a heading and a form-table whose single controls are labelled by their row headers and whose groups are named by a screen-reader legend around the form's legendless control, as WordPress's own option rows are. It saves on personal_options_update and edit_user_profile_update, which run after WordPress checks its profile nonce and edit_user and before it validates its own fields, as WooCommerce's customer fields do, and edits users through edit_user and user meta.

One test row is corrected: WooCommerce sends a subscriber away from wp-admin, so the row that edits one's own profile without the form's capability uses a contributor.

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no host for a term's own data, so object forms could not edit categories, tags or a plugin's taxonomies.

TermFormTest drives WordPress's own term.php and edit-tags.php in the request runner:
- the host renders on {taxonomy}_edit_form and saves on edited_{taxonomy} for each of its taxonomies, and serves one form;
- the edit screen of a term of its taxonomies shows the form after WordPress's own fields as a titled section of form-table rows after its nonce, single controls labelled by their row headers and groups named by a screen-reader legend, while another taxonomy's shows nothing;
- controls carry the stored value or else the default, and a user lacking the form's capability gets no section;
- Update stores the accepted fields unslashed, keeps a rejected field's value and shows its rejection once after the redirect;
- quick edit and an update in code store nothing.

Assisted-by: Claude Code:claude-opus-5-5
The settings package had no host for a term's own data, so object forms could not edit categories, tags or a plugin's taxonomies.

TermForm prints the form's section on {taxonomy}_edit_form for each of its taxonomies, after WordPress's own fields and before Update, in the profile's layout: a heading and a form-table whose groups a screen-reader legend names. It saves on edited_{taxonomy}, which runs after WordPress checks its update-tag nonce and edit_term, and edits terms through edit_term and term meta. The screen that adds a term is left out, because it creates the term over admin-ajax without a reload, so no term exists yet to bind the form's nonce to.

Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md's file map listed only Forms and OptionsPage for the settings package, convention 3 named only the two older helpers a component's register_hooks() may construct, and the limitations said nothing about where object forms validate or report.

The file map lists ObjectForm with its host and store interfaces, the three hosts and MetadataStore, and convention 3 names ObjectForm beside OptionsPage and ProductDataTab. Limitation 6 adds that an object form field's validate runs only on its form, so REST, imports, meta writes and, for a key without a leading underscore, core's Custom Fields box store what they are given. Limitation 7 adds that ObjectForm reports a rejection on the next admin screen outside the block editor, and that the profile and the block editor, which never validate the form, clear a stored number when the browser cannot parse the entry.

Assisted-by: Claude Code:claude-opus-5-5
No row put a backslash into a rejection message, so storing the messages without wp_slash(), which strips the backslashes from a field title such as \o/ when WordPress unslashes the user option, went unnoticed: a probe that dropped the slashing left every row green.

The worded and silent rejection rows now title their field with a backslash, and both fail against the unslashed write.

Assisted-by: Claude Code:claude-opus-5-5
The row that proves an unposted field keeps its value posted that field anyway: the browser-serialized form carried the stored value, so the row still passed when Forms::sanitize_fields() read a missing field as cleared.

The save helper now also removes one key of the form's array, after checking that the form posted it, and the row removes the field it claims stays unposted. Against a sanitize_fields() that reads a missing field as empty the row fails; against the real one it passes.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Feat/object forms Add object forms for post meta boxes, user profiles and term edit screens Oct 20, 2026
@ahegyes
ahegyes merged commit 02115fe into trunk Oct 20, 2026
16 checks passed
@ahegyes
ahegyes deleted the feat/object-forms branch October 20, 2026 11:25
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.

1 participant