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:
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { SchemaForm, FormSchema } from '@/components/forms/SchemaForm';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
has_settings: boolean;
|
||||
settings_component?: string;
|
||||
}
|
||||
|
||||
export default function ModuleSettings() {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useModuleSettings(moduleId || '');
|
||||
|
||||
// Fetch module info
|
||||
const { data: modulesData } = useQuery({
|
||||
queryKey: ['modules'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules');
|
||||
return response as { modules: Record<string, Module> };
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch settings schema
|
||||
const { data: schemaData } = useQuery({
|
||||
queryKey: ['module-schema', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/schema`);
|
||||
return response as { schema: FormSchema };
|
||||
},
|
||||
enabled: !!moduleId,
|
||||
});
|
||||
|
||||
const module = modulesData?.modules?.[moduleId || ''];
|
||||
|
||||
if (!module) {
|
||||
return (
|
||||
<SettingsLayout title={__('Module Settings')} isLoading={!modulesData}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{__('Module not found')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!module.has_settings) {
|
||||
return (
|
||||
<SettingsLayout title={module.label}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{__('This module does not have any settings')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// If module has custom component, load it dynamically
|
||||
if (module.settings_component) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<DynamicComponentLoader
|
||||
componentUrl={module.settings_component}
|
||||
moduleId={moduleId || ''}
|
||||
/>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render schema-based form
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
isLoading={settingsLoading}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<SettingsCard
|
||||
title={__('Configuration')}
|
||||
description={__('Configure module settings below')}
|
||||
>
|
||||
{schemaData?.schema ? (
|
||||
<SchemaForm
|
||||
schema={schemaData.schema}
|
||||
initialValues={settings}
|
||||
onSubmit={(values) => updateSettings.mutate(values)}
|
||||
isSubmitting={updateSettings.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>{__('No settings schema available for this module')}</p>
|
||||
<p className="text-xs mt-2">
|
||||
{__('The module developer needs to register a settings schema using the woonoow/module_settings_schema filter')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key, Search, Settings, Truck, CreditCard, BarChart3, Puzzle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
@@ -17,19 +20,23 @@ interface Module {
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
features: string[];
|
||||
is_addon?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
has_settings?: boolean;
|
||||
}
|
||||
|
||||
interface ModulesData {
|
||||
modules: Record<string, Module>;
|
||||
grouped: {
|
||||
marketing: Module[];
|
||||
customers: Module[];
|
||||
products: Module[];
|
||||
};
|
||||
grouped: Record<string, Module[]>;
|
||||
categories: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function Modules() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const { data: modulesData, isLoading } = useQuery<ModulesData>({
|
||||
queryKey: ['modules'],
|
||||
@@ -64,21 +71,45 @@ export default function Modules() {
|
||||
users: Users,
|
||||
'refresh-cw': RefreshCcw,
|
||||
key: Key,
|
||||
truck: Truck,
|
||||
'credit-card': CreditCard,
|
||||
'bar-chart-3': BarChart3,
|
||||
puzzle: Puzzle,
|
||||
};
|
||||
const Icon = icons[iconName] || Mail;
|
||||
const Icon = icons[iconName] || Puzzle;
|
||||
return <Icon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
marketing: __('Marketing & Sales'),
|
||||
customers: __('Customer Experience'),
|
||||
products: __('Products & Inventory'),
|
||||
};
|
||||
return labels[category] || category;
|
||||
};
|
||||
// Filter modules based on search and category
|
||||
const filteredGrouped = useMemo(() => {
|
||||
if (!modulesData?.grouped) return {};
|
||||
|
||||
const filtered: Record<string, Module[]> = {};
|
||||
|
||||
Object.entries(modulesData.grouped).forEach(([category, modules]) => {
|
||||
// Filter by category if selected
|
||||
if (selectedCategory && category !== selectedCategory) return;
|
||||
|
||||
// Filter by search query
|
||||
const matchingModules = modules.filter((module) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
module.label.toLowerCase().includes(query) ||
|
||||
module.description.toLowerCase().includes(query) ||
|
||||
module.features.some((f) => f.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
if (matchingModules.length > 0) {
|
||||
filtered[category] = matchingModules;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [modulesData, searchQuery, selectedCategory]);
|
||||
|
||||
const categories = ['marketing', 'customers', 'products'];
|
||||
const categories = Object.keys(modulesData?.categories || {});
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
@@ -86,6 +117,41 @@ export default function Modules() {
|
||||
description={__('Enable or disable features to customize your store')}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{/* Search and Filters */}
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={__('Search modules...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === null ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
>
|
||||
{__('All Categories')}
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{modulesData?.categories[category] || category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm space-y-2">
|
||||
@@ -101,15 +167,21 @@ export default function Modules() {
|
||||
</div>
|
||||
|
||||
{/* Module Categories */}
|
||||
{categories.map((category) => {
|
||||
const modules = modulesData?.grouped[category as keyof typeof modulesData.grouped] || [];
|
||||
{Object.keys(filteredGrouped).length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Search className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>{__('No modules found matching your search')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(filteredGrouped).map(([category, modules]) => {
|
||||
|
||||
if (modules.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
key={category}
|
||||
title={getCategoryLabel(category)}
|
||||
title={modulesData?.categories[category] || category}
|
||||
description={__('Manage modules in this category')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -138,6 +210,11 @@ export default function Modules() {
|
||||
{__('Active')}
|
||||
</Badge>
|
||||
)}
|
||||
{module.is_addon && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{__('Addon')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{module.description}
|
||||
@@ -159,8 +236,21 @@ export default function Modules() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div className="flex items-center">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Settings Gear Icon */}
|
||||
{module.has_settings && module.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/settings/modules/${module.id}`)}
|
||||
title={__('Module Settings')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<Switch
|
||||
checked={module.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
|
||||
Reference in New Issue
Block a user