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