From fdb2a4e380b8799e81851a64d38d6b09001bdec3 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Sat, 14 Feb 2026 13:03:48 +0600 Subject: [PATCH 01/15] Init Settings component --- src/components/settings/Settings.stories.tsx | 489 ++++++++++++++++++ src/components/settings/field-renderer.tsx | 131 +++++ src/components/settings/fields.tsx | 378 ++++++++++++++ src/components/settings/index.tsx | 114 ++++ src/components/settings/settings-content.tsx | 227 ++++++++ src/components/settings/settings-context.tsx | 303 +++++++++++ src/components/settings/settings-formatter.ts | 270 ++++++++++ src/components/settings/settings-sidebar.tsx | 68 +++ src/components/settings/settings-types.ts | 126 +++++ src/index.ts | 11 + 10 files changed, 2117 insertions(+) create mode 100644 src/components/settings/Settings.stories.tsx create mode 100644 src/components/settings/field-renderer.tsx create mode 100644 src/components/settings/fields.tsx create mode 100644 src/components/settings/index.tsx create mode 100644 src/components/settings/settings-content.tsx create mode 100644 src/components/settings/settings-context.tsx create mode 100644 src/components/settings/settings-formatter.ts create mode 100644 src/components/settings/settings-sidebar.tsx create mode 100644 src/components/settings/settings-types.ts diff --git a/src/components/settings/Settings.stories.tsx b/src/components/settings/Settings.stories.tsx new file mode 100644 index 0000000..c362878 --- /dev/null +++ b/src/components/settings/Settings.stories.tsx @@ -0,0 +1,489 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Settings } from './index'; +import type { SettingsElement } from './settings-types'; + +// ============================================ +// Sample Schema — exercises all field variants +// ============================================ + +const sampleSchema: SettingsElement[] = [ + // ── Page: General ── + { + id: 'general', + type: 'page', + title: 'General', + priority: 10, + children: [ + // Subpage: Store + { + id: 'store', + type: 'subpage', + title: 'Store Settings', + description: 'Configure your store defaults and appearance.', + icon: 'Store', + page_id: 'general', + priority: 10, + children: [ + // Tab: Basic + { + id: 'store_basic', + type: 'tab', + title: 'Basic', + subpage_id: 'store', + priority: 10, + children: [ + // Section: Address + { + id: 'address_section', + type: 'section', + title: 'Address Information', + description: 'Default address for your store.', + tab_id: 'store_basic', + priority: 10, + children: [ + { + id: 'store_name', + type: 'field', + variant: 'text', + title: 'Store Name', + description: 'This is the display name of your store.', + tooltip: 'Visible to customers and on invoices.', + dependency_key: 'store_name', + default: 'My Awesome Store', + section_id: 'address_section', + priority: 10, + }, + { + id: 'store_city', + type: 'field', + variant: 'text', + title: 'City', + dependency_key: 'store_city', + placeholder: 'Enter city', + section_id: 'address_section', + priority: 20, + }, + { + id: 'store_country', + type: 'field', + variant: 'select', + title: 'Country', + description: 'Select the country where your store is located.', + dependency_key: 'store_country', + default: 'us', + options: [ + { value: 'us', title: 'United States' }, + { value: 'uk', title: 'United Kingdom' }, + { value: 'ca', title: 'Canada' }, + { value: 'au', title: 'Australia' }, + { value: 'bd', title: 'Bangladesh' }, + ], + section_id: 'address_section', + priority: 30, + }, + ], + }, + // Section: Display + { + id: 'display_section', + type: 'section', + title: 'Display Options', + tab_id: 'store_basic', + priority: 20, + children: [ + { + id: 'enable_store_listing', + type: 'field', + variant: 'switch', + title: 'Enable Store Listing', + description: 'Show the store on your public marketplace.', + dependency_key: 'enable_store_listing', + default: true, + section_id: 'display_section', + priority: 10, + }, + { + id: 'products_per_page', + type: 'field', + variant: 'number', + title: 'Products Per Page', + description: 'How many products to show per page.', + dependency_key: 'products_per_page', + default: 12, + min: 1, + max: 100, + section_id: 'display_section', + priority: 20, + dependencies: [ + { + key: 'enable_store_listing', + value: true, + condition: 'equal', + }, + ], + }, + { + id: 'layout_mode', + type: 'field', + variant: 'radio_capsule', + title: 'Layout Mode', + description: 'Choose a layout for the product grid.', + dependency_key: 'layout_mode', + default: 'grid', + options: [ + { value: 'grid', title: 'Grid' }, + { value: 'list', title: 'List' }, + { value: 'compact', title: 'Compact' }, + ], + section_id: 'display_section', + priority: 30, + dependencies: [ + { + key: 'enable_store_listing', + value: true, + condition: 'equal', + }, + ], + }, + ], + }, + ], + }, + // Tab: Advanced + { + id: 'store_advanced', + type: 'tab', + title: 'Advanced', + subpage_id: 'store', + priority: 20, + children: [ + { + id: 'advanced_section', + type: 'section', + title: 'Advanced Settings', + description: 'Careful, these settings affect the whole store.', + tab_id: 'store_advanced', + priority: 10, + children: [ + { + id: 'custom_css', + type: 'field', + variant: 'textarea', + title: 'Custom CSS', + description: 'Add custom CSS styles for your store.', + dependency_key: 'custom_css', + placeholder: '/* Enter your custom CSS here */', + section_id: 'advanced_section', + priority: 10, + }, + { + id: 'html_block', + type: 'field', + variant: 'html', + title: 'Information', + html_content: + '

This is an HTML block rendered from your settings schema. It can contain any HTML content.

', + section_id: 'advanced_section', + priority: 20, + }, + ], + }, + ], + }, + ], + }, + // Subpage: Selling + { + id: 'selling', + type: 'subpage', + title: 'Selling Options', + description: 'Configure selling behavior for vendors.', + icon: 'ShoppingCart', + page_id: 'general', + priority: 20, + children: [ + { + id: 'selling_section', + type: 'section', + title: 'Selling Configuration', + subpage_id: 'selling', + priority: 10, + children: [ + { + id: 'commission_type', + type: 'field', + variant: 'select', + title: 'Commission Type', + description: 'How commission is calculated for vendors.', + dependency_key: 'commission_type', + default: 'percentage', + options: [ + { value: 'percentage', title: 'Percentage' }, + { value: 'flat', title: 'Flat Rate' }, + { value: 'combined', title: 'Combined' }, + ], + section_id: 'selling_section', + priority: 10, + }, + { + id: 'commission_rate', + type: 'field', + variant: 'number', + title: 'Commission Rate', + description: 'The commission percentage for vendors.', + dependency_key: 'commission_rate', + default: 10, + postfix: '%', + min: 0, + max: 100, + section_id: 'selling_section', + priority: 20, + }, + { + id: 'allowed_categories', + type: 'field', + variant: 'multicheck', + title: 'Allowed Product Categories', + description: 'Select which categories vendors can sell in.', + dependency_key: 'allowed_categories', + default: ['electronics', 'clothing'], + options: [ + { value: 'electronics', title: 'Electronics' }, + { value: 'clothing', title: 'Clothing' }, + { value: 'home', title: 'Home & Garden' }, + { value: 'sports', title: 'Sports' }, + { value: 'books', title: 'Books' }, + ], + section_id: 'selling_section', + priority: 30, + }, + ], + }, + ], + }, + ], + }, + // ── Page: Payments ── + { + id: 'payments', + type: 'page', + title: 'Payments', + priority: 20, + children: [ + { + id: 'payment_methods', + type: 'subpage', + title: 'Payment Methods', + description: 'Manage your payment gateways.', + icon: 'CreditCard', + page_id: 'payments', + priority: 10, + children: [ + { + id: 'payments_section', + type: 'section', + title: 'Gateway Settings', + subpage_id: 'payment_methods', + priority: 10, + children: [ + { + id: 'enable_paypal', + type: 'field', + variant: 'switch', + title: 'Enable PayPal', + description: 'Allow payments via PayPal.', + dependency_key: 'enable_paypal', + default: true, + section_id: 'payments_section', + priority: 10, + }, + { + id: 'paypal_email', + type: 'field', + variant: 'text', + title: 'PayPal Email', + placeholder: 'you@example.com', + dependency_key: 'paypal_email', + section_id: 'payments_section', + priority: 20, + dependencies: [ + { + key: 'enable_paypal', + value: true, + condition: 'equal', + }, + ], + validations: [ + { + type: 'required', + message: 'PayPal email is required when PayPal is enabled.', + }, + ], + }, + { + id: 'enable_stripe', + type: 'field', + variant: 'switch', + title: 'Enable Stripe', + description: 'Accept credit card payments through Stripe.', + dependency_key: 'enable_stripe', + default: false, + section_id: 'payments_section', + priority: 30, + }, + { + id: 'label_info', + type: 'field', + variant: 'base_field_label', + title: 'Need more gateways?', + description: 'Contact support for additional payment integrations.', + doc_link: 'https://example.com/docs/payments', + section_id: 'payments_section', + priority: 40, + }, + ], + }, + ], + }, + ], + }, +]; + +// ============================================ +// Meta +// ============================================ + +const meta = { + title: 'Components/Settings', + component: Settings, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'A schema-driven settings page component. Accepts a hierarchical or flat settings schema, ' + + 'renders a sidebar menu, tabbed content, sections, and field components. ' + + 'All state (values, navigation) is managed internally via React Context. ' + + 'Consumers provide `onChange` and `onSave` callbacks for persistence.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + schema: { control: false }, + values: { control: false }, + onChange: { action: 'onChange' }, + onSave: { action: 'onSave' }, + loading: { control: 'boolean' }, + title: { control: 'text' }, + hookPrefix: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ============================================ +// Stories +// ============================================ + +/** Full settings page with sidebar navigation, tabs, sections, and various field types. */ +export const Default: Story = { + args: { + schema: sampleSchema, + title: 'Settings', + loading: false, + hookPrefix: 'my_plugin', + }, + render: (args) => { + const [values, setValues] = useState>({}); + + return ( +
+ { + setValues((prev) => ({ ...prev, [key]: value })); + args.onChange?.(key, value); + }} + onSave={(vals) => { + // eslint-disable-next-line no-console + console.log('Save:', vals); + args.onSave?.(vals); + }} + /> +
+ ); + }, +}; + +/** Loading state. */ +export const Loading: Story = { + args: { + schema: sampleSchema, + loading: true, + title: 'Settings', + }, +}; + +/** With pre-populated values. */ +export const WithValues: Story = { + render: () => { + const [values, setValues] = useState>({ + store_name: 'Acme Store', + store_city: 'San Francisco', + store_country: 'us', + enable_store_listing: true, + products_per_page: 24, + layout_mode: 'list', + commission_type: 'percentage', + commission_rate: 15, + allowed_categories: ['electronics', 'clothing', 'books'], + enable_paypal: true, + paypal_email: 'acme@example.com', + enable_stripe: true, + }); + + return ( +
+ { + setValues((prev) => ({ ...prev, [key]: value })); + }} + onSave={(vals) => { + // eslint-disable-next-line no-console + console.log('Save:', vals); + alert('Settings saved! Check console for values.'); + }} + /> +
+ ); + }, +}; + +/** Dependency demo — toggle the switch to show/hide dependent fields. */ +export const DependencyDemo: Story = { + render: () => { + const [values, setValues] = useState>({ + enable_store_listing: false, + }); + + return ( +
+ { + setValues((prev) => ({ ...prev, [key]: value })); + }} + /> +
+ ); + }, +}; diff --git a/src/components/settings/field-renderer.tsx b/src/components/settings/field-renderer.tsx new file mode 100644 index 0000000..7e7cccb --- /dev/null +++ b/src/components/settings/field-renderer.tsx @@ -0,0 +1,131 @@ +import type { SettingsElement, FieldComponentProps } from './settings-types'; +import { useSettings } from './settings-context'; +import { + TextField, + NumberField, + TextareaField, + SelectField, + SwitchField, + RadioCapsuleField, + MulticheckField, + LabelField, + HtmlField, + FallbackField, +} from './fields'; + +// ============================================ +// Field Renderer — dispatches by variant +// Wraps each variant with applyFilters if @wordpress/hooks is available +// ============================================ + +/** + * Try to use @wordpress/hooks if available (peer dependency). + * Falls back to identity function if not installed. + */ +let applyFilters: (hookName: string, value: any, ...args: any[]) => any; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const hooks = require('@wordpress/hooks'); + applyFilters = hooks.applyFilters; +} catch { + // Fallback: no filtering, return value as-is + applyFilters = (hookName: string, value: any) => value; +} + +export function FieldRenderer({ element }: { element: SettingsElement }) { + const { values, updateValue, shouldDisplay, hookPrefix, errors } = useSettings(); + + // Check display status (dependency evaluation) + if (!shouldDisplay(element)) { + return null; + } + + // Merge current value from context + const mergedElement: SettingsElement = { + ...element, + value: element.dependency_key ? (values[element.dependency_key] ?? element.value) : element.value, + validationError: element.dependency_key ? errors[element.dependency_key] : undefined, + }; + + const fieldProps: FieldComponentProps = { + element: mergedElement, + onChange: updateValue, + }; + + const variant = element.variant || ''; + const filterPrefix = hookPrefix || 'plugin_ui'; + + // Dispatch by variant — each wrapped with applyFilters + switch (variant) { + case 'text': + return applyFilters( + `${filterPrefix}_settings_text_field`, + , + mergedElement + ); + + case 'number': + return applyFilters( + `${filterPrefix}_settings_number_field`, + , + mergedElement + ); + + case 'textarea': + return applyFilters( + `${filterPrefix}_settings_textarea_field`, + , + mergedElement + ); + + case 'select': + return applyFilters( + `${filterPrefix}_settings_select_field`, + , + mergedElement + ); + + case 'switch': + return applyFilters( + `${filterPrefix}_settings_switch_field`, + , + mergedElement + ); + + case 'radio_capsule': + return applyFilters( + `${filterPrefix}_settings_radio_capsule_field`, + , + mergedElement + ); + + case 'multicheck': + return applyFilters( + `${filterPrefix}_settings_multicheck_field`, + , + mergedElement + ); + + case 'base_field_label': + return applyFilters( + `${filterPrefix}_settings_label_field`, + , + mergedElement + ); + + case 'html': + return applyFilters( + `${filterPrefix}_settings_html_field`, + , + mergedElement + ); + + default: + // Unknown variant — consumer must handle via applyFilters + return applyFilters( + `${filterPrefix}_settings_default_field`, + , + mergedElement + ); + } +} diff --git a/src/components/settings/fields.tsx b/src/components/settings/fields.tsx new file mode 100644 index 0000000..62916b4 --- /dev/null +++ b/src/components/settings/fields.tsx @@ -0,0 +1,378 @@ +import { cn } from '@/lib/utils'; +import { Input } from '../ui/input'; +import { Textarea } from '../ui/textarea'; +import { Switch } from '../ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; +import { Checkbox } from '../ui/checkbox'; +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'; +import { Info, FileText } from 'lucide-react'; +import type { SettingsElement, FieldComponentProps } from './settings-types'; + +// ============================================ +// Shared Field Wrapper (label + description + tooltip + error) +// ============================================ + +function FieldWrapper({ + element, + children, + layout = 'horizontal', + className, +}: { + element: SettingsElement; + children: React.ReactNode; + layout?: 'horizontal' | 'vertical' | 'full-width'; + className?: string; +}) { + const hasTitle = Boolean(element.title && element.title.length > 0); + + if (layout === 'full-width') { + return ( +
+ {hasTitle && ( + + )} +
+ {children} +
+ {element.validationError && ( +

{element.validationError}

+ )} +
+ ); + } + + return ( +
+ {hasTitle && ( +
+ +
+ )} +
+ {children} +
+ {element.validationError && ( +
+

{element.validationError}

+
+ )} +
+ ); +} + +function FieldLabel({ element }: { element: SettingsElement }) { + return ( +
+
+ {element.image_url && ( + + )} + + {element.title} + + {element.tooltip && ( + + + + + + +

{element.tooltip}

+
+
+
+ )} +
+ {element.description && ( +

+ {element.description} +

+ )} +
+ ); +} + +// ============================================ +// Text Field +// ============================================ + +export function TextField({ element, onChange }: FieldComponentProps) { + return ( + + onChange(element.dependency_key!, e.target.value)} + placeholder={element.placeholder ? String(element.placeholder) : undefined} + disabled={element.disabled} + className="sm:max-w-56" + /> + + ); +} + +// ============================================ +// Number Field +// ============================================ + +export function NumberField({ element, onChange }: FieldComponentProps) { + return ( + +
+ {element.prefix && ( + {element.prefix} + )} + onChange(element.dependency_key!, e.target.value)} + placeholder={element.placeholder ? String(element.placeholder) : undefined} + disabled={element.disabled} + min={element.min} + max={element.max} + step={element.increment} + /> + {element.postfix && ( + {element.postfix} + )} +
+
+ ); +} + +// ============================================ +// Textarea Field +// ============================================ + +export function TextareaField({ element, onChange }: FieldComponentProps) { + return ( + +