Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scan-tile-recent-top.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'wallet-frontend': patch
---

Scan Physical ID sits at the top of Add Credentials and appears under Recent once used
135 changes: 135 additions & 0 deletions src/components/QueryableList/QueryableList.placement.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryableList<Item>
list={items}
recent={['1']}
queryField="name"
isOnline={true}
translationPrefix="pageAddCredentials"
identifierField="id"
extraSection={<div data-testid="extra">Scan Physical ID entry</div>}
/>
);

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(
<QueryableList<Item>
list={items}
recent={['1']}
queryField="name"
isOnline={true}
translationPrefix="pageAddCredentials"
identifierField="id"
extraSection={<div data-testid="extra">Scan Physical ID entry</div>}
/>
);

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<React.ComponentProps<typeof QueryableList>> = {}) =>
render(
<QueryableList<Item>
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: <button data-testid="recent-scan">Scan Physical ID</button>,
});

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: <button data-testid="recent-scan">Scan</button> });

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: <button data-testid="recent-scan">Scan</button>,
});

fireEvent.change(container.querySelector('input') as HTMLInputElement, {
target: { value: 'Alpha' },
});
expect(screen.queryByTestId('recent-scan')).toBeNull();
});
});
21 changes: 18 additions & 3 deletions src/components/QueryableList/QueryableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ type QueryableListProps<T> = {
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[] = [];
Expand All @@ -28,6 +35,7 @@ const QueryableList = <T extends object>({
onClick,
identifierField,
extraSection,
recentExtra,
}: QueryableListProps<T>) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState<string>("");
Expand Down Expand Up @@ -69,18 +77,26 @@ const QueryableList = <T extends object>({
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}
<div className="my-4">
<SearchInput
placeholder={t(translationPrefix + ".searchPlaceholder")}
searchCallback={handleSearch}
/>
<div className="my-2">
{recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery && <H3 heading={t("queryableList.recent")} />}
{hasRecentSection && <H3 heading={t("queryableList.recent")} />}
<div
className="mb-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"
>
{!searchQuery && recentExtra}
{!searchQuery && recentCredentialConfigurations.map((el) => (
<Button
id={`querylist-recent-${sanitizeId(getElementPropValue(el, identifierField as string) as string)}`}
Expand All @@ -103,8 +119,7 @@ const QueryableList = <T extends object>({
</div>
</div>
</div>
{!searchQuery && extraSection}
{recentCredentialConfigurations.length > 0 && recentList.length > 0 && !searchQuery && <H3 heading={t("queryableList.all")} />}
{hasRecentSection && <H3 heading={t("queryableList.all")} />}
{filteredList.length === 0 ? (
<p className="text-lm-gray-800 dark:text-dm-gray-200 mt-4">
{t(translationPrefix + ".noFound")}
Expand Down
74 changes: 74 additions & 0 deletions src/lib/utils/scanPhysicalIdUsage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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<string, unknown>).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<string, unknown>).localStorage;
expect(() => recordScanPhysicalIdUsed()).not.toThrow();
expect(hasUsedScanPhysicalId()).toBe(false);
});
});
32 changes: 32 additions & 0 deletions src/lib/utils/scanPhysicalIdUsage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 24 additions & 1 deletion src/pages/AddCredentials/AddCredentials.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
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 = () => {
Expand All @@ -23,6 +25,7 @@
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);
Expand Down Expand Up @@ -153,6 +156,16 @@
}
}

// 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'));

Check warning on line 166 in src/pages/AddCredentials/AddCredentials.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.

See more on https://sonarcloud.io/project/issues?id=sirosfoundation_wallet-frontend&issues=AaGEBf9Xt8i2H24ad0bE&open=AaGEBf9Xt8i2H24ad0bE&pullRequest=415
};

const handleCancel = () => {
setShowRedirectPopup(false);
setSelectedCredentialConfiguration(null);
Expand Down Expand Up @@ -198,12 +211,22 @@
translationPrefix='pageAddCredentials'
identifierField='identifierField'
onClick={handleCredentialConfigurationClick}
recentExtra={isScanAvailable && scanUsed ? (
<Button
id="querylist-recent-scan-physical-id"
variant="outline"
additionalClassName="wrap-break-word w-full text-left"
onClick={openScanPhysicalId}
>
{t('pageAddCredentials.scanPhysicalId.digitalId.title')}
</Button>
) : null}
extraSection={
<div className={`mb-4 ${isScanAvailable ? '' : 'hidden'}`} data-widget="scan-physical-id">
<H3 heading={t('pageAddCredentials.scanPhysicalId.sectionTitle')} hr />
<button
className="w-full flex items-center justify-between gap-4 border border-lm-gray-400 dark:border-dm-gray-600 rounded-xl p-4 bg-lm-gray-50 dark:bg-dm-gray-800 hover:brightness-[0.97] dark:hover:brightness-[1.05] transition-all text-left cursor-pointer"
onClick={() => navigate(buildPath('add/digital-id'))}
onClick={openScanPhysicalId}
>
<div>
<p className="font-semibold text-lm-gray-900 dark:text-white">{t('pageAddCredentials.scanPhysicalId.digitalId.title')}</p>
Expand Down
Loading