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
14 changes: 14 additions & 0 deletions .changeset/29378-shell-scan-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/mobile': patch
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

feat(fields): offer the mobile shell's code scanner on text and lookup fields

Clause-②: yes
Metadata-protocol: none

- `@object-ui/mobile` types the shell bridge's `scan(opts?)` method: `'scan'` joins `NativeHostMethod`, `ObjectStackNative` gains an optional `scan?(opts)` (a shell older than the scanner does not carry it), `NativeScanOptions` (`formats?: string[]`) is exported, and `nativeScan(opts?)` resolves `unavailable`, `ok` with the decoded text or `null` (the user closed the scanner), or `failed` with the shell's message.
- `@object-ui/fields`: inside the shell, when the bridge announces `scan`, an editable single-line text field and a lookup field show a scan button. A text field takes the decoded text through `onChange`, as a keystroke would; a lookup opens its search with it. A closed scanner changes nothing, and a failure shows the error toast with the shell's message. A plain browser, an older shell, and a read-only or disabled field show no button. `PeoplePicker` gains an optional `initialSearch` prop that the search-first lookup uses to open seeded.
- `@object-ui/i18n`: two keys, `fields.scan.button` and `fields.scan.failed`, in every locale.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/packages/fields/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@object-ui/components": "workspace:*",
"@object-ui/core": "workspace:*",
"@object-ui/i18n": "workspace:*",
"@object-ui/mobile": "workspace:*",
"@object-ui/permissions": "workspace:*",
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
Expand Down
43 changes: 41 additions & 2 deletions ui/packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n';
import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { useFieldTranslation } from './useFieldTranslation.js';
import { ScanFieldButton, useFieldScanOffered } from './ScanFieldButton.js';

export interface LookupOption {
value: string | number;
Expand Down Expand Up @@ -310,6 +311,7 @@ function OptionPreviewLine({ children }: { children: React.ReactNode }) {
export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useFieldTranslation();
const scanOffered = useFieldScanOffered();
// Same two i18n channels RecordPickerDialog reads, so the inline dropdown
// resolves option labels and formats dates exactly as the picker does
// (objectui#5492). Both are provider-safe.
Expand Down Expand Up @@ -722,6 +724,9 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro

// State for the full Record Picker dialog (Level 2)
const [isPickerOpen, setIsPickerOpen] = useState(false);
// The search a scanned code opens the search-first picker with; cleared when
// that picker closes, so the next ordinary open starts with an empty box.
const [pickerInitialSearch, setPickerInitialSearch] = useState<string | undefined>(undefined);

// Dependent-lookup chain as a hard QueryParams.$filter record. Shared by
// EVERY candidate surface — quick-select popover, Level-2 table picker and
Expand Down Expand Up @@ -816,6 +821,22 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
[popoverQuery.setSearch],
);

// A scanned code searches the lookup's target: the search-first variant
// opens its picker seeded with the code; the classic and static-option
// variants open the dropdown with the code in its search box.
const handleScanned = useCallback(
(code: string) => {
if (pickerVariant === 'search' && hasDataSource) {
setPickerInitialSearch(code);
setIsPickerOpen(true);
return;
}
setIsOpen(true);
handleSearchChange(code);
},
[pickerVariant, hasDataSource, handleSearchChange],
);

/**
* Hydrate the picker's display when the field already has a value (e.g.
* edit-mode load, prefill via query-string from a related-list "+ New")
Expand Down Expand Up @@ -1694,6 +1715,18 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
</Button>
) : null;

/**
* The scan button (inside the mobile shell only, `useFieldScanOffered`).
* Gated like the trigger and the browse button: a disabled field or one
* whose `dependsOn` is unmet offers no scan, and a classic lookup whose
* target has no search (`searchOffered`) has nothing to search with.
*/
const searchVariant = pickerVariant === 'search' && hasDataSource && !!dataSource && !!referenceTo;
const lookupScan =
scanOffered && !compact && !props.disabled && !dependenciesMissing && (searchVariant || searchOffered) ? (
<ScanFieldButton onScan={handleScanned} />
) : null;

return (
<div className={compact ? '' : 'space-y-2'}>
{/* Selected values display — a multi-select field's chips (full mode
Expand Down Expand Up @@ -1753,15 +1786,19 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro

{/* Field control: search-first inline combobox (anchored dropdown / mobile
sheet), else the classic quick-select popover. */}
{pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
{searchVariant && dataSource && referenceTo ? (
// A single-select field's row holds the trigger and its clear control,
// as the classic row below does; a multi-select field is unchanged.
<div className={multiple ? 'contents' : 'flex items-center gap-1.5'}>
<PeoplePicker
inline
trigger={triggerButton}
open={isPickerOpen}
onOpenChange={setIsPickerOpen}
onOpenChange={(o) => {
setIsPickerOpen(o);
if (!o) setPickerInitialSearch(undefined);
}}
initialSearch={pickerInitialSearch}
title={lookupField?.label || t('common.select')}
multiple={multiple}
dataSource={dataSource}
Expand All @@ -1777,6 +1814,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
lookupFilters={lookupFilters}
baseFilter={dependentFilter}
/>
{lookupScan}
{singleClear}
</div>
) : (
Expand Down Expand Up @@ -1964,6 +2002,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
</PopoverContent>
</Popover>

{lookupScan}
{singleClear}

{/* "Browse All" button — classic lookups only; search fields open the
Expand Down
19 changes: 19 additions & 0 deletions ui/packages/fields/src/widgets/PeoplePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ export interface PeoplePickerProps {
inline?: boolean;
/** The element the inline dropdown/sheet anchors to (the field trigger). */
trigger?: React.ReactNode;
/**
* A search term the picker opens with (e.g. a code scanned into a lookup).
* Applied each time `open` turns true while it is set; the user can edit it
* like any typed search.
*/
initialSearch?: string;
}

const DEFAULT_PAGE_SIZE = 25;
Expand All @@ -146,6 +152,7 @@ export function PeoplePicker({
onSelectRecords,
inline = false,
trigger,
initialSearch,
}: PeoplePickerProps) {
const { t } = useFieldTranslation();
const isMobile = useIsMobile();
Expand Down Expand Up @@ -240,6 +247,18 @@ export function PeoplePicker({
searchFields,
});

// Seed the search on open. The query is enabled by `open`, so its own fetch
// runs first with an empty term; this debounced search supersedes it. Keyed
// on `open` and the term only: the setter is read through a ref, so a new
// setter identity never re-applies the seed over what the user typed.
const setSearchRef = useRef(query.setSearch);
useEffect(() => {
setSearchRef.current = query.setSearch;
});
useEffect(() => {
if (open && initialSearch) setSearchRef.current(initialSearch);
}, [open, initialSearch]);

// Recent ids captured once per open.
const recentIds = useMemo(
() => (open ? getRecentLookupIds(objectName) : []),
Expand Down
Loading
Loading