Files
WooNooW/ADDON_MODULE_DESIGN_DECISIONS.md
Dwindi Ramadhana 07020bc0dd feat: Implement centralized module management system
- Add ModuleRegistry for managing built-in modules (newsletter, wishlist, affiliate, subscription, licensing)
- Add ModulesController REST API for module enable/disable
- Create Modules settings page with category grouping and toggle controls
- Integrate module checks across admin-spa and customer-spa
- Add useModules hook for both SPAs to check module status
- Hide newsletter from footer builder when module disabled
- Hide wishlist features when module disabled (product cards, account menu, wishlist page)
- Protect wishlist API endpoints with module checks
- Auto-update navigation tree when modules toggled
- Clean up obsolete documentation files
- Add comprehensive documentation:
  - MODULE_SYSTEM_IMPLEMENTATION.md
  - MODULE_INTEGRATION_SUMMARY.md
  - ADDON_MODULE_INTEGRATION.md (proposal)
  - ADDON_MODULE_DESIGN_DECISIONS.md (design doc)
  - FEATURE_ROADMAP.md
  - SHIPPING_INTEGRATION.md

Module system provides:
- Centralized enable/disable for all features
- Automatic navigation updates
- Frontend/backend integration
- Foundation for addon-module unification
2025-12-26 19:19:49 +07:00

17 KiB

Addon-Module Integration: Design Decisions

Date: December 26, 2025
Status: 🎯 Decision Document


Problem with Static Categories

// BAD: Empty categories if no modules use them
public static function get_categories() {
    return [
        'shipping' => 'Shipping & Fulfillment', // Empty if no shipping modules!
        'payments' => 'Payments & Checkout', // Empty if no payment modules!
    ];
}

Solution: Dynamic Category Generation

class ModuleRegistry {
    
    /**
     * Get categories dynamically from registered modules
     */
    public static function get_categories() {
        $all_modules = self::get_all_modules();
        $categories = [];
        
        // Extract unique categories from modules
        foreach ($all_modules as $module) {
            $cat = $module['category'] ?? 'other';
            if (!isset($categories[$cat])) {
                $categories[$cat] = self::get_category_label($cat);
            }
        }
        
        // Sort by predefined order (if exists), then alphabetically
        $order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
        uksort($categories, function($a, $b) use ($order) {
            $pos_a = array_search($a, $order);
            $pos_b = array_search($b, $order);
            if ($pos_a === false) $pos_a = 999;
            if ($pos_b === false) $pos_b = 999;
            return $pos_a - $pos_b;
        });
        
        return $categories;
    }
    
    /**
     * Get human-readable label for category
     */
    private static function get_category_label($category) {
        $labels = [
            'marketing'  => __('Marketing & Sales', 'woonoow'),
            'customers'  => __('Customer Experience', 'woonoow'),
            'products'   => __('Products & Inventory', 'woonoow'),
            'shipping'   => __('Shipping & Fulfillment', 'woonoow'),
            'payments'   => __('Payments & Checkout', 'woonoow'),
            'analytics'  => __('Analytics & Reports', 'woonoow'),
            'other'      => __('Other Extensions', 'woonoow'),
        ];
        
        return $labels[$category] ?? ucfirst($category);
    }
    
    /**
     * Group modules by category
     */
    public static function get_grouped_modules() {
        $all_modules = self::get_all_modules();
        $grouped = [];
        
        foreach ($all_modules as $module) {
            $cat = $module['category'] ?? 'other';
            if (!isset($grouped[$cat])) {
                $grouped[$cat] = [];
            }
            $grouped[$cat][] = $module;
        }
        
        return $grouped;
    }
}

Benefits

  • No empty categories
  • Addons can define custom categories
  • Single registration point (module only)
  • Auto-sorted by predefined order

Problem with Custom URLs

'settings_url' => '/settings/shipping/biteship', // Conflict risk!
'settings_url' => '/marketing/newsletter', // Inconsistent!

Solution: Convention-Based Pattern

// Module registration - NO settings_url needed!
$addons['biteship-shipping'] = [
    'id'       => 'biteship-shipping',
    'name'     => 'Biteship Shipping',
    'has_settings' => true, // Just a flag!
];

// Auto-generated URL pattern:
// /settings/modules/{module_id}
// Example: /settings/modules/biteship-shipping

Backend: Auto Route Registration

class ModuleRegistry {
    
    /**
     * Register module settings routes automatically
     */
    public static function register_settings_routes() {
        $modules = self::get_all_modules();
        
        foreach ($modules as $module) {
            if (empty($module['has_settings'])) continue;
            
            // Auto-register route: /settings/modules/{module_id}
            add_filter('woonoow/spa_routes', function($routes) use ($module) {
                $routes[] = [
                    'path'          => "/settings/modules/{$module['id']}",
                    'component_url' => $module['settings_component'] ?? null,
                    'title'         => sprintf(__('%s Settings', 'woonoow'), $module['label']),
                ];
                return $routes;
            });
        }
    }
}

Frontend: Automatic Navigation

// Modules.tsx - Gear icon auto-links
{module.has_settings && module.enabled && (
  <Button
    variant="ghost"
    size="icon"
    onClick={() => navigate(`/settings/modules/${module.id}`)}
  >
    <Settings className="h-4 w-4" />
  </Button>
)}

Benefits

  • No URL conflicts (enforced pattern)
  • Consistent navigation
  • Simpler addon registration
  • Auto-generated breadcrumbs

3. Form Builder vs Custom HTML (HYBRID APPROACH)

Option A: Schema-Based Form Builder (For Simple Settings)

// Addon defines settings schema
add_filter('woonoow/module_settings_schema', function($schemas) {
    $schemas['biteship-shipping'] = [
        'api_key' => [
            'type'        => 'text',
            'label'       => 'API Key',
            'description' => 'Your Biteship API key',
            'required'    => true,
        ],
        'enable_tracking' => [
            'type'    => 'toggle',
            'label'   => 'Enable Tracking',
            'default' => true,
        ],
        'default_courier' => [
            'type'    => 'select',
            'label'   => 'Default Courier',
            'options' => [
                'jne'     => 'JNE',
                'jnt'     => 'J&T Express',
                'sicepat' => 'SiCepat',
            ],
        ],
    ];
    return $schemas;
});

Auto-rendered form - No React needed!

Option B: Custom React Component (For Complex Settings)

// Addon provides custom React component
add_filter('woonoow/addon_registry', function($addons) {
    $addons['biteship-shipping'] = [
        'id'                 => 'biteship-shipping',
        'has_settings'       => true,
        'settings_component' => plugin_dir_url(__FILE__) . 'dist/Settings.js',
    ];
    return $addons;
});

Full control - Custom React UI

Implementation

class ModuleSettingsRenderer {
    
    /**
     * Render settings page
     */
    public static function render($module_id) {
        $module = ModuleRegistry::get_module($module_id);
        
        // Option 1: Has custom component
        if (!empty($module['settings_component'])) {
            return self::render_custom_component($module);
        }
        
        // Option 2: Has schema - auto-generate form
        $schema = apply_filters('woonoow/module_settings_schema', []);
        if (isset($schema[$module_id])) {
            return self::render_schema_form($module_id, $schema[$module_id]);
        }
        
        // Option 3: No settings
        return ['error' => 'No settings available'];
    }
}

Benefits

  • Simple addons use schema (no React needed)
  • Complex addons use custom components
  • Consistent data persistence for both
  • Gradual complexity curve

4. Settings Data Persistence (STANDARDIZED)

Backend: Automatic Persistence

class ModuleSettingsController extends WP_REST_Controller {
    
    /**
     * GET /woonoow/v1/modules/{module_id}/settings
     */
    public function get_settings($request) {
        $module_id = $request['module_id'];
        $settings = get_option("woonoow_module_{$module_id}_settings", []);
        
        // Apply defaults from schema
        $schema = apply_filters('woonoow/module_settings_schema', []);
        if (isset($schema[$module_id])) {
            $settings = wp_parse_args($settings, self::get_defaults($schema[$module_id]));
        }
        
        return rest_ensure_response($settings);
    }
    
    /**
     * POST /woonoow/v1/modules/{module_id}/settings
     */
    public function update_settings($request) {
        $module_id = $request['module_id'];
        $new_settings = $request->get_json_params();
        
        // Validate against schema
        $schema = apply_filters('woonoow/module_settings_schema', []);
        if (isset($schema[$module_id])) {
            $validated = self::validate_settings($new_settings, $schema[$module_id]);
            if (is_wp_error($validated)) {
                return $validated;
            }
            $new_settings = $validated;
        }
        
        // Save
        update_option("woonoow_module_{$module_id}_settings", $new_settings);
        
        // Allow addons to react
        do_action("woonoow/module_settings_updated/{$module_id}", $new_settings);
        
        return rest_ensure_response(['success' => true]);
    }
}

Frontend: Unified Hook

// useModuleSettings.ts
export function useModuleSettings(moduleId: string) {
  const queryClient = useQueryClient();
  
  const { data: settings, isLoading } = useQuery({
    queryKey: ['module-settings', moduleId],
    queryFn: async () => {
      const response = await api.get(`/modules/${moduleId}/settings`);
      return response;
    },
  });
  
  const updateSettings = useMutation({
    mutationFn: async (newSettings: any) => {
      return api.post(`/modules/${moduleId}/settings`, newSettings);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['module-settings', moduleId] });
      toast.success('Settings saved');
    },
  });
  
  return { settings, isLoading, updateSettings };
}

Addon Usage

// Custom settings component
export default function BiteshipSettings() {
  const { settings, updateSettings } = useModuleSettings('biteship-shipping');
  
  return (
    <SettingsLayout title="Biteship Settings">
      <SettingsCard>
        <Input
          label="API Key"
          value={settings?.api_key || ''}
          onChange={(e) => updateSettings.mutate({ api_key: e.target.value })}
        />
      </SettingsCard>
    </SettingsLayout>
  );
}

Benefits

  • Consistent storage pattern: woonoow_module_{id}_settings
  • Automatic validation (if schema provided)
  • React hook for easy access
  • Action hooks for addon logic

5. React Extension Pattern (DOCUMENTED)

Solution: Window API + Build Externals

WooNooW Core Exposes React

// admin-spa/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { useQuery, useMutation } from '@tanstack/react-query';

// Expose for addons
window.WooNooW = {
  React,
  ReactDOM,
  hooks: {
    useQuery,
    useMutation,
    useModuleSettings, // Our custom hook!
  },
  components: {
    SettingsLayout,
    SettingsCard,
    Button,
    Input,
    Select,
    Switch,
    // ... all shadcn components
  },
  utils: {
    api,
    toast,
  },
};

Addon Development

// addon/src/Settings.tsx
const { React, hooks, components, utils } = window.WooNooW;
const { useModuleSettings } = hooks;
const { SettingsLayout, SettingsCard, Input, Button } = components;
const { toast } = utils;

export default function BiteshipSettings() {
  const { settings, updateSettings } = useModuleSettings('biteship-shipping');
  const [apiKey, setApiKey] = React.useState(settings?.api_key || '');
  
  const handleSave = () => {
    updateSettings.mutate({ api_key: apiKey });
  };
  
  return React.createElement(SettingsLayout, { title: 'Biteship Settings' },
    React.createElement(SettingsCard, null,
      React.createElement(Input, {
        label: 'API Key',
        value: apiKey,
        onChange: (e) => setApiKey(e.target.value),
      }),
      React.createElement(Button, { onClick: handleSave }, 'Save')
    )
  );
}

With JSX (Build Required)

// addon/src/Settings.tsx
const { React, hooks, components } = window.WooNooW;
const { useModuleSettings } = hooks;
const { SettingsLayout, SettingsCard, Input, Button } = components;

export default function BiteshipSettings() {
  const { settings, updateSettings } = useModuleSettings('biteship-shipping');
  
  return (
    <SettingsLayout title="Biteship Settings">
      <SettingsCard>
        <Input
          label="API Key"
          value={settings?.api_key || ''}
          onChange={(e) => updateSettings.mutate({ api_key: e.target.value })}
        />
      </SettingsCard>
    </SettingsLayout>
  );
}
// vite.config.js
export default {
  build: {
    lib: {
      entry: 'src/Settings.tsx',
      formats: ['es'],
    },
    rollupOptions: {
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'window.WooNooW.React',
          'react-dom': 'window.WooNooW.ReactDOM',
        },
      },
    },
  },
};

Benefits

  • Addons don't bundle React (use ours)
  • Access to all WooNooW components
  • Consistent UI automatically
  • Type safety with TypeScript

Yes, Refactor Newsletter as Built-in Addon

Why This is Valuable

  1. Dogfooding - We use our own addon system
  2. Example - Best reference for addon developers
  3. Consistency - Newsletter follows same pattern as external addons
  4. Testing - Proves the system works

Proposed Structure

includes/
  Modules/
    Newsletter/
      NewsletterModule.php        # Module registration
      NewsletterController.php    # API endpoints (moved from Api/)
      NewsletterSettings.php      # Settings schema
      
admin-spa/src/modules/
  Newsletter/
    Settings.tsx                  # Settings page
    Subscribers.tsx               # Subscribers page
    index.ts                      # Module exports

Registration Pattern

// includes/Modules/Newsletter/NewsletterModule.php
class NewsletterModule {
    
    public static function register() {
        // Register as module
        add_filter('woonoow/builtin_modules', function($modules) {
            $modules['newsletter'] = [
                'id'                 => 'newsletter',
                'label'              => __('Newsletter', 'woonoow'),
                'description'        => __('Email newsletter subscriptions', 'woonoow'),
                'category'           => 'marketing',
                'icon'               => 'mail',
                'default_enabled'    => true,
                'has_settings'       => true,
                'settings_component' => self::get_settings_url(),
            ];
            return $modules;
        });
        
        // Register routes (only if enabled)
        if (ModuleRegistry::is_enabled('newsletter')) {
            self::register_routes();
        }
    }
    
    private static function register_routes() {
        // Settings route
        add_filter('woonoow/spa_routes', function($routes) {
            $routes[] = [
                'path'          => '/settings/modules/newsletter',
                'component_url' => plugins_url('admin-spa/dist/modules/Newsletter/Settings.js', WOONOOW_FILE),
            ];
            return $routes;
        });
        
        // Subscribers route
        add_filter('woonoow/spa_routes', function($routes) {
            $routes[] = [
                'path'          => '/marketing/newsletter',
                'component_url' => plugins_url('admin-spa/dist/modules/Newsletter/Subscribers.js', WOONOOW_FILE),
            ];
            return $routes;
        });
    }
}

Benefits

  • Newsletter becomes reference implementation
  • Proves addon system works for complex modules
  • Shows best practices
  • Easier to maintain (follows pattern)

Summary of Decisions

# Question Decision Rationale
1 Categories Dynamic from modules No empty categories, single registration
2 Settings URL Pattern: /settings/modules/{id} No conflicts, consistent, auto-generated
3 Form Builder Hybrid: Schema + Custom Simple for basic, flexible for complex
4 Data Persistence Unified API + Hook Consistent storage, easy access
5 React Extension Window API + Externals No bundling, access to components
6 Newsletter Refactor Yes, as example Dogfooding, reference implementation

Implementation Order

Phase 1: Foundation

  1. Dynamic category generation
  2. Standardized settings URL pattern
  3. Module settings API endpoints
  4. useModuleSettings hook

Phase 2: Form System

  1. Schema-based form renderer
  2. Custom component loader
  3. Settings validation

Phase 3: UI Enhancement

  1. Search input on Modules page
  2. Category filter pills
  3. Gear icon with auto-routing

Phase 4: Example

  1. Refactor Newsletter as built-in addon
  2. Document pattern
  3. Create external addon example (Biteship)

Next Steps

Ready to implement? We have clear decisions on all 6 questions. Should we:

  1. Start with Phase 1 (Foundation)?
  2. Create the schema-based form system first?
  3. Refactor Newsletter as proof-of-concept?

Your call! All design decisions are documented and justified.