Skip to content

Latest commit

 

History

History
978 lines (841 loc) · 21.8 KB

File metadata and controls

978 lines (841 loc) · 21.8 KB

{/* Settings — Developer Documentation */}

import { Meta } from "@storybook/addon-docs/blocks";

Settings Component

A reusable, schema-driven settings page that renders a full UI from a JSON structure. Designed for WordPress plugin settings with built-in extensibility.


Installation

import {
  Settings,
  formatSettingsData,
  extractValues,
  type SettingsElement,
  type SettingsProps,
  type SaveButtonRenderProps,
} from "@wedevs/plugin-ui";

Quick Start

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>
      )}
    />
  );
}

Schema Structure

The schema is a tree of SettingsElement objects. Each element has a type that determines its role in the hierarchy.

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)

Element Types

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

Schema Definitions

Page

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": [] }
  ]
}

Subpage

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": []
}

Tab

Horizontal tab navigation within a subpage or page.

{
  "id": "store_basic",
  "type": "tab",
  "label": "Basic",
  "children": []
}

Section

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": []
}

Subsection

A nested group inside a section with its own heading.

{
  "id": "shipping_rates",
  "type": "subsection",
  "label": "Shipping Rates",
  "description": "Configure per-zone rates.",
  "children": []
}

Field Group

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"
    }
  ]
}

Field

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"
}

Field Variants

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

Options format

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."
    }
  ]
}

Switch with custom states

{
  "variant": "switch",
  "dependency_key": "enable_feature",
  "enable_state": { "value": "on", "title": "Enabled" },
  "disable_state": { "value": "off", "title": "Disabled" }
}

Flat Data Format

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);

Parent pointer fields

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 genericpage_id can point to a subpage, and section_id can point to a subsection.


Events

onChange(scopeId, key, value)

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 }));
  }}
/>

onSave(scopeId, treeValues, flatValues)

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),
    });
  }}
/>

Scope ID resolution

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.


Custom Save Button

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.


Extensibility (applyFilters)

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).

Registering a custom field type

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;
});

Dependencies

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.

Dependency properties

Property Type Description
key string dependency_key of the field to watch
value any Expected value
comparison string Comparison operator (=, !=, etc.)

Validation

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.


Utility Functions

formatSettingsData(data)

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);

extractValues(schema)

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, ... }

Props Reference

Settings

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

E2E Test Selectors

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

Example (Playwright)

// 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();

Responsive Behavior

  • 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

Live Examples

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