Multi-Attribute Calculator Pattern
Overview
A widget that manages multiple attributes simultaneously with calculated derived values. It reads initial values from attributeValues, performs calculations when any input changes, and pushes every field — including calculated totals — into the parent form's dirty state via onAttributeEdit. This is also the richest save integration pattern, combining all three save mechanisms.
When to Use
- Multiple numeric (or text) attributes are inter-related through formulas (e.g., subtotals, GST, grand total)
- The calculation logic is too complex for a declarative expression attribute
- Optionally, the widget needs to initiate save itself (not wait for the user to click the form's Save button)
- Optionally, the widget needs to show a spinner while the parent form is saving
Applicable Layout Types
| Target | Notes |
|---|---|
EditLayout | Primary target — reads and writes multiple attributes |
CreateLayout | Can also be used for complex create forms |
Pattern Structure
- Initialize one
React.useStateper editable field, reading initial values fromattributeValues - Define calculation functions that derive values from the input fields
- On each field change: update local state, run calculations, and call
onAttributeEditfor every affected attribute (inputs + derived values) - Guard with
saveOperationState.isLoading— show a spinner while the form is saving - (Optional) Render a Save button that calls
saveInstance
Code Example
function paymentlayout(pageContext) {
const { protrakUtils, protrakComponents } =
React.useContext(customWidgetContext);
const { useProtrakApi } = protrakUtils;
const { Container, Spinner, Button, ButtonEnums, TextBox, H3 } =
protrakComponents;
const { attributeValues, onAttributeEdit, saveInstance, saveOperationState } =
pageContext;
// Attribute name constants
const AGREEMENT_COST = 'AgreementCost';
const GST_PERCENT = 'GST';
const GST_VALUE = 'GSTvalue';
const STAMP_PERCENT = 'CStampDuty';
const STAMP_COST = 'CStampDutyCost';
const LEGAL = 'Legal';
const MAINTENANCE = 'MaintenanceCost';
const CLUB_CHARGES = 'Clubcharges';
const GRAND_TOTAL = 'CGrandTotal';
// Initialize state from saved attribute values
const [agreement, setAgreement] = React.useState(
attributeValues[AGREEMENT_COST]?.numericValue || 0
);
const [gstPercent] = React.useState(
attributeValues[GST_PERCENT]?.numericValue || 5
);
const [stampPercent] = React.useState(
attributeValues[STAMP_PERCENT]?.numericValue || 0
);
const [legal, setLegal] = React.useState(
attributeValues[LEGAL]?.numericValue || 0
);
const [maintenance, setMaintenance] = React.useState(
attributeValues[MAINTENANCE]?.numericValue || 0
);
const [clubCharges, setClubCharges] = React.useState(
attributeValues[CLUB_CHARGES]?.numericValue || 0
);
const pushNumeric = (name, value) => {
onAttributeEdit(
name,
{ name, type: 'Numeric', canUpdate: true, numericValue: value },
''
);
};
const calculateAndPush = (
newAgreement,
newLegal,
newMaintenance,
newClub
) => {
const gstValue = (gstPercent / 100) * newAgreement;
const stampCost = (stampPercent / 100) * newAgreement;
const grandTotal =
newAgreement + gstValue + stampCost + newLegal + newMaintenance + newClub;
pushNumeric(GST_VALUE, gstValue);
pushNumeric(STAMP_COST, stampCost);
pushNumeric(GRAND_TOTAL, grandTotal);
};
const handleAgreementChange = (value) => {
const num = parseFloat(value) || 0;
setAgreement(num);
pushNumeric(AGREEMENT_COST, num);
calculateAndPush(num, legal, maintenance, clubCharges);
};
const handleLegalChange = (value) => {
const num = parseFloat(value) || 0;
setLegal(num);
pushNumeric(LEGAL, num);
calculateAndPush(agreement, num, maintenance, clubCharges);
};
// Mechanism C: show spinner while parent form is saving
if (saveOperationState && saveOperationState.isLoading) {
return (
<Container>
<Spinner small />
</Container>
);
}
const gstValue = (gstPercent / 100) * agreement;
const stampCost = (stampPercent / 100) * agreement;
const grandTotal =
agreement + gstValue + stampCost + legal + maintenance + clubCharges;
const tableStyle = { borderCollapse: 'collapse', width: '100%' };
const headerStyle = {
border: '1px solid #dddddd',
textAlign: 'left',
padding: '8px',
backgroundColor: '#f9f9f9',
width: '30%',
};
const cellStyle = {
border: '1px solid #dddddd',
textAlign: 'left',
padding: '8px',
fontSize: '1.1rem',
};
return (
<div style={{ padding: '1rem' }}>
<H3>Payment Summary</H3>
<table style={tableStyle}>
<tbody>
<tr>
<th style={headerStyle}>Agreement Cost</th>
<td style={cellStyle}>
<TextBox
value={String(agreement)}
onEdit={handleAgreementChange}
/>
</td>
</tr>
<tr>
<th style={headerStyle}>Legal Charges</th>
<td style={cellStyle}>
<TextBox value={String(legal)} onEdit={handleLegalChange} />
</td>
</tr>
<tr>
<th style={headerStyle}>GST ({gstPercent}%)</th>
<td style={cellStyle}>{gstValue.toFixed(2)}</td>
</tr>
<tr>
<th style={headerStyle}>Stamp Duty ({stampPercent}%)</th>
<td style={cellStyle}>{stampCost.toFixed(2)}</td>
</tr>
<tr style={{ fontWeight: 'bold' }}>
<th style={headerStyle}>Grand Total</th>
<td style={cellStyle}>{grandTotal.toFixed(2)}</td>
</tr>
</tbody>
</table>
{/* Mechanism B: widget-initiated save */}
<Button
title="Save"
text="Save"
outline
appearance={ButtonEnums.Appearance.Primary}
size={ButtonEnums.Size.Small}
onClick={saveInstance}
style={{ marginTop: '1rem' }}
/>
</div>
);
}
Rules for Derived (Calculated) Attributes
- Always push calculated attributes via
onAttributeEdit— never just update local state. The server needs to receive the final computed value. - Push all affected attributes in the same handler — calling
onAttributeEditmultiple times in a single event handler is safe; the platform accumulates them all. - Derived attributes may not need their own
useState— compute them on-the-fly from other state values during render, and push them to the form in handlers.
Fetching Additional Data
The widget can also fetch auxiliary data (e.g., the flat booking status from another instance) using useProtrakApi:
const fetchRelatedInstance = React.useCallback(
({ pageContext: ctx }) => ({
endpoint: `instances/${ctx.instanceId}`,
config: { method: 'GET', params: { 'attributes[0]': 'State' } },
}),
[]
);
const { state: flatBooked } = useProtrakApi({
requestConfig: fetchRelatedInstance,
pageContext,
});
React.useEffect(() => {
if (flatBooked.isFulfilled && flatBooked.data) {
// Use fetched data to set local state or disable fields
setIsDisabled(flatBooked.data.state?.name !== 'FlatBooked');
}
}, [flatBooked]);
Real Examples
paymentlayout.js— Construction360, MountMeru