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();
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user