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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
||||||
@@ -10,6 +10,8 @@ import { __, sprintf } from '@/lib/i18n';
|
|||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
|
import { MetaFields } from '@/components/MetaFields';
|
||||||
|
import { useMetaFields } from '@/hooks/useMetaFields';
|
||||||
|
|
||||||
export default function OrdersEdit() {
|
export default function OrdersEdit() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -19,6 +21,10 @@ export default function OrdersEdit() {
|
|||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
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
|
// Hide FAB on edit page
|
||||||
useFABConfig('none');
|
useFABConfig('none');
|
||||||
|
|
||||||
@@ -46,6 +52,13 @@ export default function OrdersEdit() {
|
|||||||
}, [countriesQ.data]);
|
}, [countriesQ.data]);
|
||||||
|
|
||||||
const order = orderQ.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
|
// Set page header with back button and save button
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,11 +117,22 @@ export default function OrdersEdit() {
|
|||||||
formRef={formRef}
|
formRef={formRef}
|
||||||
hideSubmitButton={true}
|
hideSubmitButton={true}
|
||||||
onSubmit={(form) => {
|
onSubmit={(form) => {
|
||||||
const payload = { ...form } as any;
|
const payload = { ...form, meta: metaData } as any;
|
||||||
upd.mutate(payload);
|
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>
|
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { MetaFields } from '@/components/MetaFields';
|
||||||
|
import { useMetaFields } from '@/hooks/useMetaFields';
|
||||||
|
|
||||||
export default function ProductEdit() {
|
export default function ProductEdit() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -19,6 +21,10 @@ export default function ProductEdit() {
|
|||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
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
|
// Hide FAB on edit product page
|
||||||
useFABConfig('none');
|
useFABConfig('none');
|
||||||
|
|
||||||
@@ -48,7 +54,9 @@ export default function ProductEdit() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (data: ProductFormData) => {
|
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
|
// Set page header with back button and save button
|
||||||
@@ -95,6 +103,13 @@ export default function ProductEdit() {
|
|||||||
|
|
||||||
const product = productQ.data;
|
const product = productQ.data;
|
||||||
|
|
||||||
|
// Sync meta data from product
|
||||||
|
useEffect(() => {
|
||||||
|
if (product?.meta) {
|
||||||
|
setMetaData(product.meta);
|
||||||
|
}
|
||||||
|
}, [product?.meta]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProductForm
|
<ProductForm
|
||||||
@@ -104,6 +119,17 @@ export default function ProductEdit() {
|
|||||||
formRef={formRef}
|
formRef={formRef}
|
||||||
hideSubmitButton={true}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user