Skip to content

Commit 74c2294

Browse files
committed
refactor(client): render capability-driven form fields from descriptors
Three sections were hand-wiring the same three calls per field — a capability gate, a required lookup, and an explainer — and the commodity dimensions repeated all three once per side. CapabilityFields takes that wiring once and resolves it from the profile. The renderer lives in packages/shared but never imports a field component. The inputs live in apps/web, and a shared package reaching for them would invert the dependency and force every app to carry every other app's fields, so a descriptor supplies its own input through a render callback. ruleField exists because a rule names the thing an operator thinks about rather than the input. The dimension rule names `commodities`, and length, width and height all answer to it while keeping distinct form paths. An unresolved profile shows gated fields rather than hiding them: a form that renders empty and then sprouts inputs once a query settles is worse than briefly showing a field the profile turns out not to want. This matches what isCapabilitySectionVisible already did. General information, service details, the billing field group and the commodity dialog are converted. Move details is not: it is a field array of move cards driving an edit dialog, with no flat field list to describe, and forcing it through a flat renderer would produce worse code than it replaces. The BOL field also stays hand-written — its required state comes from the customer's billing profile, not the mode profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01559YzTFTKd3vV6eJWmSDzi
1 parent d5b417b commit 74c2294

6 files changed

Lines changed: 372 additions & 140 deletions

File tree

client/apps/web/src/routes/shipment/_components/shipment-billing-details.tsx

Lines changed: 71 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import {
66
import { NumberField } from "@/components/fields/number-field";
77
import { Alert, AlertDescription, AlertTitle } from "@trenova/shared/components/ui/alert";
88
import { Badge } from "@trenova/shared/components/ui/badge";
9-
import { FormControl, FormGroup, FormSection } from "@trenova/shared/components/ui/form";
9+
import {
10+
CapabilityFields,
11+
type FieldDescriptor,
12+
} from "@trenova/shared/components/capability-form-section";
13+
import { FormSection } from "@trenova/shared/components/ui/form";
1014
import { Separator } from "@trenova/shared/components/ui/separator";
1115
import { TextShimmer } from "@trenova/shared/components/ui/text-shimmer";
1216
import { useShipmentTotalsPreview } from "@/hooks/use-shipment-totals-preview";
1317
import { queries } from "@/lib/queries";
18+
import { getProfile } from "@trenova/shared/lib/capability";
1419
import { cn, formatCurrency } from "@trenova/shared/lib/utils";
1520
import type { CreditStatus } from "@trenova/shared/types/customer";
1621
import type { GetPreviousRatesRequest, Shipment } from "@trenova/shared/types/shipment";
@@ -256,6 +261,70 @@ export default function ShipmentBillingDetails() {
256261
fuelSurchargeChange,
257262
resolveFuelSurchargeChange,
258263
} = useShipmentTotalsPreview();
264+
const { data: shipmentUIPolicy } = useQuery({ ...queries.shipment.uiPolicy() });
265+
266+
const profile = getProfile(shipmentUIPolicy);
267+
268+
const descriptors: FieldDescriptor[] = [
269+
{
270+
name: "orderId",
271+
render: () => (
272+
<OrderAutocompleteField
273+
control={control}
274+
name="orderId"
275+
label="Order"
276+
placeholder="Select Order"
277+
description="Optionally group this shipment under a commercial order for the same customer. Set on creation; use the order's Add Legs afterwards."
278+
disabled={!customerId}
279+
extraSearchParams={customerId ? { customerId, attachableOnly: "true" } : undefined}
280+
/>
281+
),
282+
},
283+
{
284+
name: "customerId",
285+
render: () => (
286+
<CustomerAutocompleteField
287+
control={control}
288+
name="customerId"
289+
rules={{ required: true }}
290+
label="Customer"
291+
placeholder="Select Customer"
292+
description="Choose the customer who requested this shipment."
293+
/>
294+
),
295+
},
296+
{
297+
name: "formulaTemplateId",
298+
cols: "full",
299+
render: () => (
300+
<FormulaTemplateAutocompleteField
301+
control={control}
302+
name="formulaTemplateId"
303+
label="Rating Method"
304+
placeholder="Select Rating Method"
305+
description="Select how the shipment charges are calculated (e.g., per mile, per stop, flat rate)."
306+
rules={{ required: true }}
307+
/>
308+
),
309+
},
310+
{
311+
name: "baseRate",
312+
cols: "full",
313+
render: () => (
314+
<NumberField
315+
decimalScale={4}
316+
thousandSeparator
317+
control={control}
318+
rules={{ required: true }}
319+
name="baseRate"
320+
label="Base Rate"
321+
placeholder="Enter Base Rate"
322+
description="Per-unit rate used by the formula template to calculate freight charges."
323+
sideText="USD"
324+
/>
325+
),
326+
},
327+
];
259328

260329
return (
261330
<Inner>
@@ -265,52 +334,7 @@ export default function ShipmentBillingDetails() {
265334
/>
266335
{customerId && <CreditHoldAlert customerId={customerId} />}
267336
{shipmentId && <ProfitabilitySummary shipmentId={shipmentId} />}
268-
<FormGroup cols={2}>
269-
<FormControl>
270-
<OrderAutocompleteField
271-
control={control}
272-
name="orderId"
273-
label="Order"
274-
placeholder="Select Order"
275-
description="Optionally group this shipment under a commercial order for the same customer. Set on creation; use the order's Add Legs afterwards."
276-
disabled={!customerId}
277-
extraSearchParams={customerId ? { customerId, attachableOnly: "true" } : undefined}
278-
/>
279-
</FormControl>
280-
<FormControl>
281-
<CustomerAutocompleteField
282-
control={control}
283-
name="customerId"
284-
rules={{ required: true }}
285-
label="Customer"
286-
placeholder="Select Customer"
287-
description="Choose the customer who requested this shipment."
288-
/>
289-
</FormControl>
290-
<FormControl cols="full">
291-
<FormulaTemplateAutocompleteField
292-
control={control}
293-
name="formulaTemplateId"
294-
label="Rating Method"
295-
placeholder="Select Rating Method"
296-
description="Select how the shipment charges are calculated (e.g., per mile, per stop, flat rate)."
297-
rules={{ required: true }}
298-
/>
299-
</FormControl>
300-
<FormControl cols="full">
301-
<NumberField
302-
decimalScale={4}
303-
thousandSeparator
304-
control={control}
305-
rules={{ required: true }}
306-
name="baseRate"
307-
label="Base Rate"
308-
placeholder="Enter Base Rate"
309-
description="Per-unit rate used by the formula template to calculate freight charges."
310-
sideText="USD"
311-
/>
312-
</FormControl>
313-
</FormGroup>
337+
<CapabilityFields descriptors={descriptors} profile={profile} />
314338

315339
<ChargeSummary isCalculating={isCalculating} error={totalsError} />
316340
<RatingBreakdownCard />

client/apps/web/src/routes/shipment/_components/shipment-commodities.tsx

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ import {
2626
CAPABILITIES,
2727
getProfile,
2828
isCapabilitySectionVisible,
29-
isFieldRequired,
3029
} from "@trenova/shared/lib/capability";
3130
import { describeCommodityDimensions } from "@trenova/shared/lib/permit";
31+
import {
32+
CapabilityFields,
33+
type FieldDescriptor,
34+
} from "@trenova/shared/components/capability-form-section";
3235
import { apiService } from "@/services/api";
3336
import type { Commodity } from "@trenova/shared/types/commodity";
3437
import type { ResolvedModeProfile, Shipment } from "@trenova/shared/types/shipment";
@@ -71,7 +74,35 @@ function CommodityDialog({
7174
const { control, setValue, getValues, setError, clearErrors } = useFormContext<Shipment>();
7275
const [saving, setSaving] = useState(false);
7376
const showDimensions = isCapabilitySectionVisible(profile, CAPABILITIES.dimensionalCargo);
74-
const dimensionsRequired = isFieldRequired(profile, "commodities");
77+
78+
// The three sides share one rule, one capability gate and one explainer, so
79+
// they are described once and rendered by the shared renderer rather than
80+
// hand-wired three times.
81+
const dimensionDescriptors: FieldDescriptor[] = (
82+
[
83+
["lengthFeet", "Length"],
84+
["widthFeet", "Width"],
85+
["heightFeet", "Height"],
86+
] as const
87+
).map(([field, label]) => ({
88+
name: `commodities.${index}.${field}`,
89+
// The mode profile's dimension rule names `commodities`, not the individual
90+
// sides, so that is what resolves the required state.
91+
ruleField: "commodities",
92+
capability: CAPABILITIES.dimensionalCargo,
93+
// One explainer under the group rather than three identical popovers.
94+
hideExplainer: true,
95+
render: ({ required }) => (
96+
<NumberField
97+
control={control}
98+
name={`commodities.${index}.${field}`}
99+
label={label}
100+
placeholder="0"
101+
sideText="ft"
102+
rules={{ required }}
103+
/>
104+
),
105+
}));
75106

76107
function handleCommoditySelected(option: Commodity | null) {
77108
setValue(`commodities.${index}.commodity`, option ?? undefined);
@@ -178,43 +209,8 @@ function CommodityDialog({
178209
/>
179210
</FormControl>
180211
</FormGroup>
181-
{showDimensions && (
182-
<FormGroup cols={3}>
183-
<FormControl>
184-
<NumberField
185-
control={control}
186-
name={`commodities.${index}.lengthFeet`}
187-
label="Length"
188-
placeholder="0"
189-
sideText="ft"
190-
rules={{ required: dimensionsRequired }}
191-
/>
192-
</FormControl>
193-
<FormControl>
194-
<NumberField
195-
control={control}
196-
name={`commodities.${index}.widthFeet`}
197-
label="Width"
198-
placeholder="0"
199-
sideText="ft"
200-
rules={{ required: dimensionsRequired }}
201-
/>
202-
</FormControl>
203-
<FormControl>
204-
<NumberField
205-
control={control}
206-
name={`commodities.${index}.heightFeet`}
207-
label="Height"
208-
placeholder="0"
209-
sideText="ft"
210-
rules={{ required: dimensionsRequired }}
211-
/>
212-
</FormControl>
213-
<FormControl cols="full">
214-
<CapabilityExplainer profile={profile} field="commodities" />
215-
</FormControl>
216-
</FormGroup>
217-
)}
212+
<CapabilityFields descriptors={dimensionDescriptors} profile={profile} cols={3} />
213+
{showDimensions && <CapabilityExplainer profile={profile} field="commodities" />}
218214
<DialogFooter>
219215
<Button type="button" variant="outline" onClick={onCancel} disabled={saving}>
220216
Cancel

client/apps/web/src/routes/shipment/_components/shipment-general-information.tsx

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { InputField } from "@/components/fields/input-field";
22
import { NumberField } from "@/components/fields/number-field";
3-
import { CapabilityExplainer } from "@trenova/shared/components/capability-explainer";
4-
import { FormControl, FormGroup, FormSection } from "@trenova/shared/components/ui/form";
53
import {
6-
CAPABILITIES,
7-
getProfile,
8-
isFieldRequired,
9-
isCapabilitySectionVisible,
10-
} from "@trenova/shared/lib/capability";
4+
CapabilityFields,
5+
type FieldDescriptor,
6+
} from "@trenova/shared/components/capability-form-section";
7+
import { FormControl, FormGroup, FormSection } from "@trenova/shared/components/ui/form";
8+
import { CAPABILITIES, getProfile } from "@trenova/shared/lib/capability";
119
import { ApiRequestError } from "@trenova/shared/lib/api";
1210
import { queries } from "@/lib/queries";
1311
import { apiService } from "@/services/api";
@@ -33,45 +31,49 @@ export default function ShipmentGeneralInformation() {
3331
const { data: shipmentUIPolicy } = useQuery({ ...queries.shipment.uiPolicy() });
3432

3533
const profile = getProfile(shipmentUIPolicy);
36-
const showTemperature = isCapabilitySectionVisible(
37-
profile,
38-
CAPABILITIES.temperatureControl,
39-
);
40-
const temperatureRequired = isFieldRequired(profile, "temperatureMin");
34+
35+
const descriptors: FieldDescriptor[] = [
36+
{
37+
name: "temperatureMin",
38+
capability: CAPABILITIES.temperatureControl,
39+
render: ({ required }) => (
40+
<NumberField
41+
control={control}
42+
name="temperatureMin"
43+
description="The minimum temperature for the shipment."
44+
label="Temperature Min"
45+
placeholder="Enter Temperature Min"
46+
sideText="°F"
47+
rules={{ required }}
48+
/>
49+
),
50+
},
51+
{
52+
name: "temperatureMax",
53+
capability: CAPABILITIES.temperatureControl,
54+
render: ({ required }) => (
55+
<NumberField
56+
control={control}
57+
name="temperatureMax"
58+
label="Temperature Max"
59+
description="The maximum temperature for the shipment."
60+
placeholder="Enter Temperature Max"
61+
sideText="°F"
62+
rules={{ required }}
63+
/>
64+
),
65+
},
66+
];
4167

4268
return (
4369
<Inner>
70+
{/* The BOL field stays outside the descriptor list: its required state
71+
comes from the customer's billing profile, not from the mode profile,
72+
so it has nothing for the renderer to resolve. */}
4473
<FormGroup cols={2}>
4574
<BOLField />
46-
{showTemperature && (
47-
<>
48-
<FormControl>
49-
<NumberField
50-
control={control}
51-
name="temperatureMin"
52-
description="The minimum temperature for the shipment."
53-
label="Temperature Min"
54-
placeholder="Enter Temperature Min"
55-
sideText="°F"
56-
rules={{ required: temperatureRequired }}
57-
/>
58-
<CapabilityExplainer profile={profile} field="temperatureMin" />
59-
</FormControl>
60-
<FormControl>
61-
<NumberField
62-
control={control}
63-
name="temperatureMax"
64-
label="Temperature Max"
65-
description="The maximum temperature for the shipment."
66-
placeholder="Enter Temperature Max"
67-
sideText="°F"
68-
rules={{ required: isFieldRequired(profile, "temperatureMax") }}
69-
/>
70-
<CapabilityExplainer profile={profile} field="temperatureMax" />
71-
</FormControl>
72-
</>
73-
)}
7475
</FormGroup>
76+
<CapabilityFields descriptors={descriptors} profile={profile} />
7577
</Inner>
7678
);
7779
}
@@ -120,7 +122,14 @@ export function BOLField() {
120122
return () => {
121123
if (bolCheckTimer.current != null) clearTimeout(bolCheckTimer.current);
122124
};
123-
}, [bol, shipmentId, shipmentUIPolicy?.checkForDuplicateBols, setError, clearErrors, getFieldState]);
125+
}, [
126+
bol,
127+
shipmentId,
128+
shipmentUIPolicy?.checkForDuplicateBols,
129+
setError,
130+
clearErrors,
131+
getFieldState,
132+
]);
124133

125134
const bolRequired = billingProfile?.enforceCustomerBillingReq && billingProfile?.requireBOLNumber;
126135

0 commit comments

Comments
 (0)