Skip to content

Commit ec2f5d5

Browse files
feat(fields): offer the mobile shell's code scanner on text and lookup fields (#29493)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQnx983aXpxUvWnKCfqDKdq)_ Fixes #29378 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:在 ObjectStack 移动壳内(桥接 `has('scan')` 为真时),可编辑的单行文本字段与查找(lookup)字段旁出现一个「扫码」图标按钮;文本字段把解码结果经 `onChange` 写入,查找字段用解码结果打开搜索。普通浏览器、旧版壳、只读或禁用字段不显示按钮。 - **为什么改**:移动壳已上线 `scan()`(objectstack-ai/mobile#170),Console 端没有调用方,就是「声明了却没人用」的能力;现场用户用手机录入资产标签、条码、单据号时需要它。 - **风险与回滚**:新增的公开面全部是加法:`@object-ui/mobile` 的 `NativeHostMethod` 加 `'scan'`、`ObjectStackNative` 加可选 `scan?(opts)`、新导出 `NativeScanOptions` 与 `nativeScan(opts?)`;`@object-ui/fields` 的 `PeoplePicker` 加可选 prop `initialSearch`;`@object-ui/i18n` 十个语言包各加 `fields.scan.button`、`fields.scan.failed` 两个键;`@object-ui/fields` 新增依赖 `@object-ui/mobile`(mobile 只依赖 types,无环)。浏览器里按钮不渲染、DOM 不变。回滚即 revert 本 PR。 - **先看**:`ui/packages/fields/src/widgets/ScanFieldButton.tsx`(显示门 `useFieldScanOffered` 与失败 toast);`ui/packages/fields/src/widgets/LookupField.tsx`(`handleScanned` 与 `lookupScan` 的门控);`ui/packages/mobile/src/nativeHost.ts`(`nativeScan`)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/fields/src/widgets/ScanFieldButton.shellScan.test.tsx` 应 13/13 通过;截图见下方 Verification(390 px,桩桥接)。 - **需要你做的**:无,席位落地。 ## What changes - **`@object-ui/mobile`** types the bridge's scan method, mirroring mobile `docs/HYBRID-SHELL.md` "Scanning codes": `'scan'` joins `NativeHostMethod`; `ObjectStackNative` gains an OPTIONAL `scan?(opts)` (a shell older than the scanner does not carry it, and a required member would break typed stubs); `NativeScanOptions` (`formats?: string[]`) is exported; `nativeScan(opts?)` goes through `callNative` and resolves `unavailable`, `ok` with the decoded text or `null` (scanner closed), or `failed` with the shell's code and message. The method is called on the bridge, so the bridge stays its `this`. - **`@object-ui/fields`** gains a dependency on `@object-ui/mobile` (mobile depends only on `@object-ui/types`; plugin-grid, list, calendar and timeline already take this edge). An internal `ScanFieldButton` plus `useFieldScanOffered()` (not exported from the package entry) reads `useNativeHost()`, so a bridge the shell injects late re-renders the field with the button. - `TextField`: the single-line input only (not password, not the multi-row textarea, not disabled or read-only). The decoded text REPLACES the value through `onChange`, the same path a keystroke takes, so the host's validation judges it; it is not cut to `maxLength`, which would lose scanned data silently. - `LookupField`: the classic popover and the static-option list open with the code in their search box; the search-first variant (`picker: 'search'`) opens `PeoplePicker` seeded through the new optional `initialSearch` prop, cleared when it closes. Gated like the trigger: no button when disabled, when `dependsOn` is unmet, in compact (inline cell) mode, or on a classic lookup whose target offers no search. - Outcomes: `ok` with text runs the handler; `ok` with `null` or `unavailable` does nothing; `failed` raises `toast.error` from `@object-ui/components` (the sonner instance the Console mounts) with the new key as title and the shell's message as description. - **`@object-ui/i18n`**: `fields.scan.button` ("Scan code") and `fields.scan.failed` ("Could not scan") in all ten locales, plus `FIELD_DEFAULTS` for a provider-less render. - No metadata key: the affordance follows the bridge capability, so `Metadata-protocol: none`. ## Tests - New `ScanFieldButton.shellScan.test.tsx` (13 cases) with a stub `window.ObjectStackNative`: plain browser and `has('scan')` false render no button (text and lookup); read-only, disabled and password render none; the decoded value lands through `onChange`; `null` changes nothing and raises no toast; `failed` raises the toast with the shell message; a lookup's decoded value opens the dropdown with it in the search box and drives the `$search` query; `null` leaves a lookup closed; the search-first variant opens `PeoplePicker` seeded. - `nativeHost.test.tsx` extended: `scan` joins the unavailable / has-says-no / method-missing tables; `ok` with text (formats forwarded, bridge kept as `this`), `ok` with `null`, and `failed` with the shell message. - **Ablation** (`scripts/ablation-replace.mjs`, source-resolved through the vitest alias): replacing `return host.has('scan') === true;` with `return (host.has('scan') || true) === true;` in `ScanFieldButton.tsx` reds exactly the two older-shell pins (2 failed, 11 passed); restored, blob equal to HEAD and `git diff HEAD` empty. ## Acceptance notes - Compact (inline grid cell) lookups get no scan button, matching the single-select clear control, which compact mode also omits. - The multi-select search-first lookup renders its row with `display: contents`, so the scan button sits on its own line under the trigger there. - `check:dual-build-cjs-loads` is NOT MEASURED locally (exit 3, needs a full `pnpm build`); it scans the root `packages/` tree only, so no ui package is in its population. CI runs it. ## Verification Local runs on head `bf41e3ae` (suites, gates), then main merged at `c6569d87` (main touched none of `ui/packages/fields`, `mobile`, `i18n` or the lockfile; the two scan test files re-ran green there: 2 files, 59 tests). <details><summary>Suites and type-check</summary> - `turbo run type-check` for `@object-ui/fields`, `@object-ui/mobile`, `@object-ui/i18n`: 50 tasks successful (each package's `tsc --noEmit && tsc -p tsconfig.test.json` echoed). - `pnpm --dir ui exec vitest run packages/mobile/ packages/i18n/`: Test Files 104 passed, Tests 1567 passed, 13 skipped. - `pnpm --dir ui exec vitest run packages/fields/`: Test Files 294 passed, 1 skipped; Tests 4426 passed, 7 skipped. - `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped; Tests 4921 passed. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 54 families; 53 exit 0, `pnpm check:dual-build-cjs-loads` exit 3 (prerequisite). `--ran` reconciliation: 54 accounted for, 53 run, 1 NOT-MEASURED, 0 UNRUN. `node scripts/check-sdui-manifest.mjs` exit 0. `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> <details><summary>Phone-width screenshots (390 px, stub bridge)</summary> A temporary Console Vite entry rendered the real `TextField` and `LookupField` with an in-memory data source, then was deleted. Playwright readings: plain browser, 0 scan buttons; shell stub, 2 buttons; after scanning, the text input holds `ASSET-0042` and the lookup dropdown opens with search `ASSET-0042` listing only "ASSET-0042 Forklift"; a refused camera shows the toast "Could not scan / Camera permission denied". Files in the project thread: `phone-plain-browser.png`, `phone-shell-buttons.png`, `phone-shell-after-scan.png`, `phone-shell-scan-failed.png`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28d0dbb commit ec2f5d5

22 files changed

Lines changed: 551 additions & 6 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/fields': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(fields): offer the mobile shell's code scanner on text and lookup fields
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
- `@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.
13+
- `@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.
14+
- `@object-ui/i18n`: two keys, `fields.scan.button` and `fields.scan.failed`, in every locale.

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ui/packages/fields/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@object-ui/components": "workspace:*",
3434
"@object-ui/core": "workspace:*",
3535
"@object-ui/i18n": "workspace:*",
36+
"@object-ui/mobile": "workspace:*",
3637
"@object-ui/permissions": "workspace:*",
3738
"@object-ui/providers": "workspace:*",
3839
"@object-ui/react": "workspace:*",

‎ui/packages/fields/src/widgets/LookupField.tsx‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n';
3838
import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
3939
import { usePermissions } from '@object-ui/permissions';
4040
import { useFieldTranslation } from './useFieldTranslation.js';
41+
import { ScanFieldButton, useFieldScanOffered } from './ScanFieldButton.js';
4142

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

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

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

824+
// A scanned code searches the lookup's target: the search-first variant
825+
// opens its picker seeded with the code; the classic and static-option
826+
// variants open the dropdown with the code in its search box.
827+
const handleScanned = useCallback(
828+
(code: string) => {
829+
if (pickerVariant === 'search' && hasDataSource) {
830+
setPickerInitialSearch(code);
831+
setIsPickerOpen(true);
832+
return;
833+
}
834+
setIsOpen(true);
835+
handleSearchChange(code);
836+
},
837+
[pickerVariant, hasDataSource, handleSearchChange],
838+
);
839+
819840
/**
820841
* Hydrate the picker's display when the field already has a value (e.g.
821842
* edit-mode load, prefill via query-string from a related-list "+ New")
@@ -1694,6 +1715,18 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
16941715
</Button>
16951716
) : null;
16961717

1718+
/**
1719+
* The scan button (inside the mobile shell only, `useFieldScanOffered`).
1720+
* Gated like the trigger and the browse button: a disabled field or one
1721+
* whose `dependsOn` is unmet offers no scan, and a classic lookup whose
1722+
* target has no search (`searchOffered`) has nothing to search with.
1723+
*/
1724+
const searchVariant = pickerVariant === 'search' && hasDataSource && !!dataSource && !!referenceTo;
1725+
const lookupScan =
1726+
scanOffered && !compact && !props.disabled && !dependenciesMissing && (searchVariant || searchOffered) ? (
1727+
<ScanFieldButton onScan={handleScanned} />
1728+
) : null;
1729+
16971730
return (
16981731
<div className={compact ? '' : 'space-y-2'}>
16991732
{/* Selected values display — a multi-select field's chips (full mode
@@ -1753,15 +1786,19 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
17531786

17541787
{/* Field control: search-first inline combobox (anchored dropdown / mobile
17551788
sheet), else the classic quick-select popover. */}
1756-
{pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
1789+
{searchVariant && dataSource && referenceTo ? (
17571790
// A single-select field's row holds the trigger and its clear control,
17581791
// as the classic row below does; a multi-select field is unchanged.
17591792
<div className={multiple ? 'contents' : 'flex items-center gap-1.5'}>
17601793
<PeoplePicker
17611794
inline
17621795
trigger={triggerButton}
17631796
open={isPickerOpen}
1764-
onOpenChange={setIsPickerOpen}
1797+
onOpenChange={(o) => {
1798+
setIsPickerOpen(o);
1799+
if (!o) setPickerInitialSearch(undefined);
1800+
}}
1801+
initialSearch={pickerInitialSearch}
17651802
title={lookupField?.label || t('common.select')}
17661803
multiple={multiple}
17671804
dataSource={dataSource}
@@ -1777,6 +1814,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
17771814
lookupFilters={lookupFilters}
17781815
baseFilter={dependentFilter}
17791816
/>
1817+
{lookupScan}
17801818
{singleClear}
17811819
</div>
17821820
) : (
@@ -1964,6 +2002,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
19642002
</PopoverContent>
19652003
</Popover>
19662004

2005+
{lookupScan}
19672006
{singleClear}
19682007

19692008
{/* "Browse All" button — classic lookups only; search fields open the

‎ui/packages/fields/src/widgets/PeoplePicker.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export interface PeoplePickerProps {
120120
inline?: boolean;
121121
/** The element the inline dropdown/sheet anchors to (the field trigger). */
122122
trigger?: React.ReactNode;
123+
/**
124+
* A search term the picker opens with (e.g. a code scanned into a lookup).
125+
* Applied each time `open` turns true while it is set; the user can edit it
126+
* like any typed search.
127+
*/
128+
initialSearch?: string;
123129
}
124130

125131
const DEFAULT_PAGE_SIZE = 25;
@@ -146,6 +152,7 @@ export function PeoplePicker({
146152
onSelectRecords,
147153
inline = false,
148154
trigger,
155+
initialSearch,
149156
}: PeoplePickerProps) {
150157
const { t } = useFieldTranslation();
151158
const isMobile = useIsMobile();
@@ -240,6 +247,18 @@ export function PeoplePicker({
240247
searchFields,
241248
});
242249

250+
// Seed the search on open. The query is enabled by `open`, so its own fetch
251+
// runs first with an empty term; this debounced search supersedes it. Keyed
252+
// on `open` and the term only: the setter is read through a ref, so a new
253+
// setter identity never re-applies the seed over what the user typed.
254+
const setSearchRef = useRef(query.setSearch);
255+
useEffect(() => {
256+
setSearchRef.current = query.setSearch;
257+
});
258+
useEffect(() => {
259+
if (open && initialSearch) setSearchRef.current(initialSearch);
260+
}, [open, initialSearch]);
261+
243262
// Recent ids captured once per open.
244263
const recentIds = useMemo(
245264
() => (open ? getRecentLookupIds(objectName) : []),

0 commit comments

Comments
 (0)