Skip to main content

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​

TargetNotes
EditLayoutPrimary target — reads and writes multiple attributes
CreateLayoutCan also be used for complex create forms

Pattern Structure​

  1. Initialize one React.useState per editable field, reading initial values from attributeValues
  2. Define calculation functions that derive values from the input fields
  3. On each field change: update local state, run calculations, and call onAttributeEdit for every affected attribute (inputs + derived values)
  4. Guard with saveOperationState.isLoading — show a spinner while the form is saving
  5. (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 onAttributeEdit multiple 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