Skip to main content

JSON Attribute Editor Pattern

Overview

The most common edit-layout pattern. The widget manages a complex data structure (table rows, schedule entries, option lists) stored as a JSON string inside a single Text attribute. The widget renders its own UI (editable table, form, list), and every change is pushed into the parent form's save state via onAttributeEdit. When the user clicks the main form's Save button, the JSON is persisted.

When to Use

  • A single Text attribute needs to store structured data (table rows, configuration, options)
  • The standard attribute input field is not sufficient for the required UX (e.g., an editable multi-row table)
  • Data must be included in the main form's save payload — no separate Save button needed in the widget

Applicable Layout Types

TargetNotes
EditLayoutPrimary target — edit mode
CreateLayoutAlso works for initial creation

Four Sub-Variants

Sub-VariantRestores saved state?How initial value is loaded
A — Restore from attributeValuesYesReact.useEffect on attributeValues[attrName].textValue
B — Starts emptyNoReact.useState([]) — always fresh
C — Sub-component delegationYesReads from attributeValues, passes onAttributeEdit as onEdit prop
D — Fetch via APIYesuseProtrakApi GET call to read current saved value

Use Variant A (the most common) unless you have a specific reason not to restore saved state.


function InvoiceLineItemsEditLayout(pageContext) {
const { attributeValues, onAttributeEdit } = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Box, Button, ButtonEnums, Label, TextBox } = protrakComponents;

const ATTR_NAME = 'InvoiceIineItemData';
const COL_HEADERS = [
'Description',
'No. of resources',
'Period',
'Rate',
'Total',
];

const [tableData, setTableData] = React.useState([]);

// Load previously saved value on mount
React.useEffect(() => {
const savedJson = attributeValues[ATTR_NAME]?.textValue;
if (savedJson) {
try {
const parsed = JSON.parse(savedJson);
if (Array.isArray(parsed)) setTableData(parsed);
} catch {
// Ignore malformed JSON
}
}
}, [attributeValues]);

// Push every change into the parent form's dirty state
const pushToForm = (updatedRows) => {
onAttributeEdit(
ATTR_NAME,
{
name: ATTR_NAME,
type: 'Text',
canUpdate: true,
textValue: JSON.stringify(updatedRows),
},
''
);
};

const handleAddRow = () => {
const newRow = COL_HEADERS.reduce((obj, h) => ({ ...obj, [h]: '' }), {});
const updated = [...tableData, newRow];
setTableData(updated);
pushToForm(updated);
};

const handleDeleteRow = (index) => {
const updated = tableData.filter((_, i) => i !== index);
setTableData(updated);
pushToForm(updated);
};

const handleCellChange = (rowIndex, colHeader, newValue) => {
const updated = tableData.map((row, i) =>
i === rowIndex ? { ...row, [colHeader]: newValue } : row
);
setTableData(updated);
pushToForm(updated);
};

const tableStyle = {
borderCollapse: 'collapse',
border: '1px solid #dddddd',
width: '100%',
};
const headerStyle = {
border: '1px solid #dddddd',
padding: '8px',
backgroundColor: '#e9e6eb',
};
const cellStyle = { border: '1px solid #dddddd', padding: '5px' };

return (
<Box style={{ padding: '1rem' }}>
<table style={tableStyle}>
<thead>
<tr style={{ backgroundColor: '#e9e6eb' }}>
{COL_HEADERS.map((h) => (
<th key={h} style={headerStyle}>
<Label style={{ fontWeight: '600', fontSize: 12 }}>{h}</Label>
</th>
))}
<th style={headerStyle}>
<Label>Action</Label>
</th>
</tr>
</thead>
<tbody>
{tableData.map((row, i) => (
<tr key={i} style={{ height: '34px' }}>
{COL_HEADERS.map((header) => (
<td key={header} style={cellStyle}>
<TextBox
value={row[header] || ''}
onEdit={(v) => handleCellChange(i, header, v)}
/>
</td>
))}
<td style={cellStyle}>
<div
onClick={() => handleDeleteRow(i)}
style={{
color: 'var(--error)',
cursor: 'pointer',
marginLeft: '0.5rem',
}}
>
<i className="fa fa-times" aria-hidden="true" />
</div>
</td>
</tr>
))}
</tbody>
</table>
<Box style={{ paddingTop: '0.5rem' }}>
<Button
onClick={handleAddRow}
text="Add Row"
appearance={ButtonEnums.Appearance.Primary}
style={{ width: 'fit-content', padding: '0.5rem' }}
/>
</Box>
</Box>
);
}

Variant B — Starts Empty (No Restore)

Use when the widget always starts from scratch (e.g., configuring options for a new poll that has no prior state).

function CommunityQuickPollOptions(pageContext) {
const { onAttributeEdit } = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Box, Button, ButtonEnums, Label, TextBox } = protrakComponents;

const ATTR_NAME = 'QuickPollOptions';
// NOTE: No useEffect to load from attributeValues — always starts fresh
const [options, setOptions] = React.useState([]);

const pushToForm = (updatedOptions) => {
onAttributeEdit(
ATTR_NAME,
{
name: ATTR_NAME,
type: 'Text',
canUpdate: true,
textValue: JSON.stringify(updatedOptions),
},
''
);
};

const handleAdd = () => {
const updated = [...options, { value: '', text: '' }];
setOptions(updated);
pushToForm(updated);
};

const handleRemove = (index) => {
const updated = options.filter((_, i) => i !== index);
setOptions(updated);
pushToForm(updated);
};

const handleChange = (index, field, val) => {
const updated = options.map((opt, i) =>
i === index ? { ...opt, [field]: val } : opt
);
setOptions(updated);
pushToForm(updated);
};

return (
<Box style={{ display: 'block', padding: '1rem' }}>
<Button
onClick={handleAdd}
text="Add Options"
appearance={ButtonEnums.Appearance.Primary}
style={{ width: 'fit-content', padding: '0.5rem' }}
/>
{options.map((opt, i) => (
<div
key={i}
style={{
display: 'flex',
gap: '1rem',
marginTop: '0.5rem',
alignItems: 'center',
}}
>
<TextBox
value={opt.value}
onEdit={(v) => handleChange(i, 'value', v)}
/>
<TextBox
value={opt.text}
onEdit={(v) => handleChange(i, 'text', v)}
/>
<div
onClick={() => handleRemove(i)}
style={{
color: 'var(--error)',
cursor: 'pointer',
fontSize: '1.2rem',
}}
>
<i className="fa fa-times" aria-hidden="true" />
</div>
</div>
))}
</Box>
);
}

Variant C — Sub-Component Delegation

Use when a third-party or platform composite component (e.g., SurveyCreatorWidget) handles the editor UI. The widget just wires onAttributeEdit to the component's onEdit prop.

function CustomSurveyCreator01(pageContext) {
const { protrakComponents } = React.useContext(customWidgetContext);
const { Box, SurveyCreatorWidget } = protrakComponents;

const { attributeValues, onAttributeEdit } = pageContext;

// Always read initial value from attributeValues — do not leave surveyJSONValue undefined
const surveyJSONValue = attributeValues['SurveyJSON']?.textValue ?? '';

return (
<Box minHeight="80vh">
<SurveyCreatorWidget
surveyJSONValue={surveyJSONValue}
onEdit={onAttributeEdit} // Platform passes (attrName, attrObj, '') directly
/>
</Box>
);
}

Gotcha: Do not omit the initial value read. If surveyJSONValue is undefined, the SurveyCreatorWidget will not restore the previously saved survey.


Variant D — Fetch Initial Value via API

Use when attributeValues is not pre-populated for the attribute you need (e.g., the attribute is on a related instance), or when the widget needs to load additional related data alongside the saved JSON.

function FunctionalTaskSelectionWidget(pageContext) {
const { protrakUtils, protrakComponents } =
React.useContext(customWidgetContext);
const { useProtrakApi } = protrakUtils;
const { Container, Spinner, ErrorModal, Button } = protrakComponents;

const { instanceId, onAttributeEdit } = pageContext;
const ATTR_NAME = 'FunctionalTaskSelection';

const [rows, setRows] = React.useState([]);

// Fetch the current saved value via API
const fetchCurrentValue = React.useCallback(
({ instanceId: id }) => ({
endpoint: `instances/${id}`,
config: {
method: 'GET',
params: { 'attributes[0]': ATTR_NAME },
},
}),
[]
);

const { state: response } = useProtrakApi({
requestConfig: fetchCurrentValue,
instanceId,
});

React.useEffect(() => {
if (response.isFulfilled && response.data?.attributes?.[0]?.textValue) {
const parsed = JSON.parse(response.data.attributes[0].textValue);
// Filter out soft-deleted rows before displaying
const active = (parsed || []).filter((r) => !r.IsDeleted);
setRows(active.map((r) => ({ ...r, IsAdded: false })));
}
}, [response]);

if (response.isLoading || !response.data) {
return (
<Container>
<Spinner />
</Container>
);
}
if (response.isError) {
return <ErrorModal message="Error loading data..." />;
}

const pushToForm = (updatedRows) => {
onAttributeEdit(
ATTR_NAME,
{
name: ATTR_NAME,
type: 'Text',
canUpdate: true,
textValue: JSON.stringify(updatedRows),
},
''
);
};

const handleAdd = (newRow) => {
const updated = [...rows, { ...newRow, IsAdded: true, IsDeleted: false }];
setRows(updated);
pushToForm(updated);
};

const handleDelete = (index) => {
// Soft delete: mark row as deleted rather than removing it
const updated = rows.map((r, i) =>
i === index ? { ...r, IsAdded: false, IsDeleted: true } : r
);
setRows(updated);
pushToForm(updated);
};

return <div>{/* Render rows and add/delete controls */}</div>;
}

Common Inline View Counterpart

When the same attribute needs to be displayed read-only on a view layout, create a separate ViewLayout widget that reads from attributeValues and renders a static table without any editing controls.

function InvoiceLineItemsEditorViewLayout(pageContext) {
const { attributeValues } = pageContext;
const { protrakComponents } = React.useContext(customWidgetContext);
const { Box, Label } = protrakComponents;

const savedJson = attributeValues['InvoiceIineItemData']?.textValue;
const rows = savedJson ? JSON.parse(savedJson) : [];

return (
<Box style={{ padding: '1rem' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
{/* ... read-only rows ... */}
</table>
</Box>
);
}

Real Examples

  • Variant A: InvoiceLineItemsEditLayout.js (C360, MountMeru), CategorywiseSchedule.js (Protrak)
  • Variant B: InvoiceLineItemsEditor.js, CommunityQuickPollOptions.js, ViewLayoutQuickPoll.js (C360, MountMeru)
  • Variant C: CustomSurveyCreator01.js (C360, MountMeru)
  • Variant D: FunctionalTaskSelectionWidget.js (Protrak)