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. 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.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')); + }); +}); 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: '添加更多图片', diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx index 22696f6cf23..b3acfcb0817 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,36 @@ 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(); + 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(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 () => { + 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;