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:
@@ -142,35 +142,11 @@ export default function OrderShow() {
|
||||
retryPaymentMutation.mutate();
|
||||
}
|
||||
|
||||
// Set page header with actions
|
||||
// Hide contextual header on detail page (has its own inline header)
|
||||
useEffect(() => {
|
||||
if (!order || isPrintMode) {
|
||||
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();
|
||||
}, [order, isPrintMode, id, setPageHeader, clearPageHeader]);
|
||||
}, [clearPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrintMode || !qrRef.current || !order) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
||||
@@ -17,6 +17,7 @@ export default function OrdersEdit() {
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Hide FAB on edit page
|
||||
useFABConfig('none');
|
||||
@@ -62,19 +63,28 @@ export default function OrdersEdit() {
|
||||
|
||||
const order = orderQ.data || {};
|
||||
|
||||
// Set page header with back button
|
||||
// Set page header with back button and save button
|
||||
useEffect(() => {
|
||||
const backButton = (
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => nav(`/orders/${orderId}`)}>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={upd.isPending}
|
||||
>
|
||||
{upd.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
const title = order.number
|
||||
? sprintf(__('Edit Order #%s'), order.number)
|
||||
: __('Edit Order');
|
||||
setPageHeader(title, backButton);
|
||||
setPageHeader(title, actions);
|
||||
return () => clearPageHeader();
|
||||
}, [order.number, orderId, setPageHeader, clearPageHeader, nav]);
|
||||
}, [order.number, orderId, upd.isPending, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -91,6 +101,8 @@ export default function OrdersEdit() {
|
||||
shippings={(shippingsQ.data || [])}
|
||||
itemsEditable={['pending', 'on-hold', 'failed', 'draft'].includes(order.status)}
|
||||
showCoupons
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
onSubmit={(form) => {
|
||||
const payload = { ...form } as any;
|
||||
upd.mutate(payload);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { OrdersApi } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -14,6 +14,7 @@ export default function OrdersNew() {
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Hide FAB on new order page
|
||||
useFABConfig('none');
|
||||
@@ -44,16 +45,25 @@ export default function OrdersNew() {
|
||||
// Prefer global store currency injected by PHP
|
||||
const { currency: storeCurrency, symbol: storeSymbol } = getStoreCurrency();
|
||||
|
||||
// Set page header with back button
|
||||
// Set page header with back button and create button
|
||||
useEffect(() => {
|
||||
const backButton = (
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => nav('/orders')}>
|
||||
{__('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();
|
||||
}, [setPageHeader, clearPageHeader, nav]);
|
||||
}, [mutate.isPending, setPageHeader, clearPageHeader, nav]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -67,6 +77,8 @@ export default function OrdersNew() {
|
||||
defaultCountry={countriesQ.data?.default_country}
|
||||
payments={(payments.data || [])}
|
||||
shippings={(shippings.data || [])}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
onSubmit={(form) => {
|
||||
mutate.mutate(form as any);
|
||||
}}
|
||||
|
||||
@@ -92,6 +92,8 @@ type Props = {
|
||||
rightTop?: React.ReactNode;
|
||||
itemsEditable?: boolean;
|
||||
showCoupons?: boolean;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
hideSubmitButton?: boolean;
|
||||
};
|
||||
|
||||
const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed'];
|
||||
@@ -113,6 +115,8 @@ export default function OrderForm({
|
||||
showCoupons = true,
|
||||
currency,
|
||||
currencySymbol,
|
||||
formRef,
|
||||
hideSubmitButton = false,
|
||||
}: Props) {
|
||||
const oneCountryOnly = countries.length === 1;
|
||||
const firstCountry = countries[0]?.code || 'US';
|
||||
@@ -346,7 +350,7 @@ export default function OrderForm({
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Items and Coupons */}
|
||||
@@ -933,9 +937,11 @@ export default function OrderForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideSubmitButton && (
|
||||
<Button type="submit" disabled={submitting} className="w-full">
|
||||
{submitting ? (mode === 'edit' ? __('Saving…') : __('Creating…')) : (mode === 'edit' ? __('Save changes') : __('Create order'))}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user