feat: Implement Phase 2, 3, 4 - Module Settings System with Schema Forms and Addon API

Phase 2: Schema-Based Form System
- Add ModuleSettingsController with GET/POST/schema endpoints
- Create SchemaField component supporting 8 field types (text, textarea, email, url, number, toggle, checkbox, select)
- Create SchemaForm component for automatic form generation from schema
- Add ModuleSettings page with dynamic routing (/settings/modules/:moduleId)
- Add useModuleSettings React hook for settings management
- Implement NewsletterSettings as example with 8 configurable fields
- Add has_settings flag to module registry
- Settings stored as woonoow_module_{module_id}_settings

Phase 3: Advanced Features
- Create windowAPI.ts exposing React, hooks, components, icons, utils to addons via window.WooNooW
- Add DynamicComponentLoader for loading external React components
- Create TypeScript definitions (woonoow-addon.d.ts) for addon developers
- Initialize Window API in App.tsx on mount
- Enable custom React components for addon settings pages

Phase 4: Production Polish & Example
- Create complete Biteship addon example demonstrating both approaches:
  * Schema-based settings (no build required)
  * Custom React component (with build)
- Add comprehensive README with installation and testing guide
- Include package.json with esbuild configuration
- Demonstrate window.WooNooW API usage in custom component

Bug Fixes:
- Fix footer newsletter form visibility (remove redundant module check)
- Fix footer contact_data and social_links not saving (parameter name mismatch: snake_case vs camelCase)
- Fix useModules hook returning undefined (remove .data wrapper, add fallback)
- Add optional chaining to footer settings rendering
- Fix TypeScript errors in woonoow-addon.d.ts (use any for external types)

Files Added (15):
- includes/Api/ModuleSettingsController.php
- includes/Modules/NewsletterSettings.php
- admin-spa/src/components/forms/SchemaField.tsx
- admin-spa/src/components/forms/SchemaForm.tsx
- admin-spa/src/routes/Settings/ModuleSettings.tsx
- admin-spa/src/hooks/useModuleSettings.ts
- admin-spa/src/lib/windowAPI.ts
- admin-spa/src/components/DynamicComponentLoader.tsx
- types/woonoow-addon.d.ts
- examples/biteship-addon/biteship-addon.php
- examples/biteship-addon/src/Settings.jsx
- examples/biteship-addon/package.json
- examples/biteship-addon/README.md
- PHASE_2_3_4_SUMMARY.md

Files Modified (11):
- admin-spa/src/App.tsx
- admin-spa/src/hooks/useModules.ts
- admin-spa/src/routes/Appearance/Footer.tsx
- admin-spa/src/routes/Settings/Modules.tsx
- customer-spa/src/hooks/useModules.ts
- customer-spa/src/layouts/BaseLayout.tsx
- customer-spa/src/components/NewsletterForm.tsx
- includes/Api/Routes.php
- includes/Api/ModulesController.php
- includes/Core/ModuleRegistry.php
- woonoow.php

API Endpoints Added:
- GET /woonoow/v1/modules/{module_id}/settings
- POST /woonoow/v1/modules/{module_id}/settings
- GET /woonoow/v1/modules/{module_id}/schema

For Addon Developers:
- Schema-based: Define settings via woonoow/module_settings_schema filter
- Custom React: Build component using window.WooNooW API, externalize react/react-dom
- Both approaches use same storage and retrieval methods
- TypeScript definitions provided for type safety
- Complete working example (Biteship) included
This commit is contained in:
Dwindi Ramadhana
2025-12-26 21:16:06 +07:00
parent 07020bc0dd
commit c6cef97ef8
25 changed files with 2512 additions and 57 deletions

View File

@@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
interface DynamicComponentLoaderProps {
componentUrl: string;
moduleId: string;
fallback?: React.ReactNode;
}
/**
* Dynamic Component Loader
*
* Loads external React components from addons dynamically
* The component is loaded as a script and should export a default component
*/
export function DynamicComponentLoader({
componentUrl,
moduleId,
fallback
}: DynamicComponentLoaderProps) {
const [Component, setComponent] = useState<React.ComponentType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const loadComponent = async () => {
try {
setLoading(true);
setError(null);
// Create a unique global variable name for this component
const globalName = `WooNooWAddon_${moduleId.replace(/[^a-zA-Z0-9]/g, '_')}`;
// Check if already loaded
if ((window as any)[globalName]) {
if (mounted) {
setComponent(() => (window as any)[globalName]);
setLoading(false);
}
return;
}
// Load the script
const script = document.createElement('script');
script.src = componentUrl;
script.async = true;
script.onload = () => {
// The addon script should assign its component to window[globalName]
const loadedComponent = (window as any)[globalName];
if (!loadedComponent) {
if (mounted) {
setError(`Component not found. The addon must export to window.${globalName}`);
setLoading(false);
}
return;
}
if (mounted) {
setComponent(() => loadedComponent);
setLoading(false);
}
};
script.onerror = () => {
if (mounted) {
setError('Failed to load component script');
setLoading(false);
}
};
document.head.appendChild(script);
// Cleanup
return () => {
mounted = false;
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
};
loadComponent();
return () => {
mounted = false;
};
}, [componentUrl, moduleId]);
if (loading) {
return fallback || (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading component...</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to Load Component</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<p className="text-xs text-muted-foreground">
Component URL: <code className="bg-muted px-2 py-1 rounded">{componentUrl}</code>
</p>
</div>
);
}
if (!Component) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground">Component not available</p>
</div>
);
}
return <Component />;
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
export interface FieldSchema {
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
label: string;
description?: string;
placeholder?: string;
required?: boolean;
default?: any;
options?: Record<string, string>;
min?: number;
max?: number;
}
interface SchemaFieldProps {
name: string;
schema: FieldSchema;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function SchemaField({ name, schema, value, onChange, error }: SchemaFieldProps) {
const renderField = () => {
switch (schema.type) {
case 'text':
case 'email':
case 'url':
return (
<Input
type={schema.type}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={schema.placeholder}
required={schema.required}
/>
);
case 'number':
return (
<Input
type="number"
value={value || ''}
onChange={(e) => onChange(parseFloat(e.target.value))}
placeholder={schema.placeholder}
required={schema.required}
min={schema.min}
max={schema.max}
/>
);
case 'textarea':
return (
<Textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={schema.placeholder}
required={schema.required}
rows={4}
/>
);
case 'toggle':
return (
<div className="flex items-center gap-2">
<Switch
checked={!!value}
onCheckedChange={onChange}
/>
<span className="text-sm text-muted-foreground">
{value ? 'Enabled' : 'Disabled'}
</span>
</div>
);
case 'checkbox':
return (
<div className="flex items-center gap-2">
<Checkbox
checked={!!value}
onCheckedChange={onChange}
/>
<Label className="text-sm font-normal cursor-pointer">
{schema.label}
</Label>
</div>
);
case 'select':
return (
<Select value={value || ''} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={schema.placeholder || 'Select an option'} />
</SelectTrigger>
<SelectContent>
{schema.options && Object.entries(schema.options).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default:
return (
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={schema.placeholder}
/>
);
}
};
return (
<div className="space-y-2">
{schema.type !== 'checkbox' && (
<Label htmlFor={name}>
{schema.label}
{schema.required && <span className="text-destructive ml-1">*</span>}
</Label>
)}
{renderField()}
{schema.description && (
<p className="text-xs text-muted-foreground">
{schema.description}
</p>
)}
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import React, { useState, useEffect } from 'react';
import { SchemaField, FieldSchema } from './SchemaField';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
export type FormSchema = Record<string, FieldSchema>;
interface SchemaFormProps {
schema: FormSchema;
initialValues?: Record<string, any>;
onSubmit: (values: Record<string, any>) => void | Promise<void>;
isSubmitting?: boolean;
submitLabel?: string;
errors?: Record<string, string>;
}
export function SchemaForm({
schema,
initialValues = {},
onSubmit,
isSubmitting = false,
submitLabel = 'Save Settings',
errors = {},
}: SchemaFormProps) {
const [values, setValues] = useState<Record<string, any>>(initialValues);
useEffect(() => {
setValues(initialValues);
}, [initialValues]);
const handleChange = (name: string, value: any) => {
setValues((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(values);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{Object.entries(schema).map(([name, fieldSchema]) => (
<SchemaField
key={name}
name={name}
schema={fieldSchema}
value={values[name]}
onChange={(value) => handleChange(name, value)}
error={errors[name]}
/>
))}
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitLabel}
</Button>
</div>
</form>
);
}