Skip to content

Latest commit

 

History

History
1011 lines (868 loc) · 23.1 KB

File metadata and controls

1011 lines (868 loc) · 23.1 KB

{/* Settings — Developer Documentation */}

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

Settings Component

Note (DOKAN_NEXT_MAJOR): The dependency_key attribute has been removed from the settings schema. Use the field's id directly. show_if and dependencies rule keys are now flat field ids — dot-paths are no longer supported. See the dependency_key cleanup plan for migration context.

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",
                type: "field",
                variant: "text",
                label: "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: object keyed by field id
        // flatValues: same shape — flat id-keyed values
        console.log("Saving", scopeId, treeValues, flatValues);
      }}
      renderSaveButton={({ dirty, hasErrors, onSave }) => (
        <Button onClick={onSave} disabled={!dirty || hasErrors}>
          {__("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"
    },
    {
      "id": "max_price",
      "type": "field",
      "variant": "number",
      "label": "Max"
    }
  ]
}

Field

An individual form input. The variant determines which UI control renders.

{
  "id": "store_name",
  "type": "field",
  "variant": "text",
  "label": "Store Name",
  "description": "The display name of your store.",
  "tooltip": "Visible to customers.",
  "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

{
  "id": "enable_feature",
  "variant": "switch",
  "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: "store_name",
    type: "field",
    variant: "text",
    label: "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 id
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'} Object keyed by field id (e.g. {'{"store_name":"...","enable_tax":true}'})
flatValues {'Record'} Same shape — flat id-keyed values (e.g. {'{"store_name":"...","enable_tax":true}'})
<Settings
  onSave={async (scopeId, treeValues, flatValues) => {
    await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, {
      method: "POST",
      body: JSON.stringify(treeValues),
    });
  }}
/>

Server-Side Validation Errors

If your API returns field-level validation errors, throw an object with an errors property from onSave. The keys must match field id values:

<Settings
  onSave={async (scopeId, treeValues, flatValues) => {
    const res = await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(treeValues),
    });

    if (!res.ok) {
      const data = await res.json();
      // Throw with { errors: { [fieldId]: "message" } }
      throw { errors: data.errors };
      // e.g. { errors: { "store_name": "Store name already taken" } }
    }
  }}
/>
  • Errors display on the matching fields in red (text-destructive)
  • The save button is disabled while errors are present (hasErrors becomes true)
  • Errors auto-clear when the user changes the errored field

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, hasErrors, onSave }) => (
    <Button onClick={onSave} disabled={!dirty || hasErrors}>
      {__("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
hasErrors boolean true if any field in the scope has a validation error (client-side or server-side)
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",
  "type": "field",
  "variant": "number",
  "label": "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 id 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 id 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 id
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