Problem: React warning about missing keys persisted despite keys being present.
Root cause: term_id/attribute_id could be undefined during initial render before API response.
Solution: Add fallback keys using array index when primary ID is undefined:
- Categories: key={category.term_id || `category-${index}`}
- Tags: key={tag.term_id || `tag-${index}`}
- Attributes: key={attribute.attribute_id || `attribute-${index}`}
This ensures React always has a valid key, even during the brief moment
when data is loading or if the API returns malformed data.
Files Modified:
- admin-spa/src/routes/Products/Categories.tsx
- admin-spa/src/routes/Products/Tags.tsx
- admin-spa/src/routes/Products/Attributes.tsx
Result:
✅ React key warnings should be resolved
✅ Graceful handling of edge cases where IDs might be missing
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
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, DialogDescription } 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 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, index) => (
|
|
<tr key={attribute.attribute_id || `attribute-${index}`} 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>
|
|
<DialogDescription>
|
|
{editingAttribute ? __('Update attribute information') : __('Create a new product attribute')}
|
|
</DialogDescription>
|
|
</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>
|
|
);
|
|
}
|