From 120a22f57c37f4366902aac69f062aeb6eed0948 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Wed, 28 Oct 2026 15:36:16 -0400 Subject: [PATCH 1/3] fix: Scan Physical ID at the top of Add Credentials, and in Recent Closes #307. Placement. QueryableList rendered its extraSection between the Recent grid and the All heading, so the Scan Physical ID tile sat below recently used credentials rather than at the top of the page as the designs have it. It is the page's own entry point rather than one of the queryable items, so it now renders above the search box, Recent and All. Showing it only when no search is active is unchanged: a search is about the issuer list, and the entry point would be noise in the results. Recent. Every other Recent entry is derived from a stored credential matched against a configuration from /issuer/all. Scan Physical ID is a local route (add/digital-id), not an issuer's credential configuration, so there is nothing to derive it from and it could never appear there. QueryableList gains a recentExtra slot, rendered first in the Recent grid, and AddCredentials fills it once the flow has been used. Starting the flow is what makes it recent, which is the only signal available without tying it to a particular issued credential. Both headings were gated on there being matched recent configurations, so a wallet with no credentials yet would have shown the new entry floating under no heading; they now account for recentExtra too. The usage flag is a per-device convenience in localStorage: no personal data, nothing depends on it, and losing it only means the entry is absent from Recent until the next scan. Both accessors swallow failure, which matters more here than usual -- this test environment has no localStorage object at all, which is why several of the suite's pre-existing failures are modules touching it at import time. Tests live in a new QueryableList.placement.test.tsx rather than QueryableList.test.tsx, because that file imports @/i18n, reads localStorage at module scope and so never loads; a test added there would not have run. It uses the react-i18next stand-in the repo already uses in usePresentCredentialsFlow.test.tsx. The placement assertion was mutation-proved by moving the section back and watching it fail. AddCredentials' own wiring is not unit-tested: AddCredentials.test.jsx is one of the five files that already fail to load. Verified: 1364 tests pass, 0 fail (base: 1353, 0); the same five pre-existing file failures; typecheck 100 errors, exactly base; build succeeds. --- .changeset/scan-tile-recent-top.md | 5 + .../QueryableList.placement.test.tsx | 135 ++++++++++++++++++ .../QueryableList/QueryableList.tsx | 21 ++- src/lib/utils/scanPhysicalIdUsage.test.ts | 74 ++++++++++ src/lib/utils/scanPhysicalIdUsage.ts | 32 +++++ src/pages/AddCredentials/AddCredentials.jsx | 25 +++- 6 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 .changeset/scan-tile-recent-top.md create mode 100644 src/components/QueryableList/QueryableList.placement.test.tsx create mode 100644 src/lib/utils/scanPhysicalIdUsage.test.ts create mode 100644 src/lib/utils/scanPhysicalIdUsage.ts diff --git a/.changeset/scan-tile-recent-top.md b/.changeset/scan-tile-recent-top.md new file mode 100644 index 00000000..82a34fb1 --- /dev/null +++ b/.changeset/scan-tile-recent-top.md @@ -0,0 +1,5 @@ +--- +'wallet-frontend': patch +--- + +Scan Physical ID sits at the top of Add Credentials and appears under Recent once used diff --git a/src/components/QueryableList/QueryableList.placement.test.tsx b/src/components/QueryableList/QueryableList.placement.test.tsx new file mode 100644 index 00000000..3dd640e8 --- /dev/null +++ b/src/components/QueryableList/QueryableList.placement.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import QueryableList from './QueryableList'; + +// QueryableList.test.tsx imports `@/i18n`, which reads localStorage at module +// scope and so fails to load in this environment -- it is one of the suite's +// pre-existing broken files. This test lives on its own, with the same +// react-i18next stand-in the repo already uses elsewhere, so that it actually +// runs. +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }), +})); + +type Item = { id: string; name: string }; + +const items: Item[] = [ + { id: '1', name: 'Alpha Credential' }, + { id: '2', name: 'Beta Credential' }, +]; + +/** `a` precedes `b` in document order. */ +const precedes = (a: Node, b: Node) => + Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + +describe('QueryableList extraSection placement', () => { + // wallet-frontend#307: the extra section is the page's own entry point -- + // Scan Physical ID on Add Credentials -- not one of the queryable items, so + // it belongs at the top of the page rather than between Recent and All, + // which is where it used to render. + it('renders extraSection before the search input, Recent and All', () => { + const { container } = render( + + list={items} + recent={['1']} + queryField="name" + isOnline={true} + translationPrefix="pageAddCredentials" + identifierField="id" + extraSection={
Scan Physical ID entry
} + /> + ); + + const extra = screen.getByTestId('extra'); + const search = container.querySelector('input'); + expect(search).not.toBeNull(); + + const recentHeading = screen.getByText('queryableList.recent'); + const allHeading = screen.getByText('queryableList.all'); + + expect(precedes(extra, search as Node), 'extraSection should precede the search input').toBe(true); + expect(precedes(extra, recentHeading), 'extraSection should precede the Recent heading').toBe(true); + expect(precedes(extra, allHeading), 'extraSection should precede the All heading').toBe(true); + }); + + it('still hides extraSection while a search query is active', () => { + const { container } = render( + + list={items} + recent={['1']} + queryField="name" + isOnline={true} + translationPrefix="pageAddCredentials" + identifierField="id" + extraSection={
Scan Physical ID entry
} + /> + ); + + const search = container.querySelector('input') as HTMLInputElement; + fireEvent.change(search, { target: { value: 'Alpha' } }); + + // Moving the section must not change when it is shown; a search is about + // the issuer list, and the entry point would only be noise in the results. + expect(screen.queryByTestId('extra')).toBeNull(); + }); +}); + +describe('QueryableList recentExtra', () => { + // wallet-frontend#307 again: Scan Physical ID has no credential of its own to + // derive a Recent entry from, so the page supplies the entry directly. + const renderWith = (props: Partial> = {}) => + render( + + list={items} + queryField="name" + isOnline={true} + translationPrefix="pageAddCredentials" + identifierField="id" + {...(props as object)} + /> + ); + + it('renders recentExtra as the first entry of the Recent grid', () => { + renderWith({ + recent: ['1'], + recentExtra: , + }); + + const scan = screen.getByTestId('recent-scan'); + // By id, not text: the matched item also appears in the All list below. + const matched = document.getElementById('querylist-recent-1'); + expect(matched).not.toBeNull(); + expect(precedes(scan, matched as Node), 'recentExtra should come first in Recent').toBe(true); + }); + + // Without this the heading was gated purely on matched configurations, so a + // freshly installed wallet with no credentials yet would have shown the entry + // floating under no heading at all. + it('shows the Recent and All headings when only recentExtra is present', () => { + renderWith({ recent: [], recentExtra: }); + + expect(screen.getByTestId('recent-scan')).toBeTruthy(); + expect(screen.getByText('queryableList.recent')).toBeTruthy(); + expect(screen.getByText('queryableList.all')).toBeTruthy(); + }); + + it('shows no Recent section when there is neither a match nor recentExtra', () => { + renderWith({ recent: [] }); + + expect(screen.queryByText('queryableList.recent')).toBeNull(); + expect(screen.queryByText('queryableList.all')).toBeNull(); + }); + + it('hides recentExtra while a search query is active', () => { + const { container } = renderWith({ + recent: ['1'], + recentExtra: , + }); + + fireEvent.change(container.querySelector('input') as HTMLInputElement, { + target: { value: 'Alpha' }, + }); + expect(screen.queryByTestId('recent-scan')).toBeNull(); + }); +}); diff --git a/src/components/QueryableList/QueryableList.tsx b/src/components/QueryableList/QueryableList.tsx index cbafe313..adffd849 100644 --- a/src/components/QueryableList/QueryableList.tsx +++ b/src/components/QueryableList/QueryableList.tsx @@ -15,6 +15,13 @@ type QueryableListProps = { onClick?: (identifier: string | number) => void; identifierField?: keyof T; extraSection?: React.ReactNode; + /** + * Rendered as the first entry of the Recent grid. For something the page + * offers that is not one of `list`'s items and so has no identifier to match + * a `recent` entry against -- Scan Physical ID on Add Credentials + * (wallet-frontend#307). + */ + recentExtra?: React.ReactNode; }; const defaultRecent: any[] = []; @@ -28,6 +35,7 @@ const QueryableList = ({ onClick, identifierField, extraSection, + recentExtra, }: QueryableListProps) => { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); @@ -69,18 +77,26 @@ const QueryableList = ({ setRecentCredentialConfigurations(recentConfigs); }, [recentList, filteredList, identifierField]); + // The "Recent"/"All" headings only earn their place when there is a Recent + // section to distinguish from the full list. + const hasRecentSection = !searchQuery && ( + Boolean(recentExtra) || (recentCredentialConfigurations.length > 0 && recentList.length > 0) + ); + return ( <> + {!searchQuery && extraSection}
- {recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery &&

} + {hasRecentSection &&

}
+ {!searchQuery && recentExtra} {!searchQuery && recentCredentialConfigurations.map((el) => (

- {!searchQuery && extraSection} - {recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery &&

} + {hasRecentSection &&

} {filteredList.length === 0 ? (

{t(translationPrefix + ".noFound")} diff --git a/src/lib/utils/scanPhysicalIdUsage.test.ts b/src/lib/utils/scanPhysicalIdUsage.test.ts new file mode 100644 index 00000000..9bedbf54 --- /dev/null +++ b/src/lib/utils/scanPhysicalIdUsage.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hasUsedScanPhysicalId, recordScanPhysicalIdUsed } from './scanPhysicalIdUsage'; + +// This environment provides no localStorage at all -- several of the suite's +// pre-existing failures are modules touching it at import time -- so each test +// installs exactly the storage it wants to exercise. +const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + +function install(storage: unknown) { + Object.defineProperty(globalThis, 'localStorage', { + value: storage, + configurable: true, + writable: true, + }); +} + +function memoryStorage() { + const map = new Map(); + return { + getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null), + setItem: (k: string, v: string) => void map.set(k, v), + }; +} + +beforeEach(() => install(memoryStorage())); + +afterEach(() => { + if (original) { + Object.defineProperty(globalThis, 'localStorage', original); + } else { + delete (globalThis as Record).localStorage; + } +}); + +describe('scanPhysicalIdUsage', () => { + it('reports not used before anything is recorded', () => { + expect(hasUsedScanPhysicalId()).toBe(false); + }); + + it('reports used after recording', () => { + recordScanPhysicalIdUsed(); + expect(hasUsedScanPhysicalId()).toBe(true); + }); + + // The entry point is pinned at the top of the page regardless, so a device + // that cannot store this must still render rather than fail with it. Both + // shapes matter: a storage that refuses (private window, blocked site data) + // and no storage object at all, which is what this very environment has. + it('survives a localStorage that throws on write', () => { + install({ + getItem: () => null, + setItem: () => { + throw new DOMException('denied'); + }, + }); + expect(() => recordScanPhysicalIdUsed()).not.toThrow(); + }); + + it('reports not used when localStorage throws on read', () => { + install({ + getItem: () => { + throw new DOMException('denied'); + }, + setItem: () => undefined, + }); + expect(hasUsedScanPhysicalId()).toBe(false); + }); + + it('survives localStorage being absent entirely', () => { + delete (globalThis as Record).localStorage; + expect(() => recordScanPhysicalIdUsed()).not.toThrow(); + expect(hasUsedScanPhysicalId()).toBe(false); + }); +}); diff --git a/src/lib/utils/scanPhysicalIdUsage.ts b/src/lib/utils/scanPhysicalIdUsage.ts new file mode 100644 index 00000000..27177bf1 --- /dev/null +++ b/src/lib/utils/scanPhysicalIdUsage.ts @@ -0,0 +1,32 @@ +const STORAGE_KEY = 'scanPhysicalIdLastUsed'; + +/** + * Remembers that the Scan Physical ID flow was used, so the Add Credentials + * page can list it under "Recent" (wallet-frontend#307). + * + * Every other Recent entry is derived from a stored credential, but the scan + * flow is a local route rather than an issuer's credential configuration, so + * there is nothing to derive it from -- hence this one small piece of state. + * + * It is a per-device convenience, not wallet data: it holds no personal + * information, nothing depends on it being present, and losing it only means + * the entry point is absent from Recent until the next scan. localStorage can + * be unavailable or throw outright (private windows, blocked site data), so + * both calls swallow failure rather than letting the page fail with it. + */ +export function recordScanPhysicalIdUsed(now: number = Date.now()): void { + try { + localStorage.setItem(STORAGE_KEY, String(now)); + } catch { + // A device that will not remember this still has the pinned entry point. + } +} + +/** Whether the Scan Physical ID flow has been used on this device. */ +export function hasUsedScanPhysicalId(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) !== null; + } catch { + return false; + } +} diff --git a/src/pages/AddCredentials/AddCredentials.jsx b/src/pages/AddCredentials/AddCredentials.jsx index f7476b01..25c1b6a5 100644 --- a/src/pages/AddCredentials/AddCredentials.jsx +++ b/src/pages/AddCredentials/AddCredentials.jsx @@ -14,6 +14,8 @@ import QueryableList from '../../components/QueryableList/QueryableList'; import CredentialsContext from '@/context/CredentialsContext'; import useFilterItemByLang from '@/hooks/useFilterItemByLang'; import { buildCredentialConfiguration } from '@/components/QueryableList/CredentialsDisplayUtils'; +import Button from '@/components/Buttons/Button'; +import { hasUsedScanPhysicalId, recordScanPhysicalIdUsed } from '@/lib/utils/scanPhysicalIdUsage'; import { useOpenID4VCIHelper } from '@/lib/services/OpenID4VCIHelper'; const AddCredentials = () => { @@ -23,6 +25,7 @@ const AddCredentials = () => { const [recent, setRecent] = useState([]); const [credentialConfigurations, setCredentialConfigurations] = useState([]); const [showRedirectPopup, setShowRedirectPopup] = useState(false); + const [scanUsed, setScanUsed] = useState(() => hasUsedScanPhysicalId()); const [selectedCredentialConfiguration, setSelectedCredentialConfiguration] = useState(null); const { vcEntityList, getData } = useContext(CredentialsContext); @@ -153,6 +156,16 @@ const AddCredentials = () => { } } + // Scan Physical ID is a local route rather than an issuer's credential + // configuration, so its Recent entry cannot be derived from a stored + // credential the way the others are (wallet-frontend#307). Starting the flow + // is what makes it recent. + const openScanPhysicalId = () => { + recordScanPhysicalIdUsed(); + setScanUsed(true); + navigate(buildPath('add/digital-id')); + }; + const handleCancel = () => { setShowRedirectPopup(false); setSelectedCredentialConfiguration(null); @@ -198,12 +211,22 @@ const AddCredentials = () => { translationPrefix='pageAddCredentials' identifierField='identifierField' onClick={handleCredentialConfigurationClick} + recentExtra={isScanAvailable && scanUsed ? ( + + ) : null} extraSection={

, - }); - - const scan = screen.getByTestId('recent-scan'); - // By id, not text: the matched item also appears in the All list below. - const matched = document.getElementById('querylist-recent-1'); - expect(matched).not.toBeNull(); - expect(precedes(scan, matched as Node), 'recentExtra should come first in Recent').toBe(true); - }); - - // Without this the heading was gated purely on matched configurations, so a - // freshly installed wallet with no credentials yet would have shown the entry - // floating under no heading at all. - it('shows the Recent and All headings when only recentExtra is present', () => { - renderWith({ recent: [], recentExtra: }); - - expect(screen.getByTestId('recent-scan')).toBeTruthy(); - expect(screen.getByText('queryableList.recent')).toBeTruthy(); - expect(screen.getByText('queryableList.all')).toBeTruthy(); - }); - - it('shows no Recent section when there is neither a match nor recentExtra', () => { - renderWith({ recent: [] }); - - expect(screen.queryByText('queryableList.recent')).toBeNull(); - expect(screen.queryByText('queryableList.all')).toBeNull(); - }); - - it('hides recentExtra while a search query is active', () => { - const { container } = renderWith({ - recent: ['1'], - recentExtra: , - }); - - fireEvent.change(container.querySelector('input') as HTMLInputElement, { - target: { value: 'Alpha' }, - }); - expect(screen.queryByTestId('recent-scan')).toBeNull(); - }); -}); diff --git a/src/components/QueryableList/QueryableList.test.tsx b/src/components/QueryableList/QueryableList.test.tsx deleted file mode 100644 index 56d8166c..00000000 --- a/src/components/QueryableList/QueryableList.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import i18n from '@/i18n'; -import QueryableList from './QueryableList'; - -beforeEach(() => { - i18n.changeLanguage('en'); -}); - -afterEach(() => { - i18n.changeLanguage('en'); -}); - -type Item = { id: string; name: string }; - -const items: Item[] = [ - { id: '1', name: 'Alpha Credential' }, - { id: '2', name: 'Beta Credential' }, -]; - -describe('QueryableList extraSection', () => { - it('renders extraSection when no search query is entered', () => { - render( - - list={items} - queryField="name" - isOnline={true} - translationPrefix="pageAddCredentials" - identifierField="id" - extraSection={
Scan Physical ID entry
} - /> - ); - - expect(screen.getByTestId('extra')).toBeInTheDocument(); - }); - - it('hides extraSection while a search query is active', () => { - render( - - list={items} - queryField="name" - isOnline={true} - translationPrefix="pageAddCredentials" - identifierField="id" - extraSection={
Scan Physical ID entry
} - /> - ); - - expect(screen.getByTestId('extra')).toBeInTheDocument(); - - fireEvent.change(screen.getByPlaceholderText('Search credential...'), { - target: { value: 'Alpha' }, - }); - - expect(screen.queryByTestId('extra')).not.toBeInTheDocument(); - }); - - it('renders nothing extra when extraSection is not provided', () => { - render( - - list={items} - queryField="name" - isOnline={true} - translationPrefix="pageAddCredentials" - identifierField="id" - /> - ); - - expect(screen.queryByTestId('extra')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/QueryableList/QueryableList.tsx b/src/components/QueryableList/QueryableList.tsx index adffd849..2cdefe30 100644 --- a/src/components/QueryableList/QueryableList.tsx +++ b/src/components/QueryableList/QueryableList.tsx @@ -14,14 +14,6 @@ type QueryableListProps = { translationPrefix: string; onClick?: (identifier: string | number) => void; identifierField?: keyof T; - extraSection?: React.ReactNode; - /** - * Rendered as the first entry of the Recent grid. For something the page - * offers that is not one of `list`'s items and so has no identifier to match - * a `recent` entry against -- Scan Physical ID on Add Credentials - * (wallet-frontend#307). - */ - recentExtra?: React.ReactNode; }; const defaultRecent: any[] = []; @@ -34,8 +26,6 @@ const QueryableList = ({ translationPrefix, onClick, identifierField, - extraSection, - recentExtra, }: QueryableListProps) => { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); @@ -77,26 +67,18 @@ const QueryableList = ({ setRecentCredentialConfigurations(recentConfigs); }, [recentList, filteredList, identifierField]); - // The "Recent"/"All" headings only earn their place when there is a Recent - // section to distinguish from the full list. - const hasRecentSection = !searchQuery && ( - Boolean(recentExtra) || (recentCredentialConfigurations.length > 0 && recentList.length > 0) - ); - return ( <> - {!searchQuery && extraSection}
- {hasRecentSection &&

} + {recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery &&

}
- {!searchQuery && recentExtra} {!searchQuery && recentCredentialConfigurations.map((el) => (

- {hasRecentSection &&

} + {recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery &&

} {filteredList.length === 0 ? (

{t(translationPrefix + ".noFound")} diff --git a/src/lib/utils/scanPhysicalIdUsage.test.ts b/src/lib/utils/scanPhysicalIdUsage.test.ts deleted file mode 100644 index 9bedbf54..00000000 --- a/src/lib/utils/scanPhysicalIdUsage.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { hasUsedScanPhysicalId, recordScanPhysicalIdUsed } from './scanPhysicalIdUsage'; - -// This environment provides no localStorage at all -- several of the suite's -// pre-existing failures are modules touching it at import time -- so each test -// installs exactly the storage it wants to exercise. -const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - -function install(storage: unknown) { - Object.defineProperty(globalThis, 'localStorage', { - value: storage, - configurable: true, - writable: true, - }); -} - -function memoryStorage() { - const map = new Map(); - return { - getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null), - setItem: (k: string, v: string) => void map.set(k, v), - }; -} - -beforeEach(() => install(memoryStorage())); - -afterEach(() => { - if (original) { - Object.defineProperty(globalThis, 'localStorage', original); - } else { - delete (globalThis as Record).localStorage; - } -}); - -describe('scanPhysicalIdUsage', () => { - it('reports not used before anything is recorded', () => { - expect(hasUsedScanPhysicalId()).toBe(false); - }); - - it('reports used after recording', () => { - recordScanPhysicalIdUsed(); - expect(hasUsedScanPhysicalId()).toBe(true); - }); - - // The entry point is pinned at the top of the page regardless, so a device - // that cannot store this must still render rather than fail with it. Both - // shapes matter: a storage that refuses (private window, blocked site data) - // and no storage object at all, which is what this very environment has. - it('survives a localStorage that throws on write', () => { - install({ - getItem: () => null, - setItem: () => { - throw new DOMException('denied'); - }, - }); - expect(() => recordScanPhysicalIdUsed()).not.toThrow(); - }); - - it('reports not used when localStorage throws on read', () => { - install({ - getItem: () => { - throw new DOMException('denied'); - }, - setItem: () => undefined, - }); - expect(hasUsedScanPhysicalId()).toBe(false); - }); - - it('survives localStorage being absent entirely', () => { - delete (globalThis as Record).localStorage; - expect(() => recordScanPhysicalIdUsed()).not.toThrow(); - expect(hasUsedScanPhysicalId()).toBe(false); - }); -}); diff --git a/src/lib/utils/scanPhysicalIdUsage.ts b/src/lib/utils/scanPhysicalIdUsage.ts deleted file mode 100644 index 27177bf1..00000000 --- a/src/lib/utils/scanPhysicalIdUsage.ts +++ /dev/null @@ -1,32 +0,0 @@ -const STORAGE_KEY = 'scanPhysicalIdLastUsed'; - -/** - * Remembers that the Scan Physical ID flow was used, so the Add Credentials - * page can list it under "Recent" (wallet-frontend#307). - * - * Every other Recent entry is derived from a stored credential, but the scan - * flow is a local route rather than an issuer's credential configuration, so - * there is nothing to derive it from -- hence this one small piece of state. - * - * It is a per-device convenience, not wallet data: it holds no personal - * information, nothing depends on it being present, and losing it only means - * the entry point is absent from Recent until the next scan. localStorage can - * be unavailable or throw outright (private windows, blocked site data), so - * both calls swallow failure rather than letting the page fail with it. - */ -export function recordScanPhysicalIdUsed(now: number = Date.now()): void { - try { - localStorage.setItem(STORAGE_KEY, String(now)); - } catch { - // A device that will not remember this still has the pinned entry point. - } -} - -/** Whether the Scan Physical ID flow has been used on this device. */ -export function hasUsedScanPhysicalId(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) !== null; - } catch { - return false; - } -} diff --git a/src/pages/AddCredentials/AddCredentials.jsx b/src/pages/AddCredentials/AddCredentials.jsx index 8b6cb4d4..07a6700b 100644 --- a/src/pages/AddCredentials/AddCredentials.jsx +++ b/src/pages/AddCredentials/AddCredentials.jsx @@ -14,8 +14,6 @@ import QueryableList from '../../components/QueryableList/QueryableList'; import CredentialsContext from '@/context/CredentialsContext'; import useFilterItemByLang from '@/hooks/useFilterItemByLang'; import { buildCredentialConfiguration } from '@/components/QueryableList/CredentialsDisplayUtils'; -import Button from '@/components/Buttons/Button'; -import { hasUsedScanPhysicalId, recordScanPhysicalIdUsed } from '@/lib/utils/scanPhysicalIdUsage'; import { useOpenID4VCIHelper } from '@/lib/services/OpenID4VCIHelper'; const AddCredentials = () => { @@ -25,7 +23,6 @@ const AddCredentials = () => { const [recent, setRecent] = useState([]); const [credentialConfigurations, setCredentialConfigurations] = useState([]); const [showRedirectPopup, setShowRedirectPopup] = useState(false); - const [scanUsed, setScanUsed] = useState(() => hasUsedScanPhysicalId()); const [selectedCredentialConfiguration, setSelectedCredentialConfiguration] = useState(null); const { vcEntityList, getData } = useContext(CredentialsContext); @@ -156,16 +153,6 @@ const AddCredentials = () => { } } - // Scan Physical ID is a local route rather than an issuer's credential - // configuration, so its Recent entry cannot be derived from a stored - // credential the way the others are (wallet-frontend#307). Starting the flow - // is what makes it recent. - const openScanPhysicalId = () => { - recordScanPhysicalIdUsed(); - setScanUsed(true); - void navigate(buildPath('add/digital-id')); - }; - const handleCancel = () => { setShowRedirectPopup(false); setSelectedCredentialConfiguration(null); @@ -202,6 +189,20 @@ const AddCredentials = () => {

+
+

+ +

+ {credentialConfigurations && recent && ( { translationPrefix='pageAddCredentials' identifierField='identifierField' onClick={handleCredentialConfigurationClick} - recentExtra={isScanAvailable && scanUsed ? ( - - ) : null} - extraSection={ -
-

- -

- } /> )}

diff --git a/src/pages/AddCredentials/AddCredentials.test.jsx b/src/pages/AddCredentials/AddCredentials.test.jsx index 6866c09f..b7029583 100644 --- a/src/pages/AddCredentials/AddCredentials.test.jsx +++ b/src/pages/AddCredentials/AddCredentials.test.jsx @@ -99,4 +99,33 @@ describe('AddCredentials scan-physical-id entry point', () => { const widget = document.querySelector('[data-widget="scan-physical-id"]'); expect(widget).toHaveClass('hidden'); }); + + // wallet-frontend#307: the entry point is the page's own, not one of the + // issuers' credential configurations, so it sits above the search bar and + // both lists instead of between Recent and All. + it('renders the section above the search bar', () => { + window.nativeWrapper = { startScanPhysicalId: vi.fn() }; + render(); + + const widget = document.querySelector('[data-widget="scan-physical-id"]'); + const search = screen.getByPlaceholderText('Search credential...'); + + const widgetIsFirst = + widget.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING; + expect(Boolean(widgetIsFirst)).toBe(true); + }); + + it('keeps the section visible while a search query is active', () => { + // It used to be passed into QueryableList, which hid it on any query. + // Above the search bar it is not part of what the query filters. + window.nativeWrapper = { startScanPhysicalId: vi.fn() }; + render(); + + fireEvent.change(screen.getByPlaceholderText('Search credential...'), { + target: { value: 'passport' }, + }); + + expect(document.querySelector('[data-widget="scan-physical-id"]')).not.toHaveClass('hidden'); + expect(screen.getByText('Digital ID (scanned passport)')).toBeInTheDocument(); + }); });