Attribute Renderer Pattern
Overview
An attribute renderer is a widget registered with target CustomAttributeRenderer. Instead of rendering inside a layout group, it replaces the display of a single attribute field cell — either in a view layout row, or as a cell in a list/grid. Two variants exist:
- Read-only renderer: Customizes how an attribute value is displayed (role-based masking, formatted display, etc.). No save.
- Click-to-dialog renderer: Shows a clickable element. Clicking opens a
MuiDialogthat reads and writes data via its ownprotrakApiClientcall — completely independent of the parent form's save flow.
When to Use
- Display a single field with conditional masking (e.g., show real value only to certain roles)
- Replace a plain text cell with a formatted display (colors, badges, calculated text)
- Show a "View Details" link per row that opens a popup with related data
- Allow in-place editing of a single attribute via a dialog without affecting the main form
Applicable Layout Types
| Target | Notes |
|---|---|
CustomAttributeRenderer | Replaces the field cell in view layout or list grid |
ViewLayoutWidget | Can also be placed as a widget in a Details section |
pageContext Shape for Attribute Renderers
pageContext.attribute = {
value: any, // Raw attribute value (typed, e.g. textValue)
getAttributeValue: function, // Returns resolved display string
config: {
options: Array<{ name: string, displayName: string }>, // Picklist options for this attribute
},
options: {
instanceId: string, // ID of the owning instance
versionDetails: {
name: string,
id: string, // Version ID (not instance ID)
// Attribute values of this specific version are available here:
CertificateSerialNumber: { textValue: string },
[anyOtherAttributeName]: AttributeObject,
},
},
}
// The row's instance in a list/grid context
pageContext.currentInstance = {
id: string,
name: string,
attributes: AttributeObject[],
// Attribute values also directly accessible as:
[attributeName]: AttributeObject, // e.g. currentInstance.CertificateStatus.arrayValue[0]
}
// Other attribute values of the owning instance — useful for cross-attribute logic
// without needing to fetch the instance separately
pageContext.sourceInstanceAttrValues = {
[attributeName]: AttributeObject,
}
// Whether the field is currently in edit mode (for view/edit layout renderers)
pageContext.isEditable = boolean;
Variant A — Read-Only Renderer (Role-Based Masking)
function TenderAmountAttributeVisiblilityCustomRender(pageContext) {
const { protrakComponents } = React.useContext(customWidgetContext);
const { InputBox } = protrakComponents;
const { attribute, userData } = pageContext;
const { getAttributeValue } = attribute;
// Roles that are allowed to see the real value
const allowedRoles = ['Admin', 'Billing Engineer', 'Management'];
const roles = userData?.roles || [];
const canViewRealValue = allowedRoles.some((r) => roles.includes(r));
const attributeValue = getAttributeValue();
const displayValue = canViewRealValue ? attributeValue : 0;
return (
<InputBox
type="numeric"
readOnly
value={displayValue}
style={{ width: '100%' }}
/>
);
}
Variant B — Click-to-Dialog with Independent API Save
function ProcessDetailsEquipmentRenderer(pageContext) {
const { protrakComponents } = React.useContext(customWidgetContext);
const { Text, Box } = protrakComponents;
const [showPopup, setShowPopup] = React.useState(false);
return (
<Box>
<Text
style={{ fontWeight: 'bold', cursor: 'pointer', color: '#0078d4' }}
onClick={() => setShowPopup(true)}
title="Click to view process details"
>
Process Details
</Text>
{showPopup && (
<DetailsDialog
onClose={() => setShowPopup(false)}
pageContext={pageContext}
/>
)}
</Box>
);
}
const DetailsDialog = ({ onClose, pageContext }) => {
const { protrakComponents, protrakUtils } =
React.useContext(customWidgetContext);
const { protrakApiClient, useAuthContext, useProtrakApi } = protrakUtils;
const {
MuiDialog,
Button,
ButtonEnums,
Box,
Spinner,
ProtrakFontAwesomeIcon,
} = protrakComponents;
const authContext = useAuthContext();
// Read identifying info from the current row's instance
const instanceId = pageContext.currentInstance.id;
const instanceName = pageContext.currentInstance.attributes.find(
(a) => a.name === 'Name'
)?.textValue;
const [pendingUpdate, setPendingUpdate] = React.useState(null);
const [saving, setSaving] = React.useState(false);
// Fetch data for this instance
const fetchData = React.useCallback(
({ id }) => ({
endpoint: 'programs/GetMyInstanceData/executeCommonProgram',
config: {
method: 'POST',
data: [JSON.stringify({ InstanceId: id })],
},
}),
[]
);
const { state: dataResponse } = useProtrakApi({
requestConfig: fetchData,
id: instanceId,
});
// Independent save — does NOT use onAttributeEdit or saveInstance
const handleSave = async () => {
setSaving(true);
try {
await protrakApiClient(
'instances?lastModified=null',
{
method: 'PUT',
data: {
id: instanceId,
attributes: [{ name: 'Status', arrayValue: [pendingUpdate] }],
},
},
authContext
);
onClose();
} catch {
alert('Save failed. Please try again.');
} finally {
setSaving(false);
}
};
return (
<MuiDialog
dialogTitle={`Details: ${instanceName}`}
dialogWidth="sm"
dialogContainerStyle={{ zIndex: '1', height: '80vh' }}
leftFooter={<Box display="flex" />}
rightFooter={
<Box display="flex" justifyContent="flex-end" width="100%">
<Button
onClick={onClose}
text="Cancel"
appearance={ButtonEnums.Appearance.Primary}
disabled={saving}
/>
<Button
onClick={handleSave}
text={saving ? 'Saving...' : 'Save'}
appearance={ButtonEnums.Appearance.Primary}
disabled={saving || !pendingUpdate}
style={{ marginLeft: '0.5rem' }}
/>
</Box>
}
dialogHeaderButton={
<div onClick={onClose} style={{ cursor: 'pointer' }}>
<ProtrakFontAwesomeIcon
className="fa fa-times"
aria-hidden="true"
style={{
fontSize: '1rem',
padding: '0.2rem 0.35rem',
border: '0.13rem solid',
borderRadius: '50%',
}}
/>
</div>
}
>
{dataResponse.isLoading ? (
<Spinner />
) : (
<div>{/* Render data and collect pendingUpdate via onChange */}</div>
)}
</MuiDialog>
);
};
Important: This Pattern Bypasses Form Save
The click-to-dialog variant calls protrakApiClient PUT instances directly. This is independent of the parent form's save lifecycle:
- Do not call
onAttributeEdit— there is no parent form attribute being edited - Do not call
saveInstance— the save is not a form save - Changes take effect immediately when the dialog confirms, not when the main form saves
Variant C — Masked Value with Toggle-Reveal
Renders a sensitive attribute value as a password field (bullets). A toggle button shows/hides the real value. No API calls, no save.
function CustomMaskedRenderer(pageContext) {
const { value, getAttributeValue } = pageContext.attribute;
const { protrakComponents } = React.useContext(customWidgetContext);
const { InputBox, ProtrakFontAwesomeIcon } = protrakComponents;
const attributeValue = getAttributeValue(value);
const [showMaskedValue, setShowMaskedValue] = React.useState(false);
if (!attributeValue) return <>{'—'}</>;
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ width: 'calc(100% - 3rem)' }}>
<InputBox
type={showMaskedValue ? 'text' : 'password'}
readOnly
value={attributeValue}
width="100%"
style={{ cursor: 'pointer', backgroundColor: 'unset' }}
/>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowMaskedValue((v) => !v);
}}
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
aria-pressed={showMaskedValue}
>
<ProtrakFontAwesomeIcon
className={showMaskedValue ? 'fa fa-eye-slash' : 'fa fa-eye'}
aria-hidden="true"
/>
</button>
</div>
);
}
Key points:
- Use
InputBox type="password"for the masked state — the browser renders bullets natively - Set
readOnlyon theInputBox— this is a display renderer, not an editor - Call
e.stopPropagation()on the toggle click to prevent row-click events in a grid pageContext.isEditablecan be used to adjust layout:width: isEditable ? 'calc(100% - 3rem)' : '100%'
Variant D — Encode/Decode Renderer with Role-Based Visibility
Extends the masked-value pattern with:
- Role-based gating of the reveal buttons (only certain roles can reveal the value)
- A second toggle to base64-decode the revealed value
function EncodeDecodeRenderer(pageContext) {
const { userData } = pageContext;
const { value, getAttributeValue } = pageContext.attribute;
const { protrakComponents } = React.useContext(customWidgetContext);
const { InputBox, ProtrakFontAwesomeIcon } = protrakComponents;
const attributeValue = getAttributeValue(value);
const allowedRoles = ['Tenant Administrator'];
const canReveal = userData?.roles?.some((r) => allowedRoles.includes(r));
const [showValue, setShowValue] = React.useState(false);
const [isDecoded, setIsDecoded] = React.useState(false);
if (!attributeValue) return <>{'—'}</>;
const displayValue =
showValue && isDecoded ? atob(attributeValue) : attributeValue;
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ width: 'calc(100% - 3rem)' }}>
<InputBox
type={showValue ? 'text' : 'password'}
readOnly
value={showValue ? displayValue : attributeValue}
width="100%"
style={{ cursor: 'pointer', backgroundColor: 'unset' }}
/>
</div>
{canReveal && (
<>
{/* Encode/decode toggle — only active when value is revealed */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (showValue) setIsDecoded((d) => !d);
}}
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
<ProtrakFontAwesomeIcon
className={isDecoded ? 'fa fa-lock-open' : 'fa fa-lock'}
aria-hidden="true"
title={isDecoded ? 'Encoded' : 'Decoded'}
/>
</button>
{/* Reveal/hide toggle */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (showValue) setIsDecoded(false); // reset decode when hiding
setShowValue((v) => !v);
}}
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
<ProtrakFontAwesomeIcon
className={showValue ? 'fa fa-eye-slash' : 'fa fa-eye'}
aria-hidden="true"
/>
</button>
</>
)}
</div>
);
}
Variant E — versionDetails-Based Status Renderer
For versioned attributes (e.g., certificates), pageContext.attribute.options.versionDetails contains attribute values of the specific version being rendered (not the instance). This allows the renderer to cross-reference another version-level attribute without an API call.
function DeviceCertificateRevokeHistoryStatusWidget(pageContext) {
const currentInstance = pageContext?.currentInstance || {};
// Attribute from the VERSION being rendered
const certificateSerialNumber =
pageContext?.attribute?.options?.versionDetails?.CertificateSerialNumber?.textValue?.trim() ??
'';
// Attribute from the parent INSTANCE (via sourceInstanceAttrValues)
const revokeHistoryValue =
pageContext?.sourceInstanceAttrValues?.DeviceCertificateRevokeHistoryStatus
?.textValue ?? '';
const isRevoked = React.useMemo(() => {
if (!revokeHistoryValue || !certificateSerialNumber) return false;
return revokeHistoryValue
.split(',')
.map((v) => v.trim())
.includes(certificateSerialNumber);
}, [revokeHistoryValue, certificateSerialNumber]);
// Resolve the picklist display name from currentInstance + attribute config
const certificateStatus = React.useMemo(() => {
const value = currentInstance?.CertificateStatus?.arrayValue?.[0];
const options = pageContext?.attribute?.config?.options ?? [];
const match = options.find((item) => item.name === value);
return match?.displayName || value || null;
}, [currentInstance, pageContext]);
if (isRevoked) {
return <span style={{ fontWeight: 500 }}>Revoked</span>;
}
return certificateStatus ? <span>{certificateStatus}</span> : null;
}
Key points:
versionDetailsholds attribute values of a specific version — useful when the instance has multiple versions and the renderer is bound to onesourceInstanceAttrValuesgives access to all other saved attribute values of the instance without a fetchattribute.config.optionsprovides picklist{ name, displayName }pairs for resolving display names
Real Examples
- Variant A:
TenderAmountAttributeVisiblilityCustomRender.js— Aaryan Devcon - Variant B:
ProcessDetailsEquipmentRenderer.js,ProcessDetailsProjectRenderer.js— GEECI - Variant C:
CustomMaskedRenderer.js— Symmera;ViewPageMaskedRenderer.js— Symmera (isEditable-aware) - Variant D:
EncodeDecodeRenderer.js— Symmera - Variant E:
DeviceCertificateRevokeHistoryStatusWidget.js,DeviceCertificateRevokeHistoryCustomWidget.js— Symmera