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