feat: Wishlist settings cleanup + Categories/Tags/Attributes CRUD pages

Wishlist Settings Cleanup:
- Removed wishlist_page setting (not needed for SPA architecture)
- Marked advanced features as 'Coming Soon' with disabled flag:
  * Wishlist Sharing
  * Back in Stock Notifications
  * Multiple Wishlists
- Added disabled prop support to SchemaField toggle component
- Kept only working features: guest wishlist, show in header, max items, add to cart button

Product Taxonomy CRUD Pages:
Built full CRUD interfaces for all three taxonomy types:

1. Categories (/products/categories):
   - Table view with search
   - Create/Edit dialog with name, slug, description
   - Delete with confirmation
   - Product count display
   - Parent category support

2. Tags (/products/tags):
   - Table view with search
   - Create/Edit dialog with name, slug, description
   - Delete with confirmation
   - Product count display

3. Attributes (/products/attributes):
   - Table view with search
   - Create/Edit dialog with label, slug, type, orderby
   - Delete with confirmation
   - Type selector (Select/Text)
   - Sort order selector (Custom/Name/ID)

All pages include:
- React Query for data fetching/mutations
- Toast notifications for success/error
- Loading states
- Empty states
- Responsive tables
- Dialog forms with validation

Files Modified:
- includes/Modules/WishlistSettings.php (removed page selector, marked advanced as coming soon)
- admin-spa/src/components/forms/SchemaField.tsx (added disabled prop)
- admin-spa/src/routes/Products/Categories.tsx (full CRUD)
- admin-spa/src/routes/Products/Tags.tsx (full CRUD)
- admin-spa/src/routes/Products/Attributes.tsx (full CRUD)
- admin-spa/src/components/nav/SubmenuBar.tsx (removed debug logging)
- admin-spa/dist/app.js (rebuilt)

Result:
 Wishlist settings now clearly show what's implemented vs coming soon
 Categories/Tags/Attributes pages fully functional
 Professional CRUD interfaces matching admin design
 All taxonomy management now in SPA
This commit is contained in:
Dwindi Ramadhana
2025-12-26 23:43:40 +07:00
parent 1c6b76efb4
commit 4095d2a70c
6 changed files with 743 additions and 55 deletions

View File

@@ -1,11 +1,264 @@
import React from 'react';
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { api } from '@/lib/api';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
interface Attribute {
attribute_id: number;
attribute_name: string;
attribute_label: string;
attribute_type: string;
attribute_orderby: string;
attribute_public: number;
}
export default function ProductAttributes() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [editingAttribute, setEditingAttribute] = useState<Attribute | null>(null);
const [formData, setFormData] = useState({
name: '',
label: '',
type: 'select',
orderby: 'menu_order',
public: 1
});
const { data: attributes = [], isLoading } = useQuery<Attribute[]>({
queryKey: ['product-attributes'],
queryFn: async () => {
const response = await fetch(`${api.root()}/products/attributes`, {
headers: { 'X-WP-Nonce': api.nonce() },
});
return response.json();
},
});
const createMutation = useMutation({
mutationFn: (data: any) => api.post('/products/attributes', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
toast.success(__('Attribute created successfully'));
handleCloseDialog();
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to create attribute'));
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/products/attributes/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
toast.success(__('Attribute updated successfully'));
handleCloseDialog();
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to update attribute'));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.del(`/products/attributes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
toast.success(__('Attribute deleted successfully'));
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to delete attribute'));
},
});
const handleOpenDialog = (attribute?: Attribute) => {
if (attribute) {
setEditingAttribute(attribute);
setFormData({
name: attribute.attribute_name,
label: attribute.attribute_label,
type: attribute.attribute_type,
orderby: attribute.attribute_orderby,
public: attribute.attribute_public,
});
} else {
setEditingAttribute(null);
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
}
setDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingAttribute(null);
setFormData({ name: '', label: '', type: 'select', orderby: 'menu_order', public: 1 });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingAttribute) {
updateMutation.mutate({ id: editingAttribute.attribute_id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleDelete = (id: number) => {
if (confirm(__('Are you sure you want to delete this attribute?'))) {
deleteMutation.mutate(id);
}
};
const filteredAttributes = attributes.filter((attr) =>
attr.attribute_label.toLowerCase().includes(search.toLowerCase()) ||
attr.attribute_name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<h1 className="text-xl font-semibold mb-3">{__('Product Attributes')}</h1>
<p className="opacity-70">{__('Coming soon — SPA attributes manager.')}</p>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{__('Product Attributes')}</h1>
<Button onClick={() => handleOpenDialog()}>
<Plus className="w-4 h-4 mr-2" />
{__('Add Attribute')}
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={__('Search attributes...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{isLoading ? (
<div className="text-center py-8">
<p className="text-muted-foreground">{__('Loading attributes...')}</p>
</div>
) : filteredAttributes.length === 0 ? (
<div className="text-center py-8 border rounded-lg">
<p className="text-muted-foreground">{__('No attributes found')}</p>
</div>
) : (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 font-medium">{__('Name')}</th>
<th className="text-left p-4 font-medium">{__('Slug')}</th>
<th className="text-left p-4 font-medium">{__('Type')}</th>
<th className="text-center p-4 font-medium">{__('Order By')}</th>
<th className="text-right p-4 font-medium">{__('Actions')}</th>
</tr>
</thead>
<tbody>
{filteredAttributes.map((attribute) => (
<tr key={attribute.attribute_id} className="border-t hover:bg-muted/30">
<td className="p-4 font-medium">{attribute.attribute_label}</td>
<td className="p-4 text-muted-foreground">{attribute.attribute_name}</td>
<td className="p-4 text-sm capitalize">{attribute.attribute_type}</td>
<td className="p-4 text-center text-sm">{attribute.attribute_orderby}</td>
<td className="p-4">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog(attribute)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(attribute.attribute_id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingAttribute ? __('Edit Attribute') : __('Add Attribute')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="label">{__('Label')}</Label>
<Input
id="label"
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
placeholder={__('e.g., Color, Size')}
required
/>
</div>
<div>
<Label htmlFor="name">{__('Slug')}</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={__('Leave empty to auto-generate')}
/>
</div>
<div>
<Label htmlFor="type">{__('Type')}</Label>
<Select value={formData.type} onValueChange={(value) => setFormData({ ...formData, type: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select">{__('Select')}</SelectItem>
<SelectItem value="text">{__('Text')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="orderby">{__('Default Sort Order')}</Label>
<Select value={formData.orderby} onValueChange={(value) => setFormData({ ...formData, orderby: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="menu_order">{__('Custom ordering')}</SelectItem>
<SelectItem value="name">{__('Name')}</SelectItem>
<SelectItem value="name_num">{__('Name (numeric)')}</SelectItem>
<SelectItem value="id">{__('Term ID')}</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCloseDialog}>
{__('Cancel')}
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{editingAttribute ? __('Update') : __('Create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}