Reusable React controls for Workflows member, assignee, and date
fields. The package uses @sanity/ui but has no dependency on Sanity Studio or
the App SDK, so the same components can be used in either integration.
Render the components inside a Sanity UI ThemeProvider. Member controls take
account-global user ids (sanityUserId) — the one identity namespace
@sanity/workflow-engine stores and compares. The integration owns the join
from its host's project-scoped member records to these rows — for example
useProjectMembers in @sanity/workflow-sdk; the Studio plugin ships its
own equivalent — built on this package's projectMemberRow.
Supply the project directory explicitly. App SDK integrations can pass the
result of useProjectMembers(projectId) from @sanity/workflow-sdk; Studio
integrations can adapt their existing project-member query to the same shape.
import {MemberPicker, type ProjectMember} from '@sanity/workflow-components'
const members: ProjectMember[] = [
{
id: 'gAccountUserId',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
roles: [{name: 'editor', title: 'Editor'}],
},
]
export function OwnerPicker() {
return (
<MemberPicker
error={undefined}
loading={false}
members={members}
onSelect={(member) => console.log(member?.id)}
selectedIds={new Set()}
unassignRow
/>
)
}
MemberPicker reports a ProjectMember or null for the optional unassigned
row. AssigneePicker is a controlled multi-value input: value is the current
engine Assignee array and onChange receives the complete next array after a
role or member is toggled. AssigneeBadge,
AssigneeBadges, MemberAvatar, and MemberAvatarGroup provide matching
read-only presentation.
AssigneeStack presents a scope's whole assignment as one overlapping cluster
for a dense list row: members render as round avatars, roles as square ones
through RoleAvatar, which initials a role from its label and colours it from
its name. Roles render last, so a cluster past three items collapses to roles
rather than to faces. Both take display data only — resolving user ids to
people, and naming everyone in full once the cluster abbreviates, stay with the
integration.
Neither avatar renders a tooltip or carries an accessible name of its own, so a
host rendering RoleAvatar outside AssigneeStack owns naming it.
A ProjectMember's roles carry both spellings a host supplies. name is the
machine name: the only spelling the engine stores in an assignee value and
compares against an actor's roles. title is the project's human label, present
when the host's membership records include one. Every surface showing a role
reads the title, falling back to the name when there is no title, so a record
without one reads as its machine name rather than as a blank label.
ProjectMembersState.roles is the project's own catalog of assignable roles,
supplied by the host alongside the members. It is the authority on a role's
title and the only account of a role nobody currently holds, which no membership
record can carry. A RoleVocabulary — the catalog plus the members — is what
resolution takes.
roleLabel resolves the name/title pair. memberRoleFor and roleLabelFor
resolve the other direction, from a name stored in an assignee value back to the
role record or straight to its label. The catalog answers first, and any name it
does not account for falls through to the roles members hold — whether the
catalog is still loading, could not be read, or simply omits that name — so a
label degrades to a worse title rather than to none. A name in neither resolves
to itself.
roleMemberCount reports the distinct people who can fulfill any supplied role
under a workflow definition's aliases. Pass members, requiredRoles, and the
definition's normalized roleAliases; the result includes alias-only fulfillers
and deduplicates them by account-global user id. It does not expand or rewrite a
member's literal roles, which remain the source for profile display and search.
Without eligibleRoles, AssigneePicker offers the whole catalog, so a role
with no holders is assignable. It always merges in any name the current value
carries that the catalog does not account for — a role deleted since it was
assigned would otherwise have no row, and so no way to be seen or removed.
Those value-derived rows sort in by label like every other one, and stay listed
for the picker's lifetime once present, so clearing one does not delete the row
under the pointer; a host that renders the picker in a popover gets them dropped
on the next open, since closing unmounts the content. A host mounting it inline
should remount it (or change its key) to get the same reset. MemberPicker
takes no catalog: it lists members only.
Both pickers match role names and titles in their search, so typing a role narrows the list to its holders, and both list members by display name. A host's own member order is the sequence people joined the project, which on a large project puts whoever joined first at the top rather than anyone findable.
A picker row shows a name and nothing else, because a name is not enough to tell
two members apart — the account behind it lives in MemberPreview, which the
row previews beside itself on hover: a larger avatar badged with the person's
identity provider, their email, and the roles they hold. Rows share one tooltip
delay group, so the wait is paid once and moving along the list swaps the preview
instead of making the reader wait again at every row.
The preview belongs to the pointer and does not follow the keyboard-active row. Driving it from both reads as broken from either: with the keyboard holding one row, moving the mouse onto another leaves the first preview up through the hover delay, so the reader watches the wrong card and then a jump. The active row has its own highlight.
MemberAvatar takes an optional loginProvider to badge the mark onto its
corner; leave it off in a dense list, where the mark is larger than the corner it
would sit in.
Marks exist for Google, GitHub, and any saml--prefixed deployment, which reads
as an SSO wordmark. Any other provider badges nothing at all — no mark and no
circle — so an unrecognised value costs a reader nothing rather than showing an
empty badge.
Beyond the value-derived rows described above, neither picker reorders or
re-sections its list in response to the value: selecting a catalogued role or a
member decides what is checkmarked and nothing else. AssigneePicker can opt
into field-value eligibility with eligibleRoles and roleAliases. It then
offers users who fulfil at least one listed role and only the listed collective
roles. The picker names the active eligible roles and distinguishes an empty
eligible set from a search with no matches. A current value that no longer
qualifies stays visible so an editor can remove or replace it. This is distinct
from action permission and field editability; callers should pass eligibility
only from an assignment field's declared roles facet.
DatePicker emits local JavaScript Date values after complete calendar or
time gestures. Storage remains the caller's responsibility, so the control
works with an engine field edit, an action parameter, or local application
state.
import {ClearableDatePicker} from '@sanity/workflow-components'
;<ClearableDatePicker
clearLabel="Clear due date"
onClear={() => setDueDate(undefined)}
onPick={setDueDate}
value={dueDate}
/>
Set selectTime when time-of-day is part of the workflow field.