feat: Move action buttons to contextual headers for CRUD pages

Implemented proper contextual header pattern for all Order CRUD pages.

Problem:
- New/Edit pages had action buttons at bottom of form
- Detail page showed duplicate headers (contextual + inline)
- Not following mobile-first best practices

Solution: [Back] Page Title [Action]

1. Edit Order Page
   Header: [Back] Edit Order #337 [Save]

   Implementation:
   - Added formRef to trigger form submit from header
   - Save button in contextual header
   - Removed submit button from form bottom
   - Button shows loading state during save

   Changes:
   - Edit.tsx: Added formRef, updated header with Save button
   - OrderForm.tsx: Added formRef and hideSubmitButton props
   - Form submit triggered via formRef.current.requestSubmit()

2. New Order Page
   Header: [Back] New Order [Create]

   Implementation:
   - Added formRef to trigger form submit from header
   - Create button in contextual header
   - Removed submit button from form bottom
   - Button shows loading state during creation

   Changes:
   - New.tsx: Added formRef, updated header with Create button
   - Same OrderForm props as Edit page

3. Order Detail Page
   Header: (hidden)

   Implementation:
   - Cleared contextual header completely
   - Detail page has its own inline header with actions
   - Inline header: [Back] Order #337 [Print] [Invoice] [Label] [Edit]

   Changes:
   - Detail.tsx: clearPageHeader() in useEffect
   - No duplicate headers

OrderForm Component Updates:
- Added formRef prop (React.RefObject<HTMLFormElement>)
- Added hideSubmitButton prop (boolean)
- Form element accepts ref: <form ref={formRef}>
- Submit button conditionally rendered: {!hideSubmitButton && <Button...>}
- Backward compatible (both props optional)

Benefits:
 Consistent header pattern across all CRUD pages
 Action buttons always visible (sticky header)
 Better mobile UX (no scrolling to find buttons)
 Loading states in header buttons
 Clean, modern interface
 Follows industry standards (Gmail, Notion, Linear)

Files Modified:
- routes/Orders/New.tsx
- routes/Orders/Edit.tsx
- routes/Orders/Detail.tsx
- routes/Orders/partials/OrderForm.tsx

Result:
 New/Edit: Action buttons in contextual header
 Detail: No contextual header (has inline header)
 Professional, mobile-first UX! 🎯
This commit is contained in:
dwindown
2025-11-08 15:38:38 +07:00
parent 4e764f9368
commit 58d508eb4e
4 changed files with 53 additions and 47 deletions

View File

@@ -142,35 +142,11 @@ export default function OrderShow() {
retryPaymentMutation.mutate(); retryPaymentMutation.mutate();
} }
// Set page header with actions // Hide contextual header on detail page (has its own inline header)
useEffect(() => { useEffect(() => {
if (!order || isPrintMode) { clearPageHeader();
clearPageHeader();
return;
}
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={printInvoice}>
<FileText className="w-4 h-4 mr-1" />
{__('Invoice')}
</Button>
<Link to={`/orders/${id}/edit`}>
<Button size="sm">
<Pencil className="w-4 h-4 mr-1" />
{__('Edit')}
</Button>
</Link>
</div>
);
setPageHeader(
order.number ? `${__('Order')} #${order.number}` : __('Order'),
actions
);
return () => clearPageHeader(); return () => clearPageHeader();
}, [order, isPrintMode, id, setPageHeader, clearPageHeader]); }, [clearPageHeader]);
useEffect(() => { useEffect(() => {
if (!isPrintMode || !qrRef.current || !order) return; if (!isPrintMode || !qrRef.current || !order) return;

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef } 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';
@@ -17,6 +17,7 @@ export default function OrdersEdit() {
const nav = useNavigate(); const nav = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader(); const { setPageHeader, clearPageHeader } = usePageHeader();
const formRef = useRef<HTMLFormElement>(null);
// Hide FAB on edit page // Hide FAB on edit page
useFABConfig('none'); useFABConfig('none');
@@ -62,19 +63,28 @@ export default function OrdersEdit() {
const order = orderQ.data || {}; const order = orderQ.data || {};
// Set page header with back button // Set page header with back button and save button
useEffect(() => { useEffect(() => {
const backButton = ( const actions = (
<Button size="sm" variant="ghost" onClick={() => nav(`/orders/${orderId}`)}> <div className="flex gap-2">
{__('Back')} <Button size="sm" variant="ghost" onClick={() => nav(`/orders/${orderId}`)}>
</Button> {__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={upd.isPending}
>
{upd.isPending ? __('Saving...') : __('Save')}
</Button>
</div>
); );
const title = order.number const title = order.number
? sprintf(__('Edit Order #%s'), order.number) ? sprintf(__('Edit Order #%s'), order.number)
: __('Edit Order'); : __('Edit Order');
setPageHeader(title, backButton); setPageHeader(title, actions);
return () => clearPageHeader(); return () => clearPageHeader();
}, [order.number, orderId, setPageHeader, clearPageHeader, nav]); }, [order.number, orderId, upd.isPending, setPageHeader, clearPageHeader, nav]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -91,6 +101,8 @@ export default function OrdersEdit() {
shippings={(shippingsQ.data || [])} shippings={(shippingsQ.data || [])}
itemsEditable={['pending', 'on-hold', 'failed', 'draft'].includes(order.status)} itemsEditable={['pending', 'on-hold', 'failed', 'draft'].includes(order.status)}
showCoupons showCoupons
formRef={formRef}
hideSubmitButton={true}
onSubmit={(form) => { onSubmit={(form) => {
const payload = { ...form } as any; const payload = { ...form } as any;
upd.mutate(payload); upd.mutate(payload);

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { OrdersApi } from '@/lib/api'; import { OrdersApi } from '@/lib/api';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -14,6 +14,7 @@ export default function OrdersNew() {
const nav = useNavigate(); const nav = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader(); const { setPageHeader, clearPageHeader } = usePageHeader();
const formRef = useRef<HTMLFormElement>(null);
// Hide FAB on new order page // Hide FAB on new order page
useFABConfig('none'); useFABConfig('none');
@@ -44,16 +45,25 @@ export default function OrdersNew() {
// Prefer global store currency injected by PHP // Prefer global store currency injected by PHP
const { currency: storeCurrency, symbol: storeSymbol } = getStoreCurrency(); const { currency: storeCurrency, symbol: storeSymbol } = getStoreCurrency();
// Set page header with back button // Set page header with back button and create button
useEffect(() => { useEffect(() => {
const backButton = ( const actions = (
<Button size="sm" variant="ghost" onClick={() => nav('/orders')}> <div className="flex gap-2">
{__('Back')} <Button size="sm" variant="ghost" onClick={() => nav('/orders')}>
</Button> {__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={mutate.isPending}
>
{mutate.isPending ? __('Creating...') : __('Create')}
</Button>
</div>
); );
setPageHeader(__('New Order'), backButton); setPageHeader(__('New Order'), actions);
return () => clearPageHeader(); return () => clearPageHeader();
}, [setPageHeader, clearPageHeader, nav]); }, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -67,6 +77,8 @@ export default function OrdersNew() {
defaultCountry={countriesQ.data?.default_country} defaultCountry={countriesQ.data?.default_country}
payments={(payments.data || [])} payments={(payments.data || [])}
shippings={(shippings.data || [])} shippings={(shippings.data || [])}
formRef={formRef}
hideSubmitButton={true}
onSubmit={(form) => { onSubmit={(form) => {
mutate.mutate(form as any); mutate.mutate(form as any);
}} }}

View File

@@ -92,6 +92,8 @@ type Props = {
rightTop?: React.ReactNode; rightTop?: React.ReactNode;
itemsEditable?: boolean; itemsEditable?: boolean;
showCoupons?: boolean; showCoupons?: boolean;
formRef?: React.RefObject<HTMLFormElement>;
hideSubmitButton?: boolean;
}; };
const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed']; const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed'];
@@ -113,6 +115,8 @@ export default function OrderForm({
showCoupons = true, showCoupons = true,
currency, currency,
currencySymbol, currencySymbol,
formRef,
hideSubmitButton = false,
}: Props) { }: Props) {
const oneCountryOnly = countries.length === 1; const oneCountryOnly = countries.length === 1;
const firstCountry = countries[0]?.code || 'US'; const firstCountry = countries[0]?.code || 'US';
@@ -346,7 +350,7 @@ export default function OrderForm({
} }
return ( return (
<form onSubmit={handleSubmit} className={cn('grid grid-cols-1 lg:grid-cols-3 gap-6', className)}> <form ref={formRef} onSubmit={handleSubmit} className={cn('grid grid-cols-1 lg:grid-cols-3 gap-6', className)}>
{/* Left: Order details */} {/* Left: Order details */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Items and Coupons */} {/* Items and Coupons */}
@@ -933,9 +937,11 @@ export default function OrderForm({
</div> </div>
)} )}
<Button type="submit" disabled={submitting} className="w-full"> {!hideSubmitButton && (
{submitting ? (mode === 'edit' ? __('Saving…') : __('Creating…')) : (mode === 'edit' ? __('Save changes') : __('Create order'))} <Button type="submit" disabled={submitting} className="w-full">
</Button> {submitting ? (mode === 'edit' ? __('Saving…') : __('Creating…')) : (mode === 'edit' ? __('Save changes') : __('Create order'))}
</Button>
)}
</div> </div>
</aside> </aside>
</form> </form>