Repository navigation
Add the WooCommerce product data tab and ProductMeta::get_value() - #33
Merged
Merged
Conversation
composer outdated --direct and npm outdated list nothing, so the refresh moves only roave/security-advisories to its latest advisory set, and package-lock.json is unchanged. The configs workflows already pin 6302f1f, configs' trunk. Assisted-by: Claude Code:claude-opus-5-5
The woocommerce package had no read for product field values, so every port would repeat the expression that tells a stored empty value from a missing one, and a panel that read through WooCommerce's view filter would save the filtered value back.
ProductMetaTest reads real products saved through WooCommerce CRUD:
- a product without the meta reads as the fallback, an empty string unless one is given;
- a stored text, empty string, empty list or list reads as stored, never as the fallback;
- the view context applies WooCommerce's woocommerce_product_get_{key} filter, and the edit context returns the stored value.
ProductFixturesTrait creates the products and deletes them, with WooCommerce's stored meta box errors, after each test.
Assisted-by: Claude Code:claude-opus-5-5
Ports and the product data panel need one read for a product field's value: the stored meta, or a fallback only when the product has no meta under the key, so a stored empty string or empty list is never mistaken for a missing value.
ProductMeta is a final class with a protected constructor, after its subject, the product's meta. get_value() takes WooCommerce's read context: the view default applies the woocommerce_product_get_{key} filter, as WooCommerce's getters do on the storefront, and edit returns the stored value, which the panel needs so an unchanged save never writes a filtered value back. The fallback parameter is not named default, which WordPress-Core's PHPCS rejects as a reserved keyword.
Assisted-by: Claude Code:claude-opus-5-5
The woocommerce package had no product data tab, so a plugin could not add a tab to WooCommerce's product data box whose fields share the framework's field contract. ProductDataTabTest drives WooCommerce's classic product edit screen, post.php and post-new.php, each request in its own PHP process, with the tab built by a fixture on plugins_loaded: - the constructor rejects invalid ids and accepts every v1 key, and the definition is not evaluated until the edit screen needs it; - the tab carries its label and classes and sits by its priority among WooCommerce's tabs, 65 by default, and its panel holds each group's fields; - a product predating its fields renders their defaults, while a stored empty string and a stored empty list render as stored; - a field whose name differs from its id renders the value stored under its name, and a field renders its stored value, not one WooCommerce's view filter changed; - a custom type renders its control through its render callable inside WooCommerce's row, and a checkboxes group renders in a fieldset named by its label, with its description; - a multiselect posts an empty value before its choices, an enabled checkbox posts no when unchecked, and a disabled checkbox, by custom attribute or top-level key, renders disabled without that hidden input; - an unlabelled field is labelled by its id, and an unknown type without render prints nothing; - an unsupported product shows neither tab nor panel, a per-product field list and a group from the groups filter receive the product being edited, and a new product renders the tab with its defaults; - labels and values are escaped. Assisted-by: Claude Code:claude-opus-5-5
A plugin can now add a tab to WooCommerce's product data box: ProductDataTab takes an id and a definition closure, evaluated after init on first use, and the plugin calls its register_hooks() itself, with or without the kernel.
- The tab is added on woocommerce_product_data_tabs with its label, classes and priority, 65 by default, and its panel is printed on woocommerce_product_data_panels, for the product being edited when the definition's supports callable accepts it.
- Each group is an options group whose fields come from a list or from a closure given the product, and other plugins add groups through the {id}_groups filter, which also receives the product.
- Fields take woocommerce_wp_* arguments and render through WooCommerce's own functions with the value ProductMeta::get_value() reads in the edit context under the field's name or id without brackets, so a stored empty value is never shown as the default and a view filter's value is never shown.
- An enabled checkbox posts no when unchecked, and a disabled one, by custom attribute or top-level key, renders disabled without that hidden input; a multiselect posts an empty value before its choices.
- A field with a render callable prints its control inside WooCommerce's labelled row, and a checkboxes group prints Forms' choices in a fieldset whose legend is the label.
- An unlabelled field is labelled by its id, because WooCommerce reads the label unguarded; an unknown type without render prints nothing.
The multiselect test row looks for the select inside WooCommerce's row after the hidden input, since WooCommerce wraps the select in its own paragraph.
Assisted-by: Claude Code:claude-opus-5-5
The product data tab rendered its fields but saved nothing, so a product's field values could not change through its panel. ProductDataTabTest posts the classic product edit form as its Update and Publish buttons submit it, each request in its own PHP process: - registering the tab hooks the save onto the product WooCommerce is about to save, and the product is saved once; - an unchanged save stores the defaults of a product predating its fields, keeps stored empty values, and keeps the stored value under a view filter; - a field saves under its name without brackets, not its id, and a v1 meta key round-trips; - a field without validate is sanitized by its type, submissions are stored unslashed on a new and an existing key, and validate receives the unslashed submission alone and its return value is stored; - a rejected choice and a validate rejection keep the stored value and show the message once, as text, on the next page, where WooCommerce prints its meta box errors; - an unchecked checkbox stores no, a disabled checkbox keeps its value even when a value is posted, an unposted field keeps its value, and a posted key outside the definition is never stored; - a multiselect and a checkboxes group store exactly their choices, an empty list when none is chosen, and a checkboxes group rejects an unknown choice; - a custom type stores its validate's return value and is rejected without validate; - an unsupported product never saves the tab's fields, per-product field lists and contributed groups receive the product being saved, and a new product saves its fields when published. Assisted-by: Claude Code:claude-opus-5-5
The panel rendered its fields but nothing saved them. save_fields() runs on woocommerce_admin_process_product_object, which WooCommerce fires after it verified its meta box nonce and the user's edit_post capability, and sets the accepted values on the product it is about to save, so the tab adds no save of its own: - for a product the tab supports, it reads the unslashed submission of each field under its name or id without brackets, and passes the fields present in it to Forms::sanitize_fields(); - a field the form did not post, because it was not rendered or is disabled, keeps its stored value, and a posted key outside the definition is ignored; - a rejected field keeps its stored value and adds a WooCommerce meta box error that names it by its label, or its id, escaped, because WooCommerce prints those errors through wp_kses_post(). WooCommerce's stub types update_meta_data()'s value as a string or an array, while validate may return any value WooCommerce's data store serializes, so that argument check is ignored by name. Three test rows are corrected: the field helper let its default label override a given one; WordPress refuses to publish a new product with no title or content, so the publish row sets a title; and WooCommerce itself saves the product twice on Update, so the save row compares with WooCommerce's own saves and the fixture notes any save made while WooCommerce processes the form's product. Assisted-by: Claude Code:claude-opus-5-5
The file map still named a placeholder for the product field read, and the limitations did not say where a product field's validate runs or where the panel reports a rejection. The map names ProductDataTab and ProductMeta. Limitation 6 adds that a product field's validate runs only on the classic product edit form, so WC REST meta_data, CSV import and CRUD writes store what they are given. Limitation 7 adds that the panel's row around a custom-type or checkboxes field imitates WooCommerce's, and that ProductDataTab, like OptionsPage, reports a rejection in the page's notices. Assisted-by: Claude Code:claude-opus-5-5
woocommerce_wp_text_input() prints a number input without a step, so the browser applied its default step of 1: a stored decimal such as 1.25 made the input invalid, and the browser refused to submit the whole product edit form until the user changed it. A number field now carries step="any" unless its custom attributes set a step, as Forms' own number inputs do. The class docblock also says why render returns inline content: the panel's row is a paragraph. Assisted-by: Claude Code:claude-opus-5-5
woocommerce_wp_text_input() prints the value through esc_attr(), which leaves existing entities as they are. A stored "Use <b>bold</b> & more" reached the browser as markup and an ampersand, so clicking Update posted "Use <b>bold</b> & more", and the text sanitizer stored "Use bold & more". Text, number, email and url fields now encode the value's ampersands before WooCommerce escapes it, as Forms does for its own attributes, so the browser shows and posts the stored text. Textareas need nothing, because esc_textarea() encodes every ampersand. The new row failed against the previous code. Assisted-by: Claude Code:claude-opus-5-5
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.
Summary
The woocommerce package had no product surface. A plugin could not add a tab to WooCommerce's product data box whose fields share the framework's field contract, and every port would repeat the read that tells a stored empty value from a missing one.
ProductData\ProductMeta::get_value( $product, $key, $fallback = '', $context = 'view' )returns the product's meta under the key, or the fallback only when the product has no meta under it, so a stored''or empty list is never read as the fallback. The view context applies WooCommerce'swoocommerce_product_get_{key}filter; the edit context returns the stored value.ProductData\ProductDataTabtakes an id and a definition closure, evaluated afteriniton first use, and a plugin calls itsregister_hooks()itself, with or without the kernel. It adds the tab and its panel for the product being edited when the definition'ssupportscallable accepts it, with groups of fields from a list or a closure given the product, and other plugins add groups through the{id}_groupsfilter.woocommerce_wp_*arguments and render through WooCommerce's own functions with the stored value read in the edit context under the field's name or id without brackets, so a filtered value is never saved back. A field with arendercallable and acheckboxesgroup print in WooCommerce's row aroundForms::render_control().Forms::sanitize_fields();nowhen unchecked, and a multiselect orcheckboxesgroup stores an empty list when cleared;wp_kses_post().AGENTS.md records the file map, that a product field's
validateruns only on the classic product edit form, and that the panel reports a rejection in the page's notices.Affected packages
wp-framework-bootstrapwp-framework-sharedwp-framework-corewp-framework-settingswp-framework-woocommerceChecklist
tests/Integration/WooCommerce/ProductDataTabTest.phpholds 65 rows that drive WordPress's ownpost.phpandpost-new.php, each request in its own PHP process, with the edit form posted back as a browser serializes it;ProductMetaTestholds 6.composer quality-checkpasses locally (Unit 204, Integration 858), pluscomposer test:integration:multisite(29) and PHP 7.4php -loverpackages/bootstrapand the woocommerce uninstall filesREADME.md(the package READMEs stay pointer-form; AGENTS.md carries the file map)