{/* Settings — Developer Documentation */}
import { Meta } from "@storybook/addon-docs/blocks";
A reusable, schema-driven settings page that renders a full UI from a JSON structure. Designed for WordPress plugin settings with built-in extensibility.
import {
Settings,
formatSettingsData,
extractValues,
type SettingsElement,
type SettingsProps,
type SaveButtonRenderProps,
} from "@wedevs/plugin-ui";import { Settings, type SettingsElement } from "@wedevs/plugin-ui";
import { Button } from "@wedevs/plugin-ui";
import { __ } from "@wordpress/i18n";
const schema: SettingsElement[] = [
{
id: "general",
type: "page",
label: "General",
children: [
{
id: "basic",
type: "subpage",
label: "Basic Settings",
icon: "Settings",
children: [
{
id: "main_section",
type: "section",
label: "Main",
children: [
{
id: "site_name_field",
type: "field",
variant: "text",
label: "Site Name",
dependency_key: "site_name",
default: "",
},
],
},
],
},
],
},
];
function MySettingsPage() {
const [values, setValues] = useState({});
return (
<Settings
schema={schema}
values={values}
title="My Plugin"
onChange={(scopeId, key, value) => {
setValues((prev) => ({ ...prev, [key]: value }));
}}
onSave={(scopeId, treeValues, flatValues) => {
// treeValues: nested object from dot-separated keys
// flatValues: original flat dot-keyed values
console.log("Saving", scopeId, treeValues, flatValues);
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
{__("Save Changes", "my-plugin")}
</Button>
)}
/>
);
}The schema is a tree of SettingsElement objects. Each element has a type that determines
its role in the hierarchy.
page
├── subpage
│ ├── tab (optional)
│ │ ├── section
│ │ │ ├── subsection (optional)
│ │ │ ├── fieldgroup (optional)
│ │ │ └── field
│ │ └── field (direct under tab)
│ ├── section (direct under subpage, no tabs)
│ └── field (direct under subpage)
└── section (page without subpages)
| Type | Role | Parent(s) | Has children? |
|---|---|---|---|
page |
Top-level navigation group | Root | subpage, tab, section, field |
subpage |
Navigable menu item | page | tab, section, field |
tab |
Horizontal tab within a subpage/page | subpage, page | section, field |
section |
Visual card grouping fields | tab, subpage, page | subsection, fieldgroup, field |
subsection |
Nested group inside a section | section | fieldgroup, field |
fieldgroup |
Inline group of fields | section, subsection | field |
field |
Individual form input | section, subsection, fieldgroup, tab, subpage | No |
The top-level container. Rendered as a parent menu item in the sidebar.
{
"id": "general",
"type": "page",
"label": "General",
"icon": "Settings",
"children": []
}Pages without subpages are also supported — the page itself becomes a clickable leaf item in the sidebar:
{
"id": "notifications",
"type": "page",
"label": "Notifications",
"icon": "Bell",
"children": [
{ "id": "tab1", "type": "tab", "label": "Email", "children": [] },
{ "id": "tab2", "type": "tab", "label": "SMS", "children": [] }
]
}A navigable child under a page. Appears as a submenu item in the sidebar.
{
"id": "store",
"type": "subpage",
"label": "Store Settings",
"description": "Configure your store defaults.",
"icon": "Store",
"doc_link": "https://docs.example.com/store",
"children": []
}Horizontal tab navigation within a subpage or page.
{
"id": "store_basic",
"type": "tab",
"label": "Basic",
"children": []
}A bordered card grouping related fields. Has an optional heading and description.
{
"id": "address_section",
"type": "section",
"label": "Address Information",
"description": "Default address for your store.",
"doc_link": "https://docs.example.com/address",
"children": []
}A nested group inside a section with its own heading.
{
"id": "shipping_rates",
"type": "subsection",
"label": "Shipping Rates",
"description": "Configure per-zone rates.",
"children": []
}Groups multiple fields in a horizontal row (e.g. min/max pair).
{
"id": "price_range",
"type": "fieldgroup",
"children": [
{
"id": "min_price",
"type": "field",
"variant": "number",
"label": "Min",
"dependency_key": "min_price"
},
{
"id": "max_price",
"type": "field",
"variant": "number",
"label": "Max",
"dependency_key": "max_price"
}
]
}An individual form input. The variant determines which UI control renders.
{
"id": "store_name_field",
"type": "field",
"variant": "text",
"label": "Store Name",
"description": "The display name of your store.",
"tooltip": "Visible to customers.",
"dependency_key": "store_name",
"default": "My Store",
"placeholder": "Enter store name"
}| Variant | Control | Key Props |
|---|---|---|
text |
Text input | placeholder, default |
number |
Number input | min, max, increment, prefix, postfix |
textarea |
Multi-line textarea | placeholder, default |
select |
Dropdown select | options[], placeholder |
switch |
Toggle switch | enable_state, disable_state |
radio_capsule |
Segmented toggle | options[] |
customize_radio |
RadioCard grid | options[] (with description) |
multicheck / checkbox_group |
Checkbox list | options[] |
base_field_label |
Label only (no input) | doc_link |
html |
Raw HTML content | html_content |
Used by select, radio_capsule, customize_radio, and multicheck:
{
"options": [
{
"value": "flat",
"label": "Flat Rate",
"description": "Single rate for all."
},
{
"value": "percent",
"label": "Percentage",
"description": "Based on order total."
}
]
}{
"variant": "switch",
"dependency_key": "enable_feature",
"enable_state": { "value": "on", "title": "Enabled" },
"disable_state": { "value": "off", "title": "Disabled" }
}If your backend returns a flat array of elements (each with parent pointer fields),
use formatSettingsData() to convert it into the hierarchy the component expects.
import { Settings, formatSettingsData, extractValues } from "@wedevs/plugin-ui";
// Flat array from REST API
const flatData = [
{ id: "general", type: "page", label: "General" },
{
id: "store",
type: "subpage",
label: "Store",
page_id: "general",
icon: "Store",
},
{ id: "basic_tab", type: "tab", label: "Basic", subpage_id: "store" },
{ id: "main_section", type: "section", label: "Main", tab_id: "basic_tab" },
{
id: "name_field",
type: "field",
variant: "text",
label: "Store Name",
dependency_key: "store_name",
section_id: "main_section",
},
];
const schema = formatSettingsData(flatData);
const initialValues = extractValues(schema);Each flat element can specify its parent using these pointer fields (checked in order):
| Pointer field | Compatible parent types |
|---|---|
field_group_id |
fieldgroup |
subsection_id |
subsection, section |
section_id |
section, subsection |
tab_id |
tab |
subpage_id |
subpage, page |
page_id |
page, subpage |
The formatter checks pointers from most specific to least specific. The first match wins.
Pointers are generic — page_id can point to a subpage, and section_id can point to a subsection.
Fired whenever a field value changes.
| Parameter | Type | Description |
|---|---|---|
scopeId |
string |
Active subpage ID, or page ID if no subpage |
key |
string |
The field's dependency_key |
value |
any |
New value |
<Settings
onChange={(scopeId, key, value) => {
console.log(`[${scopeId}] ${key} = ${JSON.stringify(value)}`);
setValues((prev) => ({ ...prev, [key]: value }));
}}
/>Fired when the save button is clicked. Only receives values scoped to the active page/subpage.
| Parameter | Type | Description |
|---|---|---|
scopeId |
string |
Active subpage ID, or page ID if no subpage |
treeValues |
{'Record'} |
Nested object built from dot-separated keys (e.g. {'{"dokan":{"general":{"store_name":"..."}}}'}) |
flatValues |
{'Record'} |
Original flat dot-keyed values (e.g. {'{"dokan.general.store_name":"..."}'}) |
<Settings
onSave={async (scopeId, treeValues, flatValues) => {
await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, {
method: "POST",
body: JSON.stringify(treeValues),
});
}}
/>The scopeId follows this logic:
- If a subpage is active:
scopeId = subpage.id - If a page without subpages is active:
scopeId = page.id
This lets you save settings on a per-page/subpage basis to different API endpoints.
Since settings may be consumed by third-party WordPress plugins, the save button
must support i18n. Use renderSaveButton to inject your own button:
import { __ } from "@wordpress/i18n";
import { Settings, Button } from "@wedevs/plugin-ui";
<Settings
renderSaveButton={({ scopeId, dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
{__("Save Changes", "my-text-domain")}
</Button>
)}
/>;| Render prop | Type | Description |
|---|---|---|
scopeId |
string |
Active scope (subpage or page ID) |
dirty |
boolean |
true if any field in the scope has been modified |
onSave |
{'() => void'} |
Call this to trigger save — internally gathers scope values and calls the consumer's onSave(scopeId, treeValues, flatValues) |
The save button area only renders when onSave is provided.
For WordPress environments, pass applyFilters from @wordpress/hooks to enable
other plugins to override or replace field components:
import { applyFilters } from "@wordpress/hooks";
<Settings applyFilters={applyFilters} hookPrefix="dokan" />;Every field is wrapped with a filter hook:
{hookPrefix}_settings_{variant}_field
For example, dokan_settings_text_field receives (ReactElement, mergedElement).
import { addFilter } from "@wordpress/hooks";
// Override text fields to add a character counter
addFilter("dokan_settings_text_field", "my-plugin", (element, fieldData) => {
if (fieldData.id === "special_field") {
return <MyCustomTextField data={fieldData} />;
}
return element; // pass through for other text fields
});
// Handle completely unknown variants
addFilter("dokan_settings_default_field", "my-plugin", (element, fieldData) => {
if (fieldData.variant === "color_picker") {
return <ColorPickerField data={fieldData} />;
}
return element;
});Fields can be conditionally shown/hidden based on other field values using the
dependencies array:
{
"id": "tax_rate_field",
"type": "field",
"variant": "number",
"label": "Tax Rate (%)",
"dependency_key": "tax_rate",
"dependencies": [
{
"key": "enable_tax",
"value": true,
"comparison": "="
}
]
}This field only appears when enable_tax is true.
| Property | Type | Description |
|---|---|---|
key |
string |
dependency_key of the field to watch |
value |
any |
Expected value |
comparison |
string |
Comparison operator (=, !=, etc.) |
Fields can declare validation rules via the validations array:
{
"validations": [
{ "rules": "required", "message": "This field is required." },
{
"rules": "min_value",
"message": "Must be at least 1.",
"params": { "min": 1 }
},
{
"rules": "max_value",
"message": "Must be at most 100.",
"params": { "max": 100 }
}
]
}Supported rules: required, not_empty, min / min_value, max / max_value.
Converts a flat array of SettingsElement into a hierarchical tree.
If the data is already hierarchical (pages with children), it passes through unchanged.
import { formatSettingsData } from "@wedevs/plugin-ui";
const schema = formatSettingsData(flatApiResponse);Walks a hierarchical schema and extracts all dependency_key to value pairs into a flat object.
Useful for initializing the values prop.
import { extractValues } from "@wedevs/plugin-ui";
const initialValues = extractValues(schema);
// { store_name: 'My Store', enable_tax: true, tax_rate: 10, ... }| Prop | Type | Default | Description |
|---|---|---|---|
schema |
{'SettingsElement[]'} |
required | JSON schema (flat or hierarchical) |
values |
{'Record'} |
{'{}'} |
Current field values keyed by dependency_key |
onChange |
{'(scopeId, key, value) => void'} |
— | Called on every field change |
onSave |
{'(scopeId, treeValues, flatValues) => void'} |
— | Called on save; enables save button area |
renderSaveButton |
{'(props: SaveButtonRenderProps) => ReactNode'} |
— | Custom save button |
loading |
boolean |
false |
Show loading spinner |
title |
string |
— | Title in sidebar header |
hookPrefix |
string |
"plugin_ui" |
Prefix for filter hook names |
className |
string |
— | Class name on root element |
applyFilters |
{'(hook, value, ...args) => any'} |
identity | Filter function for extensibility |
Every interactive element has a data-testid attribute, auto-generated from the schema id:
| Selector | Element |
|---|---|
settings-root |
Root container |
settings-sidebar |
Sidebar wrapper |
settings-search |
Search input |
{'settings-menu-{id}'} |
Menu item |
settings-mobile-open |
Mobile hamburger button |
settings-mobile-close |
Mobile close button |
settings-content |
Content area |
{'settings-heading-{id}'} |
Page/subpage heading |
settings-tabs |
Tabs container |
{'settings-tab-{id}'} |
Tab button |
{'settings-section-{id}'} |
Section card |
{'settings-subsection-{id}'} |
Subsection card |
{'settings-fieldgroup-{id}'} |
Field group block |
{'settings-field-{id}'} |
Field wrapper |
{'settings-field-block-{id}'} |
Standalone field card |
{'settings-save-{scopeId}'} |
Save button area |
// Navigate to a subpage
await page.getByTestId("settings-menu-store").click();
// Change a text field
await page
.getByTestId("settings-field-store_name_field")
.locator("input")
.fill("New Name");
// Verify save button is enabled, then save
const saveArea = page.getByTestId("settings-save-store");
await expect(saveArea.locator("button")).toBeEnabled();
await saveArea.locator("button").click();- Desktop: Sidebar + content side by side
- Mobile: Sidebar becomes a slide-in drawer with hamburger toggle
- Single page: Sidebar auto-hides when there is only one navigable item
See the Settings stories in the sidebar for interactive demos:
- Default — Full hierarchical schema with all field variants
- FlatData — Flat array formatted with
formatSettingsData() - SinglePage — Auto-hidden sidebar
- MixedPages — Pages with and without subpages