docs: Update progress and SOP with CRUD pattern
Updated documentation with latest progress and standardized CRUD pattern. PROGRESS_NOTE.md Updates: - Email notification enhancements (variable dropdown, card reorganization) - Card styling fixes (success = green, not purple) - List support verification - Product CRUD backend API complete (600+ lines) - All endpoints: list, get, create, update, delete - Full variant support for variable products - Categories, tags, attributes endpoints PROJECT_SOP.md Updates: - Added Section 6.9: CRUD Module Pattern (Standard Template) - Complete file structure template - Backend API pattern with code examples - Frontend index/create/edit page patterns - Comprehensive checklist for new modules - Based on Orders module analysis - Ready to use for Products, Customers, Coupons, etc. Benefits: - Consistent pattern across all modules - Faster development (copy-paste template) - Standardized UX and code structure - Clear checklist for implementation - Reference implementation documented
This commit is contained in:
448
PROJECT_SOP.md
448
PROJECT_SOP.md
@@ -1157,6 +1157,454 @@ Use Orders as the template for building new core modules.
|
||||
|
||||
---
|
||||
|
||||
## 6.9 CRUD Module Pattern (Standard Template)
|
||||
|
||||
**All CRUD modules (Orders, Products, Customers, Coupons, etc.) MUST follow this exact pattern for consistency.**
|
||||
|
||||
### 📁 File Structure
|
||||
|
||||
```
|
||||
admin-spa/src/routes/{Module}/
|
||||
├── index.tsx # List view (table + filters)
|
||||
├── New.tsx # Create new item
|
||||
├── Edit.tsx # Edit existing item
|
||||
├── Detail.tsx # View item details (optional)
|
||||
├── components/ # Module-specific components
|
||||
│ ├── {Module}Card.tsx # Mobile card view
|
||||
│ ├── FilterBottomSheet.tsx # Mobile filters
|
||||
│ └── SearchBar.tsx # Search component
|
||||
└── partials/ # Shared form components
|
||||
└── {Module}Form.tsx # Reusable form for create/edit
|
||||
```
|
||||
|
||||
### 🎯 Backend API Pattern
|
||||
|
||||
**File:** `includes/Api/{Module}Controller.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
namespace WooNooW\Api;
|
||||
|
||||
class {Module}Controller {
|
||||
|
||||
public static function register_routes() {
|
||||
// List
|
||||
register_rest_route('woonoow/v1', '/{module}', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_{module}'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||
]);
|
||||
|
||||
// Single
|
||||
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_{item}'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||
]);
|
||||
|
||||
// Create
|
||||
register_rest_route('woonoow/v1', '/{module}', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'create_{item}'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||
]);
|
||||
|
||||
// Update
|
||||
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||
'methods' => 'PUT',
|
||||
'callback' => [__CLASS__, 'update_{item}'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||
]);
|
||||
|
||||
// Delete
|
||||
register_rest_route('woonoow/v1', '/{module}/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_{item}'],
|
||||
'permission_callback' => [Permissions::class, 'check_admin'],
|
||||
]);
|
||||
}
|
||||
|
||||
// List with pagination & filters
|
||||
public static function get_{module}(WP_REST_Request $request) {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = min(100, max(1, (int) ($request->get_param('per_page') ?: 20)));
|
||||
$search = $request->get_param('search');
|
||||
$status = $request->get_param('status');
|
||||
$orderby = $request->get_param('orderby') ?: 'date';
|
||||
$order = $request->get_param('order') ?: 'DESC';
|
||||
|
||||
// Query logic here
|
||||
|
||||
return new WP_REST_Response([
|
||||
'rows' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'pages' => $max_pages,
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Register in Routes.php:**
|
||||
```php
|
||||
use WooNooW\Api\{Module}Controller;
|
||||
|
||||
// In rest_api_init:
|
||||
{Module}Controller::register_routes();
|
||||
```
|
||||
|
||||
### 🎨 Frontend Index Page Pattern
|
||||
|
||||
**File:** `admin-spa/src/routes/{Module}/index.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { setQuery, getQuery } from '@/lib/query-params';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
export default function {Module}Index() {
|
||||
useFABConfig('{module}'); // Enable FAB for create
|
||||
|
||||
const initial = getQuery();
|
||||
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
// Sync URL params
|
||||
React.useEffect(() => {
|
||||
setQuery({ page, status });
|
||||
}, [page, status]);
|
||||
|
||||
// Fetch data
|
||||
const q = useQuery({
|
||||
queryKey: ['{module}', { page, perPage, status }],
|
||||
queryFn: () => api.get('/{module}', {
|
||||
page, per_page: perPage, status
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const data = q.data as undefined | { rows: any[]; total: number };
|
||||
|
||||
// Filter by search
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const rows = data?.rows;
|
||||
if (!rows) return [];
|
||||
if (!searchQuery.trim()) return rows;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rows.filter((item: any) =>
|
||||
item.name?.toLowerCase().includes(query) ||
|
||||
item.id?.toString().includes(query)
|
||||
);
|
||||
}, [data, searchQuery]);
|
||||
|
||||
// Bulk delete
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(id => api.del(`/{module}/${id}`))
|
||||
);
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
return { total: ids.length, failed };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const { total, failed } = result;
|
||||
if (failed === 0) {
|
||||
toast.success(__('Items deleted successfully'));
|
||||
} else if (failed < total) {
|
||||
toast.warning(__(`${total - failed} deleted, ${failed} failed`));
|
||||
} else {
|
||||
toast.error(__('Failed to delete items'));
|
||||
}
|
||||
setSelectedIds([]);
|
||||
setShowDeleteDialog(false);
|
||||
q.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
// Checkbox handlers
|
||||
const allIds = filteredItems.map(r => r.id) || [];
|
||||
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(allSelected ? [] : allIds);
|
||||
};
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full pb-4">
|
||||
{/* Desktop: Filters */}
|
||||
<div className="hidden md:block rounded-lg border p-4">
|
||||
{/* Filter controls */}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Search + Filter */}
|
||||
<div className="md:hidden">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
onFilterClick={() => setFilterSheetOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<div className="hidden md:block">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<th>{__('Name')}</th>
|
||||
<th>{__('Status')}</th>
|
||||
<th>{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td><Checkbox checked={selectedIds.includes(item.id)} onCheckedChange={() => toggleRow(item.id)} /></td>
|
||||
<td>{item.name}</td>
|
||||
<td><StatusBadge value={item.status} /></td>
|
||||
<td><Link to={`/{module}/${item.id}`}>{__('View')}</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Cards */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{filteredItems.map(item => (
|
||||
<{Module}Card key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
{/* Dialog content */}
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 📝 Frontend Create Page Pattern
|
||||
|
||||
**File:** `admin-spa/src/routes/{Module}/New.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {Module}Form from './partials/{Module}Form';
|
||||
|
||||
export default function {Module}New() {
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useFABConfig('none'); // Hide FAB on create page
|
||||
|
||||
const mutate = useMutation({
|
||||
mutationFn: (data: any) => api.post('/{module}', data),
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: ['{module}'] });
|
||||
showSuccessToast(__('Item created successfully'));
|
||||
nav('/{module}');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Set page header
|
||||
useEffect(() => {
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => nav('/{module}')}>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={mutate.isPending}
|
||||
>
|
||||
{mutate.isPending ? __('Creating...') : __('Create')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
setPageHeader(__('New {Item}'), actions);
|
||||
return () => clearPageHeader();
|
||||
}, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<{Module}Form
|
||||
mode="create"
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
onSubmit={(form) => mutate.mutate(form)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ✏️ Frontend Edit Page Pattern
|
||||
|
||||
**File:** `admin-spa/src/routes/{Module}/Edit.tsx`
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {Module}Form from './partials/{Module}Form';
|
||||
|
||||
export default function {Module}Edit() {
|
||||
const { id } = useParams();
|
||||
const itemId = Number(id);
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useFABConfig('none');
|
||||
|
||||
const itemQ = useQuery({
|
||||
queryKey: ['{item}', itemId],
|
||||
enabled: Number.isFinite(itemId),
|
||||
queryFn: () => api.get(`/{module}/${itemId}`)
|
||||
});
|
||||
|
||||
const upd = useMutation({
|
||||
mutationFn: (payload: any) => api.put(`/{module}/${itemId}`, payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['{module}'] });
|
||||
qc.invalidateQueries({ queryKey: ['{item}', itemId] });
|
||||
showSuccessToast(__('Item updated successfully'));
|
||||
nav(`/{module}/${itemId}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
showErrorToast(error);
|
||||
}
|
||||
});
|
||||
|
||||
const item = itemQ.data || {};
|
||||
|
||||
// Set page header
|
||||
useEffect(() => {
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => nav(`/{module}/${itemId}`)}>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={upd.isPending}
|
||||
>
|
||||
{upd.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
setPageHeader(__('Edit {Item}'), actions);
|
||||
return () => clearPageHeader();
|
||||
}, [itemId, upd.isPending, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
if (!Number.isFinite(itemId)) {
|
||||
return <div className="p-4 text-sm text-red-600">{__('Invalid ID')}</div>;
|
||||
}
|
||||
|
||||
if (itemQ.isLoading) {
|
||||
return <LoadingState message={__('Loading...')} />;
|
||||
}
|
||||
|
||||
if (itemQ.isError) {
|
||||
return <ErrorCard
|
||||
title={__('Failed to load item')}
|
||||
message={getPageLoadErrorMessage(itemQ.error)}
|
||||
onRetry={() => itemQ.refetch()}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<{Module}Form
|
||||
mode="edit"
|
||||
initial={item}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
onSubmit={(form) => upd.mutate(form)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 📋 Checklist for New CRUD Module
|
||||
|
||||
**Backend:**
|
||||
- [ ] Create `{Module}Controller.php` with all CRUD endpoints
|
||||
- [ ] Register routes in `Routes.php`
|
||||
- [ ] Add permission checks (`Permissions::check_admin`)
|
||||
- [ ] Implement pagination, filters, search
|
||||
- [ ] Return consistent response format
|
||||
- [ ] Add i18n for all error messages
|
||||
|
||||
**Frontend:**
|
||||
- [ ] Create `routes/{Module}/index.tsx` (list view)
|
||||
- [ ] Create `routes/{Module}/New.tsx` (create)
|
||||
- [ ] Create `routes/{Module}/Edit.tsx` (edit)
|
||||
- [ ] Create `routes/{Module}/Detail.tsx` (optional view)
|
||||
- [ ] Create `components/{Module}Card.tsx` (mobile)
|
||||
- [ ] Create `partials/{Module}Form.tsx` (reusable form)
|
||||
- [ ] Add to navigation tree (`nav/tree.ts`)
|
||||
- [ ] Configure FAB (`useFABConfig`)
|
||||
- [ ] Add all i18n strings
|
||||
- [ ] Implement bulk delete
|
||||
- [ ] Add filters (status, date, search)
|
||||
- [ ] Add pagination
|
||||
- [ ] Test mobile responsive
|
||||
- [ ] Test error states
|
||||
- [ ] Test loading states
|
||||
|
||||
**Testing:**
|
||||
- [ ] Create item
|
||||
- [ ] Edit item
|
||||
- [ ] Delete item
|
||||
- [ ] Bulk delete
|
||||
- [ ] Search
|
||||
- [ ] Filter by status
|
||||
- [ ] Pagination
|
||||
- [ ] Mobile view
|
||||
- [ ] Error handling
|
||||
- [ ] Permission checks
|
||||
|
||||
---
|
||||
|
||||
## 7. 🎨 Admin Interface Modes
|
||||
|
||||
WooNooW provides **three distinct admin interface modes** to accommodate different workflows and user preferences:
|
||||
|
||||
Reference in New Issue
Block a user