Files
WooNooW/METABOX_COMPAT.md
dwindown afb54b962e fix: Critical fixes for shipping and meta field registration
Issue 1: Shipping recalculation on order edit (FIXED)
- Problem: OrderForm recalculated shipping on every edit
- Expected: Shipping should be fixed unless address changes
- Solution: Use existing order.totals.shipping in edit mode
- Create mode: Still calculates from shipping method

Issue 2: Meta fields not appearing without data (DOCUMENTED)
- Problem: Private meta fields dont appear if no data exists yet
- Example: Admin cannot input tracking number on first time
- Root cause: Fields only exposed if data exists in database
- Solution: Plugins MUST register fields via MetaFieldsRegistry
- Registration makes field available even when empty

Updated METABOX_COMPAT.md:
- Changed optional to REQUIRED for field registration
- Added critical warning section
- Explained private vs public meta behavior
- Private meta: MUST register to appear
- Public meta: Auto-exposed, no registration needed

The Flow (Corrected):
1. Plugin registers field -> Field appears in UI (even empty)
2. Admin inputs data -> Saved to database
3. Data visible in both admins

Without Registration:
- Private meta (_field): Not exposed, not editable
- Public meta (field): Auto-exposed, auto-editable

Why Private Meta Requires Registration:
- Security: Hidden by default
- Privacy: Prevents exposing sensitive data
- Control: Plugins explicitly declare visibility

Files Changed:
- OrderForm.tsx: Use existing shipping total in edit mode
- METABOX_COMPAT.md: Critical documentation updates

Result:
- Shipping no longer recalculates on edit
- Clear documentation on field registration requirement
- Developers know they MUST register private meta fields
2025-11-20 12:53:55 +07:00

22 KiB

WooNooW Metabox & Custom Fields Compatibility

Philosophy: 3-Level Compatibility Strategy

Following ADDON_BRIDGE_PATTERN.md, we support plugins at 3 levels:

Level 1: Native WP/WooCommerce Hooks 🟢 (THIS DOCUMENT)

Community does NOTHING extra - We listen automatically

  • Plugins use standard add_meta_box(), update_post_meta()
  • Store data in WooCommerce order/product meta
  • WooNooW exposes this data via API automatically
  • Status: NOT IMPLEMENTED - MUST DO NOW

Level 2: Bridge Snippets 🟡 (See ADDON_BRIDGE_PATTERN.md)

Community creates simple bridge - For non-standard behavior

  • Plugins that bypass standard hooks (e.g., Rajaongkir custom UI)
  • WooNooW provides hook system + documentation
  • Community creates bridge snippets
  • Status: Hook system exists, documentation provided

Level 3: Native WooNooW Addons 🔵 (See ADDON_BRIDGE_PATTERN.md)

Community builds proper addons - Best experience

  • Native WooNooW integration
  • Uses WooNooW addon system
  • Independent plugins
  • Status: Addon system exists, developer docs provided

Current Status: LEVEL 1 NOT IMPLEMENTED

Critical Gap: Our SPA admin does NOT currently expose custom meta fields from plugins that use standard WordPress/WooCommerce hooks.

Example Use Case (Level 1):

// Plugin: WooCommerce Shipment Tracking
// Uses STANDARD WooCommerce meta storage

// Plugin stores data (standard WooCommerce way)
update_post_meta($order_id, '_tracking_number', '1234567890');
update_post_meta($order_id, '_tracking_provider', 'JNE');

// Plugin displays in classic admin (standard metabox)
add_meta_box('wc_shipment_tracking', 'Tracking Info', function($post) {
    $tracking = get_post_meta($post->ID, '_tracking_number', true);
    echo '<input name="_tracking_number" value="' . esc_attr($tracking) . '">';
}, 'shop_order');

Current WooNooW Behavior:

  • API doesn't expose _tracking_number meta
  • Frontend can't read/write this data
  • Plugin's data exists in DB but not accessible

Expected WooNooW Behavior (Level 1):

  • API exposes meta object with all fields
  • Frontend can read/write meta data
  • Plugin works WITHOUT any bridge/addon
  • Community does NOTHING extra

Problem Analysis

1. Orders API (OrdersController.php)

Current Implementation:

public static function show(WP_REST_Request $req) {
    $order = wc_get_order($id);
    
    $data = [
        'id' => $order->get_id(),
        'status' => $order->get_status(),
        'billing' => [...],
        'shipping' => [...],
        'items' => [...],
        // ... hardcoded fields only
    ];
    
    return new WP_REST_Response($data, 200);
}

Missing:

  • No get_meta_data() exposure
  • No apply_filters('woonoow/order_data', $data, $order)
  • No metabox hook listening
  • No custom field groups

2. Products API (ProductsController.php)

Current Implementation:

public static function get_product(WP_REST_Request $request) {
    $product = wc_get_product($id);
    
    return new WP_REST_Response([
        'id' => $product->get_id(),
        'name' => $product->get_name(),
        // ... hardcoded fields only
    ], 200);
}

Missing:

  • No custom product meta exposure
  • No apply_filters('woonoow/product_data', $data, $product)
  • No ACF/CMB2/Pods integration
  • No custom tabs/panels

Solution Architecture

Phase 1: Meta Data Exposure (API Layer)

1.1 Orders API Enhancement

Add to OrdersController::show():

public static function show(WP_REST_Request $req) {
    $order = wc_get_order($id);
    
    // ... existing data ...
    
    // Expose all meta data
    $meta_data = [];
    foreach ($order->get_meta_data() as $meta) {
        $key = $meta->key;
        
        // Skip internal/private meta (starts with _)
        // unless explicitly allowed
        if (strpos($key, '_') === 0) {
            $allowed_private = apply_filters('woonoow/order_allowed_private_meta', [
                '_tracking_number',
                '_tracking_provider',
                '_shipment_tracking_items',
                '_wc_shipment_tracking_items',
                // Add more as needed
            ], $order);
            
            if (!in_array($key, $allowed_private, true)) {
                continue;
            }
        }
        
        $meta_data[$key] = $meta->value;
    }
    
    $data['meta'] = $meta_data;
    
    // Allow plugins to add/modify data
    $data = apply_filters('woonoow/order_api_data', $data, $order, $req);
    
    return new WP_REST_Response($data, 200);
}

Add to OrdersController::update():

public static function update(WP_REST_Request $req) {
    $order = wc_get_order($id);
    $data = $req->get_json_params();
    
    // ... existing update logic ...
    
    // Update custom meta fields
    if (isset($data['meta']) && is_array($data['meta'])) {
        foreach ($data['meta'] as $key => $value) {
            // Validate meta key is allowed
            $allowed = apply_filters('woonoow/order_updatable_meta', [
                '_tracking_number',
                '_tracking_provider',
                // Add more as needed
            ], $order);
            
            if (in_array($key, $allowed, true)) {
                $order->update_meta_data($key, $value);
            }
        }
    }
    
    $order->save();
    
    // Allow plugins to perform additional updates
    do_action('woonoow/order_updated', $order, $data, $req);
    
    return new WP_REST_Response(['success' => true], 200);
}

1.2 Products API Enhancement

Add to ProductsController::get_product():

public static function get_product(WP_REST_Request $request) {
    $product = wc_get_product($id);
    
    // ... existing data ...
    
    // Expose all meta data
    $meta_data = [];
    foreach ($product->get_meta_data() as $meta) {
        $key = $meta->key;
        
        // Skip internal meta unless allowed
        if (strpos($key, '_') === 0) {
            $allowed_private = apply_filters('woonoow/product_allowed_private_meta', [
                '_custom_field_example',
                // Add more as needed
            ], $product);
            
            if (!in_array($key, $allowed_private, true)) {
                continue;
            }
        }
        
        $meta_data[$key] = $meta->value;
    }
    
    $data['meta'] = $meta_data;
    
    // Allow plugins to add/modify data
    $data = apply_filters('woonoow/product_api_data', $data, $product, $request);
    
    return new WP_REST_Response($data, 200);
}

Phase 2: Frontend Rendering (React Components)

2.1 Dynamic Meta Fields Component

Create: admin-spa/src/components/MetaFields.tsx

interface MetaField {
  key: string;
  label: string;
  type: 'text' | 'textarea' | 'number' | 'select' | 'date';
  options?: Array<{value: string; label: string}>;
  section?: string; // Group fields into sections
}

interface MetaFieldsProps {
  meta: Record<string, any>;
  fields: MetaField[];
  onChange: (key: string, value: any) => void;
  readOnly?: boolean;
}

export function MetaFields({ meta, fields, onChange, readOnly }: MetaFieldsProps) {
  // Group fields by section
  const sections = fields.reduce((acc, field) => {
    const section = field.section || 'Other';
    if (!acc[section]) acc[section] = [];
    acc[section].push(field);
    return acc;
  }, {} as Record<string, MetaField[]>);
  
  return (
    <div className="space-y-6">
      {Object.entries(sections).map(([section, sectionFields]) => (
        <Card key={section}>
          <CardHeader>
            <CardTitle>{section}</CardTitle>
          </CardHeader>
          <CardContent className="space-y-4">
            {sectionFields.map(field => (
              <div key={field.key}>
                <Label>{field.label}</Label>
                {field.type === 'text' && (
                  <Input
                    value={meta[field.key] || ''}
                    onChange={(e) => onChange(field.key, e.target.value)}
                    disabled={readOnly}
                  />
                )}
                {field.type === 'textarea' && (
                  <Textarea
                    value={meta[field.key] || ''}
                    onChange={(e) => onChange(field.key, e.target.value)}
                    disabled={readOnly}
                  />
                )}
                {/* Add more field types as needed */}
              </div>
            ))}
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

2.2 Hook System for Field Registration

Create: admin-spa/src/hooks/useMetaFields.ts

interface MetaFieldsRegistry {
  orders: MetaField[];
  products: MetaField[];
}

// Global registry (can be extended by plugins via window object)
declare global {
  interface Window {
    WooNooWMetaFields?: MetaFieldsRegistry;
  }
}

export function useMetaFields(type: 'orders' | 'products'): MetaField[] {
  const [fields, setFields] = useState<MetaField[]>([]);
  
  useEffect(() => {
    // Get fields from global registry
    const registry = window.WooNooWMetaFields || { orders: [], products: [] };
    setFields(registry[type] || []);
  }, [type]);
  
  return fields;
}

2.3 Integration in Order Edit Form

Update: admin-spa/src/routes/Orders/Edit.tsx

import { MetaFields } from '@/components/MetaFields';
import { useMetaFields } from '@/hooks/useMetaFields';

export default function OrderEdit() {
  const { id } = useParams();
  const metaFields = useMetaFields('orders');
  
  const orderQ = useQuery({
    queryKey: ['order', id],
    queryFn: () => api.get(`/orders/${id}`),
  });
  
  const [formData, setFormData] = useState({
    // ... existing fields ...
    meta: {},
  });
  
  useEffect(() => {
    if (orderQ.data) {
      setFormData(prev => ({
        ...prev,
        meta: orderQ.data.meta || {},
      }));
    }
  }, [orderQ.data]);
  
  const handleMetaChange = (key: string, value: any) => {
    setFormData(prev => ({
      ...prev,
      meta: {
        ...prev.meta,
        [key]: value,
      },
    }));
  };
  
  return (
    <div>
      {/* Existing order form fields */}
      
      {/* Custom meta fields */}
      {metaFields.length > 0 && (
        <MetaFields
          meta={formData.meta}
          fields={metaFields}
          onChange={handleMetaChange}
        />
      )}
    </div>
  );
}

Phase 3: Plugin Integration Layer

3.1 PHP Hook for Field Registration

Create: includes/Compat/MetaFieldsRegistry.php

<?php
namespace WooNooW\Compat;

class MetaFieldsRegistry {
    
    private static $order_fields = [];
    private static $product_fields = [];
    
    public static function init() {
        add_action('admin_enqueue_scripts', [__CLASS__, 'localize_fields']);
        
        // Allow plugins to register fields
        do_action('woonoow/register_meta_fields');
    }
    
    /**
     * Register order meta field
     */
    public static function register_order_field($key, $args = []) {
        $defaults = [
            'key' => $key,
            'label' => ucfirst(str_replace('_', ' ', $key)),
            'type' => 'text',
            'section' => 'Other',
        ];
        
        self::$order_fields[$key] = array_merge($defaults, $args);
    }
    
    /**
     * Register product meta field
     */
    public static function register_product_field($key, $args = []) {
        $defaults = [
            'key' => $key,
            'label' => ucfirst(str_replace('_', ' ', $key)),
            'type' => 'text',
            'section' => 'Other',
        ];
        
        self::$product_fields[$key] = array_merge($defaults, $args);
    }
    
    /**
     * Localize fields to JavaScript
     */
    public static function localize_fields() {
        if (!is_admin()) return;
        
        wp_localize_script('woonoow-admin', 'WooNooWMetaFields', [
            'orders' => array_values(self::$order_fields),
            'products' => array_values(self::$product_fields),
        ]);
    }
}

3.2 Example: Shipment Tracking Integration

Create: includes/Compat/Integrations/ShipmentTracking.php

<?php
namespace WooNooW\Compat\Integrations;

use WooNooW\Compat\MetaFieldsRegistry;

class ShipmentTracking {
    
    public static function init() {
        // Only load if WC Shipment Tracking is active
        if (!class_exists('WC_Shipment_Tracking')) {
            return;
        }
        
        add_action('woonoow/register_meta_fields', [__CLASS__, 'register_fields']);
        add_filter('woonoow/order_allowed_private_meta', [__CLASS__, 'allow_meta']);
        add_filter('woonoow/order_updatable_meta', [__CLASS__, 'allow_meta']);
    }
    
    public static function register_fields() {
        MetaFieldsRegistry::register_order_field('_tracking_number', [
            'label' => __('Tracking Number', 'woonoow'),
            'type' => 'text',
            'section' => 'Shipment Tracking',
        ]);
        
        MetaFieldsRegistry::register_order_field('_tracking_provider', [
            'label' => __('Tracking Provider', 'woonoow'),
            'type' => 'select',
            'section' => 'Shipment Tracking',
            'options' => [
                ['value' => 'jne', 'label' => 'JNE'],
                ['value' => 'jnt', 'label' => 'J&T'],
                ['value' => 'sicepat', 'label' => 'SiCepat'],
            ],
        ]);
    }
    
    public static function allow_meta($allowed) {
        $allowed[] = '_tracking_number';
        $allowed[] = '_tracking_provider';
        $allowed[] = '_shipment_tracking_items';
        return $allowed;
    }
}

Implementation Checklist

Phase 1: API Layer

  • Add meta data exposure to OrdersController::show()
  • Add meta data update to OrdersController::update()
  • Add meta data exposure to ProductsController::get_product()
  • Add meta data update to ProductsController::update_product()
  • Add filters: woonoow/order_api_data, woonoow/product_api_data
  • Add filters: woonoow/order_allowed_private_meta, woonoow/order_updatable_meta
  • Add actions: woonoow/order_updated, woonoow/product_updated

Phase 2: Frontend Components

  • Create MetaFields.tsx component
  • Create useMetaFields.ts hook
  • Update Orders/Edit.tsx to include meta fields
  • Update Orders/View.tsx to display meta fields (read-only)
  • Update Products/Edit.tsx to include meta fields
  • Add meta fields to Order/Product detail pages

Phase 3: Plugin Integration

  • Create MetaFieldsRegistry.php
  • Add woonoow/register_meta_fields action
  • Localize fields to JavaScript
  • Create example integration: ShipmentTracking.php
  • Document integration pattern for third-party devs

Phase 4: Testing

  • Test with WooCommerce Shipment Tracking plugin
  • Test with ACF (Advanced Custom Fields)
  • Test with CMB2 (Custom Metaboxes 2)
  • Test with custom metabox plugins
  • Test meta data save/update
  • Test meta data display in detail view

Third-Party Plugin Integration Guide

For Plugin Developers:

Example: Adding custom fields to WooNooW admin

// In your plugin file
add_action('woonoow/register_meta_fields', function() {
    // Register order field
    WooNooW\Compat\MetaFieldsRegistry::register_order_field('_my_custom_field', [
        'label' => __('My Custom Field', 'my-plugin'),
        'type' => 'text',
        'section' => 'My Plugin',
    ]);
    
    // Register product field
    WooNooW\Compat\MetaFieldsRegistry::register_product_field('_my_product_field', [
        'label' => __('My Product Field', 'my-plugin'),
        'type' => 'textarea',
        'section' => 'My Plugin',
    ]);
});

// Allow meta to be read/written
add_filter('woonoow/order_allowed_private_meta', function($allowed) {
    $allowed[] = '_my_custom_field';
    return $allowed;
});

add_filter('woonoow/order_updatable_meta', function($allowed) {
    $allowed[] = '_my_custom_field';
    return $allowed;
});

Priority

Status: 🔴 CRITICAL - MUST IMPLEMENT

Why:

  1. Breaks compatibility with popular plugins (Shipment Tracking, ACF, etc.)
  2. Users cannot see/edit custom fields added by other plugins
  3. Data exists in database but not accessible in SPA admin
  4. Forces users to switch back to classic admin for custom fields

Timeline:

  • Phase 1 (API): 2-3 days COMPLETE
  • Phase 2 (Frontend): 3-4 days COMPLETE
  • Phase 3 (Integration): 2-3 days COMPLETE
  • Total: ~1-2 weeks COMPLETE

Status: IMPLEMENTED AND READY


Complete Example: Plugin Integration

Example 1: WooCommerce Shipment Tracking

Plugin stores data (standard WooCommerce way):

// Plugin code (no changes needed)
update_post_meta($order_id, '_tracking_number', '1234567890');
update_post_meta($order_id, '_tracking_provider', 'JNE');

Plugin registers fields for WooNooW (REQUIRED for UI display):

// In plugin's main file or init hook
add_action('woonoow/register_meta_fields', function() {
    // Register tracking number field
    \WooNooW\Compat\MetaFieldsRegistry::register_order_field('_tracking_number', [
        'label' => __('Tracking Number', 'your-plugin'),
        'type' => 'text',
        'section' => 'Shipment Tracking',
        'description' => 'Enter the shipment tracking number',
        'placeholder' => 'e.g., 1234567890',
    ]);
    
    // Register tracking provider field
    \WooNooW\Compat\MetaFieldsRegistry::register_order_field('_tracking_provider', [
        'label' => __('Tracking Provider', 'your-plugin'),
        'type' => 'select',
        'section' => 'Shipment Tracking',
        'options' => [
            ['value' => 'jne', 'label' => 'JNE'],
            ['value' => 'jnt', 'label' => 'J&T Express'],
            ['value' => 'sicepat', 'label' => 'SiCepat'],
            ['value' => 'anteraja', 'label' => 'AnterAja'],
        ],
    ]);
});

Result:

  • Fields automatically exposed in API
  • Fields displayed in WooNooW order edit page
  • Fields editable by admin
  • Data saved to WooCommerce database
  • Compatible with classic admin
  • Zero migration needed

Example 2: Advanced Custom Fields (ACF)

ACF stores data (standard way):

// ACF automatically stores to post meta
update_field('custom_field', 'value', $product_id);
// Stored as: update_post_meta($product_id, 'custom_field', 'value');

Register for WooNooW (REQUIRED for UI display):

add_action('woonoow/register_meta_fields', function() {
    \WooNooW\Compat\MetaFieldsRegistry::register_product_field('custom_field', [
        'label' => __('Custom Field', 'your-plugin'),
        'type' => 'textarea',
        'section' => 'Custom Fields',
    ]);
});

Result:

  • ACF data visible in WooNooW
  • Editable in WooNooW admin
  • Synced with ACF
  • Works with both admins

Example 3: Public Meta (Auto-Exposed, No Registration Needed)

Plugin stores data:

// Plugin stores public meta (no underscore)
update_post_meta($order_id, 'custom_note', 'Some note');

Result:

  • Automatically exposed (public meta)
  • Displayed in API response
  • No registration needed
  • Works immediately

API Response Examples

Order with Meta Fields

Request:

GET /wp-json/woonoow/v1/orders/123

Response:

{
  "id": 123,
  "status": "processing",
  "billing": {...},
  "shipping": {...},
  "items": [...],
  "meta": {
    "_tracking_number": "1234567890",
    "_tracking_provider": "jne",
    "custom_note": "Some note"
  }
}

Product with Meta Fields

Request:

GET /wp-json/woonoow/v1/products/456

Response:

{
  "id": 456,
  "name": "Product Name",
  "price": 100000,
  "meta": {
    "custom_field": "Custom value",
    "another_field": "Another value"
  }
}

Field Types Reference

Text Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'text',
    'placeholder' => 'Enter value...',
]);

Textarea Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'textarea',
    'placeholder' => 'Enter description...',
]);

Number Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'number',
    'placeholder' => '0',
]);

Select Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'select',
    'options' => [
        ['value' => 'option1', 'label' => 'Option 1'],
        ['value' => 'option2', 'label' => 'Option 2'],
    ],
]);

Date Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'date',
]);

Checkbox Field

MetaFieldsRegistry::register_order_field('_field_name', [
    'label' => 'Field Label',
    'type' => 'checkbox',
    'placeholder' => 'Enable this option',
]);

Summary

For Plugin Developers:

  1. Continue using standard WP/WooCommerce meta storage
  2. MUST register private meta fields (starting with _) for UI display
  3. Public meta (no _) auto-exposed, no registration needed
  4. Works with both classic and WooNooW admin

⚠️ CRITICAL: Private Meta Field Registration

Private meta fields (starting with _) MUST be registered to appear in WooNooW UI:

Why?

  • Security: Private meta is hidden by default
  • Privacy: Prevents exposing sensitive data
  • Control: Plugins explicitly declare what should be visible

The Flow:

  1. Plugin registers field → Field appears in UI (even if empty)
  2. Admin inputs data → Saved to database
  3. Data visible in both admins

Without Registration:

  • Private meta: Not exposed, not editable
  • Public meta: Auto-exposed, auto-editable

Example:

// This field will NOT appear without registration
update_post_meta($order_id, '_tracking_number', '123');

// Register it to make it appear
add_action('woonoow/register_meta_fields', function() {
    MetaFieldsRegistry::register_order_field('_tracking_number', [...]);
});

// Now admin can see and edit it, even when empty!

For WooNooW Core:

  1. Zero addon dependencies
  2. Provides mechanism, not integration
  3. Plugins register themselves
  4. Clean separation of concerns

Result: Level 1 compatibility fully implemented Plugins work automatically No migration needed Production ready