Save Integration
Widgets that edit data need to communicate changes to the parent form's save lifecycle. There are three mechanisms. They can be combined freely.
Overview
| Mechanism | How it works | When to use |
|---|---|---|
A — onAttributeEdit | Marks an attribute dirty in the parent form. Included in save payload when user clicks Save. | Every editable widget — this is the core mechanism |
B — saveInstance | Widget has its own Save button that directly triggers the parent form's save | Widget needs to initiate save (not wait for user to click form Save) |
C — saveOperationState | Widget observes the parent form's in-progress save and shows its own loading state | Widget needs visual feedback while save is processing |
Mechanism A — onAttributeEdit
The universal mechanism. Call this every time local widget state changes. The platform accumulates all dirty attributes and persists them together when the form saves.
function MyEditWidget(pageContext) {
const { onAttributeEdit } = pageContext;
const handleChange = (newValue) => {
// Update local state
setLocalData(newValue);
// Push change into parent form's dirty state
onAttributeEdit(
'MyAttributeName',
{
name: 'MyAttributeName',
type: 'Text', // Match the attribute's registered type
canUpdate: true, // Always required
textValue: JSON.stringify(newValue),
},
'' // Third argument is always empty string
);
};
}
Key rules
canUpdate: trueis always required in the attribute object — omitting it silently drops the change- The third argument is always
''— never omit it - The
typefield must match the attribute's registered type:'Text','Numeric','Boolean','Date','Picklist','Reference','User' - Call
onAttributeEditon every change, not just on blur — the form does not poll widget state
Mechanism B — saveInstance
Add a Save button inside the widget that calls pageContext.saveInstance(). This triggers the full form save — all dirty attributes (including those set via onAttributeEdit) are persisted.
function MyWidgetWithSaveButton(pageContext) {
const { onAttributeEdit, saveInstance } = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Button, ButtonEnums } = protrakComponents;
return (
<div>
{/* ... widget content ... */}
<Button
title="Save"
text="Save"
appearance={ButtonEnums.Appearance.Primary}
onClick={saveInstance}
/>
</div>
);
}
Note:
saveInstanceis typically used alongsideonAttributeEdit. The widget pushes changes viaonAttributeEdit, then callssaveInstancewhen ready to commit.
Mechanism C — saveOperationState
Observe the parent form's save lifecycle to show a spinner while saving. saveOperationState is injected into pageContext by the platform and updates reactively.
function MyWidget(pageContext) {
const { saveOperationState } = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Container, Spinner } = protrakComponents;
// Show spinner while the parent form is saving
if (saveOperationState && saveOperationState.isLoading) {
return (
<Container>
<Spinner small />
</Container>
);
}
return <div>{/* ... normal widget content ... */}</div>;
}
saveOperationState shape
saveOperationState: {
isLoading: boolean, // true while save is in progress
isError: boolean, // true if the last save failed
isFulfilled: boolean, // true if the last save succeeded
}
Combining All Three Mechanisms
The richest pattern — used in paymentlayout.js (C360, MountMeru):
function MyFullSaveWidget(pageContext) {
const {
attributeValues,
onAttributeEdit, // Mechanism A
saveInstance, // Mechanism B
saveOperationState, // Mechanism C
} = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Container, Spinner, Button, ButtonEnums } = protrakComponents;
const [fieldA, setFieldA] = React.useState(
attributeValues['FieldA']?.numericValue || 0
);
// Mechanism C: visual feedback during save
if (saveOperationState && saveOperationState.isLoading) {
return (
<Container>
<Spinner small />
</Container>
);
}
const handleChange = (value) => {
const numVal = parseFloat(value) || 0;
setFieldA(numVal);
// Mechanism A: push change into form
onAttributeEdit(
'FieldA',
{
name: 'FieldA',
type: 'Numeric',
canUpdate: true,
numericValue: numVal,
},
''
);
};
return (
<div>
{/* ... inputs ... */}
{/* Mechanism B: widget-initiated save */}
<Button
text="Save"
appearance={ButtonEnums.Appearance.Primary}
onClick={saveInstance}
/>
</div>
);
}
When NOT to Call onAttributeEdit
- View-only widgets — if
editMode === 'view'and the widget only displays data, do not callonAttributeEdit - Independent API save — if the widget has its own dialog that calls
protrakApiClient PUT instancesdirectly (Pattern 7, Pattern 15), do not useonAttributeEdit— the two save paths are mutually exclusive