@get-asset/sdk

Contacts

Headless compound for the Customers and Vendors directories. Both are the same surface, so one compound serves both and kind picks the endpoints, the scopes and the copy — "customer" or "vendor". It lists the business's contacts (name, email, phone, active/disabled) with cursor pagination behind LoadMore, filters by a name search and by active/disabled, and owns the whole write flow: CreateTrigger opens a blank draft, EditTrigger opens an existing contact in the same draft, and Save POSTs or PATCHes accordingly. Only name is required — a cleared email or phone is sent as null so it actually clears. DisableTrigger flips a contact’s is_disabled and the disabled listing is a separate view.

Anatomy

Import the primitive and compose the parts you need.

<Contacts.Root kind="customer">
{/* Header controls — always rendered, outside the state gates */}
<Contacts.Title />
<Contacts.Search />
<Contacts.StatusFilter>
<Select> {/* any Select-Root-shaped child */}
<Contacts.StatusFilterItems>
<Contacts.StatusFilterItem />
</Contacts.StatusFilterItems>
</Select>
</Contacts.StatusFilter>
<Contacts.CreateTrigger />
{/* The add panel — a brand-new draft, above the table */}
<Contacts.Create>
<Contacts.EditorTitle />
<Contacts.EditorError />
<Contacts.NameInput />
<Contacts.EmailInput />
<Contacts.PhoneInput />
<Contacts.Cancel />
<Contacts.Save />
</Contacts.Create>
<Contacts.Loading>
{/* skeleton rows */}
</Contacts.Loading>
<Contacts.Error>
<Contacts.ErrorMessage />
<Contacts.Retry />
</Contacts.Error>
<Contacts.Empty>
{/* empty copy — plus <Contacts.ClearSearch /> when a search is active */}
</Contacts.Empty>
<Contacts.Success>
<Contacts.Rows>
<Contacts.Name />
<Contacts.Email />
<Contacts.Phone />
<Contacts.Status />
<Contacts.EditTrigger />
<Contacts.DisableTrigger />
{/* Editing expands from the row it belongs to */}
<Contacts.RowEditing>
<Contacts.EditorTitle />
{/* the same fields as the add panel */}
</Contacts.RowEditing>
</Contacts.Rows>
<Contacts.LoadMore />
</Contacts.Success>
</Contacts.Root>

Required scopes

The access token used by AssetProvider must include these scopes for this primitive to fetch data successfully:

  • customer:read
  • customer:create
  • customer:update
  • vendor:read
  • vendor:create
  • vendor:update

API reference

Root props

PropTypeDefaultDescription
kindrequired"customer" | "vendor"Which directory this is. Picks the endpoints, the labels (Customer/Vendor, singular and plural), and the half of the scopes that gets used.
pageSizenumber25Rows per request. Further pages are fetched by LoadMore.
contactsContactOverride[]Override the fetched contacts (skips the API call). The search and active/disabled filters are still applied, client-side, so an offline story behaves like the live component. Used by storybook/tests.
state"loading" | "error" | "empty"Force a specific state for testing. Also skips the API call.
hasMorebooleanForce the LoadMore affordance to render. Only consulted alongside contacts — live, pagination comes from the API's next page token.
defaultCreatingbooleanfalseOpen the add-contact draft on mount.
childrenrequiredReactNodeCompound parts.

Parts

Container

Always rendered. Fetches the list, owns the draft and the create/update/disable mutations, and provides context. Contacts is a management surface rather than an entitled report, so it has no locked state.

PartDescription
Contacts.RootOwns fetching, pagination, the search and status filters, the open draft, and the disable toggle.

Header controls

Always available — these read the root context, not a row, so they sit outside the state gates and keep working while the list is loading, empty or errored.

PartDescription
Contacts.Title
asChild?
The directory's heading — "Customers" / "Vendors".
Contacts.Search
asChild?, placeholder?
Name filter input. Injects value, onChange and a kind-aware placeholder ("Search customers by name") into an input-shaped child; renders a bare <input type="search"> otherwise. Typing is debounced before the list is re-requested (see Search and filters).
Contacts.ClearSearch
asChild?
Clears the name filter. Renders nothing when the search is already empty, which makes it safe to drop into an Empty state unconditionally. Default label "Clear search".
Contacts.CreateTrigger
asChild?
Opens a blank draft. Sets aria-expanded while any draft is open. Default label "Add customer" / "Add vendor".
Contacts.StatusFilterWires the active/disabled filter to a single Select-Root-shaped child, cloning it with value ("active" | "disabled") and onValueChange.
Contacts.StatusFilterItemsRenders its children once per option, providing the option as context. Put it inside the select's content.
Contacts.StatusFilterItem
asChild?
One option — sets value and renders the label ("Active" / "Disabled").

Success

Renders when at least one contact matches the current filters.

PartDescription
Contacts.SuccessState wrapper. Compose the table inside.
Contacts.RowsIterates the contacts, providing a per-row context to each.
Contacts.Name
asChild?
The contact's name.
Contacts.Email
asChild?
The contact's email, or an em dash when there isn't one. Sets data-empty on the fallback so a stacked layout can drop the line instead.
Contacts.Phone
asChild?
The contact's phone, with the same em-dash fallback and data-empty.
Contacts.Status
asChild?
Renders "Active" / "Disabled" and sets data-status. Every row in a given view carries the same status — the listing is one set or the other.
Contacts.RowDisabledRenders children only when the row's contact is disabled.
Contacts.EditTrigger
asChild?
Opens the row's contact in the editor. Names itself "Edit <name>" via aria-label unless you pass your own. Default label "Edit".
Contacts.DisableTrigger
asChild?
Disables an active contact, or re-enables a disabled one. Label and aria-label follow the row ("Disable" / "Re-enable"), and the trigger disables itself while that row's write is in flight.

Editor

One draft at a time, shared by add and edit. Opening or cancelling a draft clears the previous save failure, so a fresh panel never inherits one.

PartDescription
Contacts.EditorRenders children while any draft is open — create or edit.
Contacts.CreateRenders children only while a new contact is being authored (the add panel).
Contacts.RowEditingRenders children only when this row is the one being edited. Use inside Rows to expand the editor from the row.
Contacts.EditorTitle
asChild?
"Add customer" / "Edit customer", matching the open draft.
Contacts.NameInput
asChild?
The draft's name field — the only required one. Injects value, onChange and required into an input-shaped child; renders a bare input otherwise.
Contacts.EmailInput
asChild?
The draft's email field. Optional.
Contacts.PhoneInput
asChild?
The draft's phone field. Optional.
Contacts.Save
asChild?
Submits the draft — creating or updating, depending on which draft is open. Disabled until a name is present and while the write is in flight; label follows suit ("Create customer" / "Save changes" / "Saving…"). A successful save closes the draft and refetches the list.
Contacts.Cancel
asChild?
Discards the draft. Default label "Cancel".
Contacts.EditorError
asChild?
Renders the save failure, and nothing when the last save succeeded. Scoped to the editor — a failed disable never shows up here.

Pagination

PartDescription
Contacts.LoadMore
asChild?
Fetches and appends the next page. Renders nothing once the list is fully loaded. Default label "Load more".

Loading

Renders while the list fetch is in flight.

PartDescription
Contacts.LoadingState wrapper. Render skeleton rows inside.

Empty

Renders when nothing matches. Read search and showDisabled from useContacts() to tell a search that matched nothing from an empty disabled listing from a directory with no one in it.

PartDescription
Contacts.EmptyState wrapper. Render the empty-state message inside.

Error

Renders on list-fetch error. Write failures surface on EditorError instead.

PartDescription
Contacts.ErrorState wrapper. Compose the error UI inside.
Contacts.ErrorMessage
asChild?, format?
Renders the error message string.
Contacts.Retry
asChild?
Button that re-runs the list fetch.

Hooks

PartDescription
useContacts()Hook returning the full context: kind plus its singular/plural labels, contacts, the four state flags, retry, hasMore/loadMore, search/setSearch, showDisabled/setShowDisabled, the draft and creating flag with startCreate/startEdit/cancelEdit/setDraftField/save/canSave/saving/saveError, and setDisabled/pendingDisableId.
useContactRow()Hook returning the current contact inside <Rows>.
useContactRowEditing()Hook returning whether this row is the one being edited. RowEditing unmounts its children, which can't animate out — this gives the same state as a boolean, for driving an expand transition.

Search and filters

Search filters on a case-insensitive name substring, server-side. The input stays immediate while the request only re-keys once typing settles (300ms) — otherwise every keystroke is a request, and the Loading gate replaces the list under the cursor.

The active/disabled filter is a switch between two listings, not a superset: the API returns the active contacts or the disabled ones, never both. That's why Status reads the same on every row of a given view, and why the empty disabled listing is worth its own copy.

Both filters are applied to the contacts override too, client-side, so a storybook story or test doesn’t end up showing controls that do nothing.

Disabling contacts

DisableTrigger PATCHes is_disabled, which hides the contact from the default listing and from pickers while leaving existing invoices and bills intact. The same trigger re-enables a disabled contact.

The disable write is deliberately kept apart from the editor's save: it reports progress through pendingDisableId (which only ever names the row that set it), so toggling a row can’t flip an open draft into a saving or failed state.