User

Pick a person by name, with their avatar, for an Auto Form field.

Preview

Search by name, pick one person.

Usage

import { z } from 'zod'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormUser } from '@vitnode/core/components/form/fields/input-users'

The value is the user id, so the schema is a plain number and the payload needs no unwrapping:

const formSchema = z.object({
  authorId: z.number(),
})
<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: 'authorId',
      component: (props) => (
        <AutoFormUser
          {...props}
          description="Search by name, pick one person."
          label="Author"
          placeholder="Select an author"
        />
      ),
    },
  ]}
/>

Out of the box it searches the AdminCP users list, which means it answers with whatever that route lets the signed-in admin see - the permission check lives there and is not repeated in the component.

Editing an existing record

A picker cannot show a name it has never fetched. An edit form starts holding an id, so pass the person it already knows about as selected:

<AutoFormUser
  {...props}
  label="Author"
  selected={{
    id: article.author.id,
    name: article.author.name,
    nameCode: article.author.nameCode,
    avatarColor: article.author.avatarColor,
  }}
/>

Without it the field opens on the placeholder, as though nobody were chosen. Whatever the search returns afterwards is remembered on top of that, so a person picked a moment ago still reads as their name.

Optional fields

A nullable author is z.number().nullable(). The field renders the placeholder for null and never invents a value - clearing one is up to your own control, because "no author" and "author not chosen yet" are the same state here.

Searching somewhere else

search replaces the lookup entirely - a plugin scoping to its own members, a different endpoint, or fixtures in a test:

<AutoFormUser
  {...props}
  label="Reviewer"
  search={async (value) => await searchReviewers(value)}
/>

It runs on every open with an empty string, and again - debounced - as the person types. That is deliberate: the list is a live view of who exists, and a cached one offers somebody who was deleted since.

Props

PropTypeDefaultWhat it does
labelReactNode-Field label
descriptionReactNode-Help text under the control
placeholderstringSelect an optionShown while nothing is chosen
searchPlaceholderstringSearch...Placeholder inside the search box
selectedPartialUserOption | null-The person the field opens on
search(value: string) => Promise<UserOption[]>AdminCP users listReplaces the lookup
clearablebooleanfalseAdds a button that sets the field back to nobody
disabledbooleanfalseBlocks opening the picker

UserOption is { id, name, nameCode, avatarColor } - the columns it takes to recognise a person on sight. selected accepts a partial one, because the caller often knows only an id and a name.

A generated avatar needs a colour, and that is the column a caller who resolved only a name does not have. Rather than invent one - the wrong colour reads as a different person - the field draws a neutral placeholder in the same box, so the name stays where it is when a search fills the real avatar in.

In the Content Engine

A field.user() renders this field automatically - the author picker on a blog post is this component. Its options come from the content type's own picker route rather than from the users list, so an editor who may write articles can choose an author without also being trusted to browse the member list.

See also

  • Roles - the same idea for roles, single or multiple.
  • Combobox - when the options are strings rather than people.