Skip to main content

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​

MechanismHow it worksWhen to use
A — onAttributeEditMarks an attribute dirty in the parent form. Included in save payload when user clicks Save.Every editable widget — this is the core mechanism
B — saveInstanceWidget has its own Save button that directly triggers the parent form's saveWidget needs to initiate save (not wait for user to click form Save)
C — saveOperationStateWidget observes the parent form's in-progress save and shows its own loading stateWidget 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: true is always required in the attribute object — omitting it silently drops the change
  • The third argument is always '' — never omit it
  • The type field must match the attribute's registered type: 'Text', 'Numeric', 'Boolean', 'Date', 'Picklist', 'Reference', 'User'
  • Call onAttributeEdit on 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: saveInstance is typically used alongside onAttributeEdit. The widget pushes changes via onAttributeEdit, then calls saveInstance when 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 call onAttributeEdit
  • Independent API save — if the widget has its own dialog that calls protrakApiClient PUT instances directly (Pattern 7, Pattern 15), do not use onAttributeEdit — the two save paths are mutually exclusive