Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/user-field-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/fields': minor
---

feat(fields): wire the `user` field picker to a real `sys_user` search

The `user`/`owner` field widgets previously rendered a placeholder ("User
selection component requires integration with user management system") and the
form-type map fell through to `field:text`, so a `user` field rendered as a
plain text input.

`UserField` now **delegates to the shared `LookupField`** with the reference
fixed to `sys_user` — reusing the existing debounced candidate search, the
record-picker dialog, and id resolution — so selecting a person works the same
way as any lookup, with zero bespoke data plumbing. `mapFieldTypeToFormType`
now maps `user`/`owner` to `field:user`/`field:owner`, satisfying the existing
`field-type-coverage` regression guard (which already listed both but had no
mapping wired — the widget map and cell renderers were registered, the form-type
map was the missing link). Table-cell display continues to use `UserCellRenderer`
(avatars/initials).

Pairs with the framework `user` field type (a lookup specialized to `sys_user`).
5 changes: 5 additions & 0 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,11 @@ export function mapFieldTypeToFormType(fieldType: string): string {
lookup: 'field:lookup',
master_detail: 'field:master_detail',
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
// ownership). Both render via the UserField person-picker (delegates to the
// lookup picker). Without these they would fall through to `field:text`.
user: 'field:user',
owner: 'field:owner',

// Contact fields
email: 'field:email',
Expand Down
107 changes: 29 additions & 78 deletions packages/fields/src/widgets/UserField.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,39 @@
import React from 'react';
import { Avatar, AvatarFallback, Badge, EmptyValue } from '@object-ui/components';
import { X } from 'lucide-react';
import { FieldWidgetProps } from './types';
import { LookupField } from './LookupField';

/**
* UserField - User/Owner selector field
* Allows selecting one or multiple users
* UserField — person picker for the `user` field type.
*
* `user` is a lookup specialized to the `sys_user` system object (see framework
* "lookup → sys_user" specialization). Rather than re-implement candidate search,
* the searchable picker, the record-picker dialog and id resolution, this widget
* **delegates to the shared {@link LookupField}** with the reference fixed to
* `sys_user`. The author writes `Field.user({ multiple })`; we normalise the field
* metadata so the lookup machinery targets users with a sensible display field.
*
* Table-cell display (avatars / initials) is handled separately by
* `UserCellRenderer`; this is the form/editor widget.
*/
export function UserField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
const userField = (field || (props as any).schema) as any;
const multiple = userField?.multiple || false;
export function UserField(props: FieldWidgetProps<any>) {
const raw = (props.field || (props as any).schema) as any;

if (readonly) {
if (!value) return <EmptyValue />;

const users = Array.isArray(value) ? value : [value];
return (
<div className="flex -space-x-2">
{users.slice(0, 3).map((user: any, idx: number) => {
const name = user.name || user.username || 'User';
const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);

return (
<Avatar key={idx} className="size-8 border-2 border-white" title={name}>
<AvatarFallback className="bg-blue-500 text-white text-xs">
{initials}
</AvatarFallback>
</Avatar>
);
})}
{users.length > 3 && (
<Avatar className="size-8 border-2 border-white">
<AvatarFallback className="bg-gray-200 text-gray-600 text-xs">
+{users.length - 3}
</AvatarFallback>
</Avatar>
)}
</div>
);
}
// The objectSchema field metadata may live directly on `field`, or nested at
// `field.field` when rendered via the createFieldRenderer wrapper — mirror the
// unwrap LookupField itself performs.
const metaIsNested = raw?.field && typeof raw.field === 'object'
&& ('reference' in raw.field || 'reference_to' in raw.field || 'type' in raw.field);
const meta = metaIsNested ? raw.field : raw;

const users = value ? (Array.isArray(value) ? value : [value]) : [];

const handleRemove = (index: number) => {
if (multiple) {
const newUsers = users.filter((_: any, i: number) => i !== index);
onChange(newUsers.length > 0 ? newUsers : null);
} else {
onChange(null);
}
// Ensure the picker always targets sys_user (even if the author omitted an
// explicit reference) and presents user names by default.
const normalized = {
...(meta || {}),
reference: meta?.reference || meta?.reference_to || 'sys_user',
display_field: meta?.display_field || meta?.displayField || meta?.reference_field || 'name',
};

return (
<div className={props.className}>
{users.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{users.map((user: any, idx: number) => {
const name = user.name || user.username || 'User';
const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);

return (
<Badge key={idx} variant="outline" className="gap-2 pr-1">
<Avatar className="size-5">
<AvatarFallback className="bg-blue-500 text-white text-xs">
{initials}
</AvatarFallback>
</Avatar>
<span className="text-sm">{name}</span>
<button
type="button"
onClick={() => handleRemove(idx)}
className="ml-1 rounded-full hover:bg-gray-200 p-0.5"
>
<X className="size-3" />
</button>
</Badge>
);
})}
</div>
)}

<div className="text-sm text-gray-500 italic">
User selection component requires integration with user management system
</div>
</div>
);
const fieldProp = metaIsNested ? { ...raw, field: normalized } : normalized;

return <LookupField {...(props as any)} field={fieldProp} />;
}
Loading