feat: Phase 2 - Frontend meta fields components (Level 1)

Implemented: Frontend Components for Level 1 Compatibility

Created Components:
- MetaFields.tsx - Generic meta field renderer
- useMetaFields.ts - Hook for field registry

Integrated Into:
- Orders/Edit.tsx - Meta fields after OrderForm
- Products/Edit.tsx - Meta fields after ProductForm

Features:
- Supports: text, textarea, number, date, select, checkbox
- Groups fields by section
- Zero coupling with specific plugins
- Renders any registered fields dynamically
- Read-only mode support

How It Works:
1. Backend exposes meta via API (Phase 1)
2. PHP registers fields via MetaFieldsRegistry (Phase 3 - next)
3. Fields localized to window.WooNooWMetaFields
4. useMetaFields hook reads registry
5. MetaFields component renders fields
6. User edits fields
7. Form submission includes meta
8. Backend saves via update_order_meta_data()

Result:
- Generic, reusable components
- Zero plugin-specific code
- Works with any registered fields
- Clean separation of concerns

Next: Phase 3 - PHP MetaFieldsRegistry system
This commit is contained in:
dwindown
2025-11-20 12:32:06 +07:00
parent 9f731bfe0a
commit 0c5efa3efc
4 changed files with 281 additions and 4 deletions

View File

@@ -0,0 +1,157 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface MetaField {
key: string;
label: string;
type: 'text' | 'textarea' | 'number' | 'select' | 'date' | 'checkbox';
options?: Array<{ value: string; label: string }>;
section?: string;
description?: string;
placeholder?: string;
}
interface MetaFieldsProps {
meta: Record<string, any>;
fields: MetaField[];
onChange: (key: string, value: any) => void;
readOnly?: boolean;
}
/**
* MetaFields Component
*
* Generic component to display/edit custom meta fields from plugins.
* Part of Level 1 compatibility - allows plugins using standard WP/WooCommerce
* meta storage to have their fields displayed automatically.
*
* Zero coupling with specific plugins - renders any registered fields.
*/
export function MetaFields({ meta, fields, onChange, readOnly = false }: MetaFieldsProps) {
// Don't render if no fields registered
if (fields.length === 0) {
return null;
}
// Group fields by section
const sections = fields.reduce((acc, field) => {
const section = field.section || 'Additional Fields';
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} className="space-y-2">
<Label htmlFor={field.key}>
{field.label}
{field.description && (
<span className="text-xs text-muted-foreground ml-2">
{field.description}
</span>
)}
</Label>
{field.type === 'text' && (
<Input
id={field.key}
value={meta[field.key] || ''}
onChange={(e) => onChange(field.key, e.target.value)}
disabled={readOnly}
placeholder={field.placeholder}
/>
)}
{field.type === 'textarea' && (
<Textarea
id={field.key}
value={meta[field.key] || ''}
onChange={(e) => onChange(field.key, e.target.value)}
disabled={readOnly}
placeholder={field.placeholder}
rows={4}
/>
)}
{field.type === 'number' && (
<Input
id={field.key}
type="number"
value={meta[field.key] || ''}
onChange={(e) => onChange(field.key, e.target.value)}
disabled={readOnly}
placeholder={field.placeholder}
/>
)}
{field.type === 'date' && (
<Input
id={field.key}
type="date"
value={meta[field.key] || ''}
onChange={(e) => onChange(field.key, e.target.value)}
disabled={readOnly}
/>
)}
{field.type === 'select' && field.options && (
<Select
value={meta[field.key] || ''}
onValueChange={(value) => onChange(field.key, value)}
disabled={readOnly}
>
<SelectTrigger id={field.key}>
<SelectValue placeholder={field.placeholder || 'Select...'} />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === 'checkbox' && (
<div className="flex items-center space-x-2">
<Checkbox
id={field.key}
checked={!!meta[field.key]}
onCheckedChange={(checked) => onChange(field.key, checked)}
disabled={readOnly}
/>
<label
htmlFor={field.key}
className="text-sm cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{field.placeholder || 'Enable'}
</label>
</div>
)}
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import type { MetaField } from '@/components/MetaFields';
interface MetaFieldsRegistry {
orders: MetaField[];
products: MetaField[];
}
// Global registry exposed by PHP via wp_localize_script
declare global {
interface Window {
WooNooWMetaFields?: MetaFieldsRegistry;
}
}
/**
* useMetaFields Hook
*
* Retrieves registered meta fields from global registry (set by PHP).
* Part of Level 1 compatibility - allows plugins to register their fields
* via PHP filters, which are then exposed to the frontend.
*
* Zero coupling with specific plugins - just reads the registry.
*
* @param type - 'orders' or 'products'
* @returns Array of registered meta fields
*
* @example
* ```tsx
* const metaFields = useMetaFields('orders');
*
* return (
* <MetaFields
* meta={order.meta}
* fields={metaFields}
* onChange={handleMetaChange}
* />
* );
* ```
*/
export function useMetaFields(type: 'orders' | 'products'): MetaField[] {
const [fields, setFields] = useState<MetaField[]>([]);
useEffect(() => {
// Get fields from global registry (set by PHP via wp_localize_script)
const registry = window.WooNooWMetaFields || { orders: [], products: [] };
setFields(registry[type] || []);
// Listen for dynamic field registration (for future extensibility)
const handleFieldsUpdated = (e: CustomEvent) => {
if (e.detail.type === type) {
setFields(e.detail.fields);
}
};
window.addEventListener(
'woonoow:meta_fields_updated',
handleFieldsUpdated as EventListener
);
return () => {
window.removeEventListener(
'woonoow:meta_fields_updated',
handleFieldsUpdated as EventListener
);
};
}, [type]);
return fields;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import OrderForm from '@/routes/Orders/partials/OrderForm';
@@ -10,6 +10,8 @@ import { __, sprintf } from '@/lib/i18n';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { Button } from '@/components/ui/button';
import { useFABConfig } from '@/hooks/useFABConfig';
import { MetaFields } from '@/components/MetaFields';
import { useMetaFields } from '@/hooks/useMetaFields';
export default function OrdersEdit() {
const { id } = useParams();
@@ -19,6 +21,10 @@ export default function OrdersEdit() {
const { setPageHeader, clearPageHeader } = usePageHeader();
const formRef = useRef<HTMLFormElement>(null);
// Level 1 compatibility: Meta fields from plugins
const metaFields = useMetaFields('orders');
const [metaData, setMetaData] = useState<Record<string, any>>({});
// Hide FAB on edit page
useFABConfig('none');
@@ -46,6 +52,13 @@ export default function OrdersEdit() {
}, [countriesQ.data]);
const order = orderQ.data || {};
// Sync meta data from order
useEffect(() => {
if (order.meta) {
setMetaData(order.meta);
}
}, [order.meta]);
// Set page header with back button and save button
useEffect(() => {
@@ -104,11 +117,22 @@ export default function OrdersEdit() {
formRef={formRef}
hideSubmitButton={true}
onSubmit={(form) => {
const payload = { ...form } as any;
const payload = { ...form, meta: metaData } as any;
upd.mutate(payload);
}}
/>
{/* Level 1 compatibility: Custom meta fields from plugins */}
{metaFields.length > 0 && (
<MetaFields
meta={metaData}
fields={metaFields}
onChange={(key, value) => {
setMetaData(prev => ({ ...prev, [key]: value }));
}}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '@/lib/api';
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { Skeleton } from '@/components/ui/skeleton';
import { MetaFields } from '@/components/MetaFields';
import { useMetaFields } from '@/hooks/useMetaFields';
export default function ProductEdit() {
const { id } = useParams<{ id: string }>();
@@ -19,6 +21,10 @@ export default function ProductEdit() {
const formRef = useRef<HTMLFormElement>(null);
const { setPageHeader, clearPageHeader } = usePageHeader();
// Level 1 compatibility: Meta fields from plugins
const metaFields = useMetaFields('products');
const [metaData, setMetaData] = useState<Record<string, any>>({});
// Hide FAB on edit product page
useFABConfig('none');
@@ -48,7 +54,9 @@ export default function ProductEdit() {
});
const handleSubmit = async (data: ProductFormData) => {
await updateMutation.mutateAsync(data);
// Merge meta data with form data (Level 1 compatibility)
const payload = { ...data, meta: metaData };
await updateMutation.mutateAsync(payload);
};
// Set page header with back button and save button
@@ -95,6 +103,13 @@ export default function ProductEdit() {
const product = productQ.data;
// Sync meta data from product
useEffect(() => {
if (product?.meta) {
setMetaData(product.meta);
}
}, [product?.meta]);
return (
<div className="space-y-4">
<ProductForm
@@ -104,6 +119,17 @@ export default function ProductEdit() {
formRef={formRef}
hideSubmitButton={true}
/>
{/* Level 1 compatibility: Custom meta fields from plugins */}
{metaFields.length > 0 && (
<MetaFields
meta={metaData}
fields={metaFields}
onChange={(key, value) => {
setMetaData(prev => ({ ...prev, [key]: value }));
}}
/>
)}
</div>
);
}