From ffc91d4d9e97ff002a66e68e7e0ecd7c150522a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:39:11 +0000 Subject: [PATCH 1/5] feat(mobile): add the scan bridge method and nativeScan wrapper The mobile shell's bridge now carries scan(opts) behind has('scan'). Type it as an optional member (older shells lack it) and call it through callNative, so a caller gets unavailable, ok (text or null) or failed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv --- .../mobile/src/__tests__/nativeHost.test.tsx | 38 ++++++++++++++++++- ui/packages/mobile/src/index.ts | 2 + ui/packages/mobile/src/nativeHost.ts | 33 +++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx index 22696f6cf23..9e9547c7599 100644 --- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx +++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx @@ -29,6 +29,7 @@ import { nativeNavigate, nativeOpenExternal, nativeResetServer, + nativeScan, postNativeReady, postNativeRouteChanged, resetNativeHostEventsForTests, @@ -42,7 +43,7 @@ import { useNativeHostRouting, } from '../useNativeHost'; -const ALL_METHODS: NativeHostMethod[] = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer']; +const ALL_METHODS: NativeHostMethod[] = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer', 'scan']; function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackNative & { ready: Mock<() => void>; @@ -60,6 +61,7 @@ function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackN openExternal: vi.fn(async () => null), navigate: vi.fn(async () => null), resetServer: vi.fn(async () => null), + scan: vi.fn(async () => 'ASSET-0042'), ready: vi.fn<() => void>(), routeChanged: vi.fn<(route: string) => void>(), }; @@ -189,6 +191,7 @@ describe('typed call wrappers feature-detect and never throw', () => { ['openExternal', () => nativeOpenExternal('https://example.com')], ['navigate', () => nativeNavigate('/home')], ['resetServer', () => nativeResetServer()], + ['scan', () => nativeScan()], ]; it.each(calls)('%s resolves "unavailable" without a bridge', async (_method, call) => { @@ -221,6 +224,39 @@ describe('typed call wrappers feature-detect and never throw', () => { expect(bridge.navigate).toHaveBeenCalledWith('/apps/crm'); }); + it('scan resolves "ok" with the decoded text, forwards the formats, and keeps the bridge as `this`', async () => { + const bridge = fakeBridge(); + let self: unknown; + bridge.scan = vi.fn(async function (this: unknown) { + self = this; + return 'ASSET-0042'; + }); + install(bridge); + await expect(nativeScan({ formats: ['qr', 'ean13'] })).resolves.toEqual({ status: 'ok', value: 'ASSET-0042' }); + expect(bridge.scan).toHaveBeenCalledWith({ formats: ['qr', 'ean13'] }); + expect(self).toBe(bridge); + }); + + it('scan resolves "ok" with null when the user closes the scanner', async () => { + const bridge = fakeBridge(); + bridge.scan = vi.fn(async () => null); + install(bridge); + await expect(nativeScan()).resolves.toEqual({ status: 'ok', value: null }); + }); + + it('scan resolves "failed" with the shell message when the camera is refused', async () => { + const bridge = fakeBridge(); + bridge.scan = vi.fn(async () => { + throw Object.assign(new Error('Camera permission denied'), { code: 'failed' }); + }); + install(bridge); + await expect(nativeScan()).resolves.toEqual({ + status: 'failed', + code: 'failed', + message: 'Camera permission denied', + }); + }); + it('a call the shell rejects resolves "failed" with the shell error code', async () => { const bridge = fakeBridge(); bridge.openExternal = vi.fn(async () => { diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts index 8ebf03121fb..0747f564b4e 100644 --- a/ui/packages/mobile/src/index.ts +++ b/ui/packages/mobile/src/index.ts @@ -73,6 +73,7 @@ export { nativeOpenExternal, nativeNavigate, nativeResetServer, + nativeScan, postNativeReady, postNativeRouteChanged, type ObjectStackNative, @@ -81,6 +82,7 @@ export { type NativeHostMethod, type NativePushToken, type NativeSafeArea, + type NativeScanOptions, } from './nativeHost.js'; // Re-export types for convenience diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts index ea517cfbffb..2961af59352 100644 --- a/ui/packages/mobile/src/nativeHost.ts +++ b/ui/packages/mobile/src/nativeHost.ts @@ -55,8 +55,23 @@ export interface NativeSafeArea { right: number; } +/** Options of `scan()`. */ +export interface NativeScanOptions { + /** + * The code formats to read, as the shell's barcode type names (`qr`, + * `ean13`, `code128`, ...). Omitted, the shell reads every format it lists. + */ + formats?: string[]; +} + /** The callable bridge methods. Each is announced as a capability of the same name. */ -export type NativeHostMethod = 'getPushToken' | 'haptic' | 'openExternal' | 'navigate' | 'resetServer'; +export type NativeHostMethod = + | 'getPushToken' + | 'haptic' + | 'openExternal' + | 'navigate' + | 'resetServer' + | 'scan'; /** `window.ObjectStackNative`, as the shell's bridge contract declares it. */ export interface ObjectStackNative { @@ -77,6 +92,12 @@ export interface ObjectStackNative { navigate(route: string): Promise; /** Forget the configured server and return to the shell's server picker. */ resetServer(): Promise; + /** + * Open the shell's full-screen code scanner. Resolves to the decoded text, + * or `null` when the user closed the scanner. Optional: a shell older than + * the scanner does not carry it, so feature-detect with `has('scan')`. + */ + scan?(opts?: NativeScanOptions): Promise; /** Event: the page is interactive (the shell lifts its loading cover). */ ready(): void; @@ -165,6 +186,16 @@ export function nativeResetServer(): Promise> { return callNative('resetServer', (host) => host.resetServer()); } +/** + * Scan a code with the shell's scanner. `ok` carries the decoded text, or + * `null` when the user closed the scanner; `failed` carries the shell's + * message (camera permission denied, a scan already open, bad options). + */ +export function nativeScan(opts?: NativeScanOptions): Promise> { + // `callNative` calls this only once it has seen `scan` is a function. + return callNative('scan', (host) => host.scan!(opts)); +} + // ── Events ────────────────────────────────────────────────────────────────── let readyPosted = false; From 8c60b9222aeab25f1d5491b0fd4784deb14a27cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:41:58 +0000 Subject: [PATCH 2/5] feat(fields): offer the shell's code scanner on text and lookup fields Inside the ObjectStack mobile shell, when the bridge announces scan, an editable single-line text field and a lookup field show a scan button. Text takes the decoded value through onChange; a lookup opens its search with it (PeoplePicker gains initialSearch for the search-first variant). A closed scanner changes nothing; a failure shows the error toast. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv --- pnpm-lock.yaml | 3 + ui/packages/fields/package.json | 1 + .../fields/src/widgets/LookupField.tsx | 43 +++++++++- .../fields/src/widgets/PeoplePicker.tsx | 19 +++++ .../fields/src/widgets/ScanFieldButton.tsx | 82 +++++++++++++++++++ ui/packages/fields/src/widgets/TextField.tsx | 21 ++++- .../fields/src/widgets/useFieldTranslation.ts | 3 + ui/packages/i18n/src/locales/ar.ts | 5 ++ ui/packages/i18n/src/locales/de.ts | 5 ++ ui/packages/i18n/src/locales/en.ts | 5 ++ ui/packages/i18n/src/locales/es.ts | 5 ++ ui/packages/i18n/src/locales/fr.ts | 5 ++ ui/packages/i18n/src/locales/ja.ts | 5 ++ ui/packages/i18n/src/locales/ko.ts | 5 ++ ui/packages/i18n/src/locales/pt.ts | 5 ++ ui/packages/i18n/src/locales/ru.ts | 5 ++ ui/packages/i18n/src/locales/zh.ts | 5 ++ 17 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 ui/packages/fields/src/widgets/ScanFieldButton.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c11f56608b8..ff1f34e5f6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4436,6 +4436,9 @@ importers: '@object-ui/i18n': specifier: workspace:* version: link:../i18n + '@object-ui/mobile': + specifier: workspace:* + version: link:../mobile '@object-ui/permissions': specifier: workspace:* version: link:../permissions diff --git a/ui/packages/fields/package.json b/ui/packages/fields/package.json index 85a44d505f6..255356bfbec 100644 --- a/ui/packages/fields/package.json +++ b/ui/packages/fields/package.json @@ -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:*", diff --git a/ui/packages/fields/src/widgets/LookupField.tsx b/ui/packages/fields/src/widgets/LookupField.tsx index 224f7e9525c..a833611d9c8 100644 --- a/ui/packages/fields/src/widgets/LookupField.tsx +++ b/ui/packages/fields/src/widgets/LookupField.tsx @@ -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; @@ -310,6 +311,7 @@ function OptionPreviewLine({ children }: { children: React.ReactNode }) { export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps) { 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. @@ -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(undefined); // Dependent-lookup chain as a hard QueryParams.$filter record. Shared by // EVERY candidate surface — quick-select popover, Level-2 table picker and @@ -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") @@ -1694,6 +1715,18 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro ) : 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) ? ( + + ) : null; + return (
{/* Selected values display — a multi-select field's chips (full mode @@ -1753,7 +1786,7 @@ 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.
@@ -1761,7 +1794,11 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro 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} @@ -1777,6 +1814,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro lookupFilters={lookupFilters} baseFilter={dependentFilter} /> + {lookupScan} {singleClear}
) : ( @@ -1964,6 +2002,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro + {lookupScan} {singleClear} {/* "Browse All" button — classic lookups only; search fields open the diff --git a/ui/packages/fields/src/widgets/PeoplePicker.tsx b/ui/packages/fields/src/widgets/PeoplePicker.tsx index cc62462a297..d92e7343b0d 100644 --- a/ui/packages/fields/src/widgets/PeoplePicker.tsx +++ b/ui/packages/fields/src/widgets/PeoplePicker.tsx @@ -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; @@ -146,6 +152,7 @@ export function PeoplePicker({ onSelectRecords, inline = false, trigger, + initialSearch, }: PeoplePickerProps) { const { t } = useFieldTranslation(); const isMobile = useIsMobile(); @@ -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) : []), diff --git a/ui/packages/fields/src/widgets/ScanFieldButton.tsx b/ui/packages/fields/src/widgets/ScanFieldButton.tsx new file mode 100644 index 00000000000..db4b436a666 --- /dev/null +++ b/ui/packages/fields/src/widgets/ScanFieldButton.tsx @@ -0,0 +1,82 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useCallback, useState } from 'react'; +import { Button, cn, toast } from '@object-ui/components'; +import { ScanBarcode } from 'lucide-react'; +import { nativeScan, useNativeHost, type ObjectStackNative } from '@object-ui/mobile'; +import { PHONE_TOUCH_SQUARE } from './touchTarget.js'; +import { useFieldTranslation } from './useFieldTranslation.js'; + +/** Whether the shell bridge announces `scan` and carries the method. */ +function hostOffersScan(host: ObjectStackNative | null): boolean { + if (!host || typeof host.has !== 'function' || typeof host.scan !== 'function') return false; + try { + return host.has('scan') === true; + } catch { + return false; + } +} + +/** + * Whether a field may offer the scan button: only inside the ObjectStack mobile + * shell, and only when that shell announces `scan`. A plain browser and an + * older shell answer `false`. Re-renders when a late bridge is injected. + */ +export function useFieldScanOffered(): boolean { + return hostOffersScan(useNativeHost()); +} + +/** Internal to `@object-ui/fields`; not exported from the package entry. */ +export interface ScanFieldButtonProps { + /** Called with the decoded text. Not called when the user closes the scanner. */ + onScan: (text: string) => void; + className?: string; +} + +/** + * The icon button that opens the shell's code scanner. The caller renders it + * only when `useFieldScanOffered()` is true and the field is editable. A + * scanner failure (camera permission denied, a scan already open) is shown + * with the error toast, carrying the shell's message. + */ +export function ScanFieldButton({ onScan, className }: ScanFieldButtonProps) { + const { t } = useFieldTranslation(); + const [scanning, setScanning] = useState(false); + + const handleClick = useCallback(async () => { + setScanning(true); + try { + const result = await nativeScan(); + if (result.status === 'ok') { + if (typeof result.value === 'string' && result.value !== '') onScan(result.value); + } else if (result.status === 'failed') { + toast.error(t('fields.scan.failed'), { description: result.message }); + } + } finally { + setScanning(false); + } + }, [onScan, t]); + + const label = t('fields.scan.button'); + return ( + + ); +} diff --git a/ui/packages/fields/src/widgets/TextField.tsx b/ui/packages/fields/src/widgets/TextField.tsx index 4bb3268728b..5225413fd56 100644 --- a/ui/packages/fields/src/widgets/TextField.tsx +++ b/ui/packages/fields/src/widgets/TextField.tsx @@ -5,6 +5,7 @@ import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { PHONE_TOUCH_HEIGHT } from './touchTarget.js'; import { CurrencyCodeField, isCurrencyCodeField } from './CurrencyCodeField.js'; +import { ScanFieldButton, useFieldScanOffered } from './ScanFieldButton.js'; /** * TextField - Standard single-line or multi-line text input @@ -12,6 +13,8 @@ import { CurrencyCodeField, isCurrencyCodeField } from './CurrencyCodeField.js'; */ export function TextField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) { const fieldData = field; + // Read before any early return: hooks run in the same order on every render. + const scanOffered = useFieldScanOffered(); // A record-currency code field (ADR-0144 D2) — `valueDomain: // 'iso_4217_currency'` declared on the field — is chosen from the ISO 4217 @@ -74,10 +77,11 @@ export function TextField({ value, onChange, field, readonly, error, ...props }: ); } - return ( + const isPassword = fieldData?.type === 'password'; + const input = ( onChange(e.target.value)} @@ -87,4 +91,17 @@ export function TextField({ value, onChange, field, readonly, error, ...props }: aria-invalid={!!error} /> ); + + // Inside the mobile shell, an editable single-line text input offers the + // shell's code scanner. 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` (that would lose scanned data + // silently). A password input and a disabled one offer no scan. + if (!scanOffered || isPassword || domProps.disabled) return input; + return ( +
+ {input} + +
+ ); } diff --git a/ui/packages/fields/src/widgets/useFieldTranslation.ts b/ui/packages/fields/src/widgets/useFieldTranslation.ts index 7584ac0bcde..77dbef89ca3 100644 --- a/ui/packages/fields/src/widgets/useFieldTranslation.ts +++ b/ui/packages/fields/src/widgets/useFieldTranslation.ts @@ -71,6 +71,9 @@ const FIELD_DEFAULTS: Record = { // second key for the same name. Same values as the `en` pack. 'fields.image.cropPreview': 'Image crop preview', 'fields.percent.slider': 'Percentage', + // The scan button text and lookup fields show inside the mobile shell. + 'fields.scan.button': 'Scan code', + 'fields.scan.failed': 'Could not scan', 'console.commandPalette.records': 'Records', // objectui#4026 — the separator between the controlling-field names that // fill `{{fields}}` of the two gate sentences above/below (`lookup. diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 0f71e4f260a..b9697a8e636 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -276,6 +276,11 @@ const ar = { show: 'إظهار رمز QR', hide: 'إخفاء رمز QR', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'مسح الرمز', + failed: 'تعذّر المسح', + }, image: { upload: "رفع صورة", addMore: "إضافة المزيد من الصور", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8de6311aae0..8abb1ead50b 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -262,6 +262,11 @@ const de = { show: 'QR-Code anzeigen', hide: 'QR-Code verbergen', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Code scannen', + failed: 'Scannen fehlgeschlagen', + }, image: { upload: "Bild hochladen", addMore: "Weitere Bilder hinzufügen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 5271d7b34d2..255fb6d9064 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -424,6 +424,11 @@ const en = { restricted: 'Restricted file', restrictedHint: 'You do not have access to the details of this file', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Scan code', + failed: 'Could not scan', + }, image: { upload: 'Upload image', addMore: 'Add more images', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 477ed6e6290..aef4797cd31 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -269,6 +269,11 @@ const es = { show: 'Mostrar código QR', hide: 'Ocultar código QR', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Escanear código', + failed: 'No se pudo escanear', + }, image: { upload: "Subir imagen", addMore: "Añadir más imágenes", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index e7e9c126ee2..55b7c581e6d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -264,6 +264,11 @@ const fr = { show: 'Afficher le code QR', hide: 'Masquer le code QR', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Scanner un code', + failed: 'Échec du scan', + }, image: { upload: "Téléverser une image", addMore: "Ajouter d'autres images", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index a129f8c9edd..ecd36525ff2 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -262,6 +262,11 @@ const ja = { show: 'QR コードを表示', hide: 'QR コードを非表示', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'コードをスキャン', + failed: 'スキャンできませんでした', + }, image: { upload: "画像をアップロード", addMore: "画像を追加", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 398a00919b3..216d5d02ee5 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -262,6 +262,11 @@ const ko = { show: 'QR 코드 표시', hide: 'QR 코드 숨기기', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: '코드 스캔', + failed: '스캔하지 못했습니다', + }, image: { upload: "이미지 업로드", addMore: "이미지 더 추가", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 2fb12977eff..a3b3ebf53c1 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -264,6 +264,11 @@ const pt = { show: 'Mostrar código QR', hide: 'Ocultar código QR', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Escanear código', + failed: 'Falha ao escanear', + }, image: { upload: "Enviar imagem", addMore: "Adicionar mais imagens", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 9b8f31b2ddc..caaec730cc5 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -269,6 +269,11 @@ const ru = { show: 'Показать QR-код', hide: 'Скрыть QR-код', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: 'Сканировать код', + failed: 'Не удалось отсканировать', + }, image: { upload: "Загрузить изображение", addMore: "Добавить ещё изображения", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 89d5039f90a..8668f9fd976 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -292,6 +292,11 @@ const zh = { restricted: '受限文件', restrictedHint: '你无权查看此文件的详细信息', }, + // The scan button text and lookup fields show inside the mobile shell. + scan: { + button: '扫码', + failed: '扫码失败', + }, image: { upload: '上传图片', addMore: '添加更多图片', From 4167917d925a9003c419eac1c635b75d3151e49c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:43:16 +0000 Subject: [PATCH 3/5] test(fields): pin the shell scan button on text and lookup fields Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv --- .../ScanFieldButton.shellScan.test.tsx | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 ui/packages/fields/src/widgets/ScanFieldButton.shellScan.test.tsx diff --git a/ui/packages/fields/src/widgets/ScanFieldButton.shellScan.test.tsx b/ui/packages/fields/src/widgets/ScanFieldButton.shellScan.test.tsx new file mode 100644 index 00000000000..a7582be0d79 --- /dev/null +++ b/ui/packages/fields/src/widgets/ScanFieldButton.shellScan.test.tsx @@ -0,0 +1,251 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The scan button on text and lookup fields, offered by the ObjectStack + * mobile shell's `scan()` bridge method (objectstack-ai/mobile + * `docs/HYBRID-SHELL.md`, "Scanning codes"). + * + * The stub below is `window.ObjectStackNative` with the members the fields + * read. The control cases are the ones a desktop browser and an older shell + * take: no global at all, and a global whose `has('scan')` says no. Both must + * render no button. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, act, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { toast } from '@object-ui/components'; +import { TextField } from './TextField'; +import { LookupField } from './LookupField'; + +const GLOBAL = 'ObjectStackNative'; + +type ScanAnswer = string | null | Error; + +function installShell(opts: { capabilities?: string[]; answer?: ScanAnswer } = {}) { + const capabilities = opts.capabilities ?? ['haptic', 'scan']; + const scan = vi.fn(async () => { + const answer = opts.answer === undefined ? 'ASSET-0042' : opts.answer; + if (answer instanceof Error) throw answer; + return answer; + }); + (window as unknown as Record)[GLOBAL] = { + version: 1, + platform: 'ios', + appVersion: '2.1.0', + capabilities, + safeArea: { top: 0, bottom: 0, left: 0, right: 0 }, + has: (c: string) => capabilities.includes(c), + haptic: vi.fn(async () => null), + scan, + ready: vi.fn(), + routeChanged: vi.fn(), + }; + return scan; +} + +afterEach(() => { + cleanup(); + delete (window as unknown as Record)[GLOBAL]; + vi.restoreAllMocks(); + try { + localStorage.clear(); + } catch { + /* no storage in this environment */ + } +}); + +const scanButton = () => screen.queryByRole('button', { name: 'Scan code' }); + +async function settle(ms = 0): Promise { + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); + } +} + +async function tapScan(): Promise { + await act(async () => { + fireEvent.click(scanButton()!); + }); + await settle(); +} + +function renderText(props: Record = {}, field: Record = {}) { + const onChange = vi.fn(); + render( + , + ); + return onChange; +} + +describe('text field scan button', () => { + it('control: a plain browser (no shell global) renders no scan button', () => { + renderText(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(scanButton()).toBeNull(); + }); + + it("control: an older shell whose has('scan') is false renders no scan button", () => { + const scan = installShell({ capabilities: ['haptic'] }); + renderText(); + expect(scanButton()).toBeNull(); + expect(scan).not.toHaveBeenCalled(); + }); + + it("a shell with has('scan') renders the button on an editable text field", () => { + installShell(); + renderText(); + expect(scanButton()).toBeInTheDocument(); + }); + + it('a read-only, a disabled and a password field render no scan button', () => { + installShell(); + renderText({ readonly: true }); + expect(scanButton()).toBeNull(); + cleanup(); + renderText({ disabled: true }); + expect(scanButton()).toBeNull(); + cleanup(); + renderText({}, { type: 'password' }); + expect(scanButton()).toBeNull(); + }); + + it('the decoded value lands through onChange', async () => { + const scan = installShell({ answer: 'ASSET-0042' }); + const onChange = renderText(); + await tapScan(); + expect(scan).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('ASSET-0042'); + }); + + it('null (the user closed the scanner) changes nothing and shows no toast', async () => { + const error = vi.spyOn(toast, 'error'); + const scan = installShell({ answer: null }); + const onChange = renderText(); + await tapScan(); + expect(scan).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("a failed scan raises the error toast with the shell's message and changes nothing", async () => { + const error = vi.spyOn(toast, 'error'); + installShell({ answer: Object.assign(new Error('Camera permission denied'), { code: 'failed' }) }); + const onChange = renderText(); + await tapScan(); + expect(onChange).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('Could not scan', { description: 'Camera permission denied' }); + }); +}); + +const ROWS: Record[] = [ + { id: 'a1', name: 'ASSET-0042 Forklift' }, + { id: 'a2', name: 'ASSET-0077 Pallet jack' }, + { id: 'a3', name: 'ASSET-0099 Scissor lift' }, +]; + +function makeBackend() { + const find = vi.fn(async (_objectName: string, params?: { $search?: string; $filter?: { id?: { $in?: unknown[] } } }) => { + const wanted = params?.$filter?.id?.$in; + if (wanted) { + const rows = ROWS.filter((r) => wanted.includes(r.id)); + return { data: rows.map((r) => ({ ...r })), total: rows.length }; + } + const term = params?.$search; + const rows = term ? ROWS.filter((r) => String(r.name).includes(term)) : ROWS; + return { data: rows.map((r) => ({ ...r })), total: rows.length }; + }); + const findOne = vi.fn(async (_o: string, id: string) => ROWS.find((r) => r.id === id) ?? null); + return { find, findOne }; +} + +function renderLookup(ds: ReturnType, field: Record = {}, props: Record = {}) { + render( + {}} + dataSource={ds as never} + field={{ name: 'asset', reference: 'asset', label: 'Asset', ...field } as never} + {...props} + />, + ); +} + +const searchedTerms = (ds: ReturnType) => + ds.find.mock.calls.map((c) => c[1]?.$search).filter(Boolean); + +describe('lookup field scan button', () => { + it('control: a plain browser renders no scan button on a lookup', async () => { + const ds = makeBackend(); + renderLookup(ds); + await settle(); + expect(screen.getByTestId('lookup-trigger-asset')).toBeInTheDocument(); + expect(scanButton()).toBeNull(); + }); + + it("control: an older shell whose has('scan') is false renders no scan button on a lookup", async () => { + installShell({ capabilities: ['haptic'] }); + const ds = makeBackend(); + renderLookup(ds); + await settle(); + expect(scanButton()).toBeNull(); + }); + + it('a disabled lookup renders no scan button', async () => { + installShell(); + const ds = makeBackend(); + renderLookup(ds, {}, { disabled: true }); + await settle(); + expect(scanButton()).toBeNull(); + }); + + it('the decoded value opens the dropdown and drives its search', async () => { + installShell({ answer: 'ASSET-0042' }); + const ds = makeBackend(); + renderLookup(ds); + await settle(); + await tapScan(); + expect(screen.getByRole('combobox')).toHaveValue('ASSET-0042'); + await settle(70); + await waitFor(() => expect(searchedTerms(ds)).toContain('ASSET-0042')); + await waitFor(() => expect(screen.getAllByRole('option').map((o) => o.getAttribute('title'))).toEqual(['ASSET-0042 Forklift'])); + }); + + it('null leaves a lookup closed and unsearched', async () => { + installShell({ answer: null }); + const ds = makeBackend(); + renderLookup(ds); + await settle(); + await tapScan(); + expect(screen.queryByRole('combobox')).toBeNull(); + expect(searchedTerms(ds)).toEqual([]); + }); + + it("the search-first variant opens its picker seeded with the decoded value", async () => { + installShell({ answer: 'ASSET-0077' }); + const ds = makeBackend(); + renderLookup(ds, { picker: 'search' }); + await settle(); + await tapScan(); + const box = await screen.findByTestId('people-picker-search'); + expect(box).toHaveValue('ASSET-0077'); + await settle(70); + await waitFor(() => expect(searchedTerms(ds)).toContain('ASSET-0077')); + }); +}); From cfa0fd12afe973e18bcc2314a0ef7418f2cc4f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:43:57 +0000 Subject: [PATCH 4/5] chore: changeset for the shell scan button Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv --- .changeset/29378-shell-scan-fields.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/29378-shell-scan-fields.md diff --git a/.changeset/29378-shell-scan-fields.md b/.changeset/29378-shell-scan-fields.md new file mode 100644 index 00000000000..d7f896f4283 --- /dev/null +++ b/.changeset/29378-shell-scan-fields.md @@ -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. From bf41e3aebd802a49748859edee71d8df5edfd86d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:23:27 +0000 Subject: [PATCH 5/5] test(mobile): read the scan call's this from the mock, not an alias Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jUw36sPe8KXr9ANYMv --- ui/packages/mobile/src/__tests__/nativeHost.test.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx index 9e9547c7599..b3acfcb0817 100644 --- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx +++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx @@ -226,15 +226,12 @@ describe('typed call wrappers feature-detect and never throw', () => { it('scan resolves "ok" with the decoded text, forwards the formats, and keeps the bridge as `this`', async () => { const bridge = fakeBridge(); - let self: unknown; - bridge.scan = vi.fn(async function (this: unknown) { - self = this; - return 'ASSET-0042'; - }); + const scan = vi.fn(async () => 'ASSET-0042'); + bridge.scan = scan; install(bridge); await expect(nativeScan({ formats: ['qr', 'ean13'] })).resolves.toEqual({ status: 'ok', value: 'ASSET-0042' }); - expect(bridge.scan).toHaveBeenCalledWith({ formats: ['qr', 'ean13'] }); - expect(self).toBe(bridge); + expect(scan).toHaveBeenCalledWith({ formats: ['qr', 'ean13'] }); + expect(scan.mock.contexts[0]).toBe(bridge); }); it('scan resolves "ok" with null when the user closes the scanner', async () => {