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:
157
admin-spa/src/components/MetaFields.tsx
Normal file
157
admin-spa/src/components/MetaFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
admin-spa/src/hooks/useMetaFields.ts
Normal file
70
admin-spa/src/hooks/useMetaFields.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user