feat(import): bookmarklet for store pages
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-30 15:59:05 +02:00
1 parent c6d76b4cb5
commit f01ad03f6e
8 files changed
+411 -22

No files matched your search

+26 -3
View File
@@ -1,7 +1,8 @@
import {useCallback, useEffect, useState} from 'react';
import {Link, useSearchParams} from 'react-router-dom';
import {Link, useLocation, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Loader2, Upload} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import ImportHistory from '../components/import/ImportHistory.jsx';
@@ -9,15 +10,20 @@ import ImportInput from '../components/import/ImportInput.jsx';
import ImportView from '../components/import/ImportView.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {listImports, RUNNING} from '../utils/imports.js';
import {decodeStoreList} from '../utils/storeList.js';
/**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; the history below can undo a saved import.
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list
* the bookmarklet read on a store page.
*/
export default function ImportPage() {
const {t} = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [imports, setImports] = useState(null);
const [storeRows, setStoreRows] = useState(null);
const {hash, search} = useLocation();
const navigate = useNavigate();
const id = searchParams.get('id');
const loadImports = useCallback(async () => {
@@ -37,6 +43,21 @@ export default function ImportPage() {
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
const open = imports?.find((entry) => RUNNING.includes(entry.status));
// Out of the URL, so a reload or the back button does not bring the list again. Only after the first answer: on
// a 401 the login page must still get it.
useEffect(() => {
const data = new URLSearchParams(hash.slice(1)).get('data');
if (!data || imports === null) return;
const rows = decodeStoreList(data);
if (!rows?.length) toast.error(t('IMPORT.STORE_LIST_BROKEN'));
else {
setStoreRows(rows);
if (open) toast.info(t('IMPORT.STORE_LIST_WAITS'));
}
navigate({search}, {replace: true});
}, [hash, search, navigate, t, imports, open]);
const show = useCallback((next) => setSearchParams(next ? {id: String(next)} : {}, {replace: !id}),
[setSearchParams, id]);
// Into the URL, so the import stays shown when it leaves the worker (review, done).
@@ -62,7 +83,9 @@ export default function ImportPage() {
) : shownId ? (
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : (
<ImportInput onCreated={(created) => {
// A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
setStoreRows(null);
show(created);
loadImports();
}}/>
+77 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {initialChoice, stepOf} from '../utils/imports.js';
@@ -11,7 +11,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
@@ -29,6 +29,11 @@ const ready = {
};
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
const ShownUrl = () => {
const {pathname, search, hash} = useLocation();
return <output aria-label="url">{pathname + search + hash}</output>;
};
function renderPage(routes, entry = '/import') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
@@ -44,10 +49,13 @@ function renderPage(routes, entry = '/import') {
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
<ShownUrl/>
</MemoryRouter>,
);
}
const storeData = (games) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(games))));
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
const polls = (path) => fetch.mock.calls.filter(([url, init]) => !init?.method && url.endsWith(path)).length;
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
@@ -154,6 +162,59 @@ describe('ImportPage', () => {
});
});
it('takes the store list from the URL, drops it there and sends it as BOOKMARKLET', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'BOOKMARKLET', status: 'MATCHING', total: 2, matched: 0}),
}, `/import#data=${storeData([['Hades', 'PC'], ['Pokémon Scarlet', 'Switch']])}`);
expect(await screen.findByText('IMPORT.STORE_READ:2')).toBeTruthy();
expect(screen.getByText('Pokémon Scarlet · Switch')).toBeTruthy();
expect(screen.getByLabelText('url').textContent).toBe('/import');
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await screen.findByText('IMPORT.MATCHING');
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({source: 'BOOKMARKLET', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: null},
{title: 'Pokémon Scarlet', platform: 'Switch', playtimeMinutes: null, status: null},
]});
});
it('tells the user when the store list in the URL is broken', async () => {
renderPage({'GET ': json(200, {imports: []})}, '/import#data=broken');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
expect(screen.getByLabelText('url').textContent).toBe('/import');
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('says that the store list waits while another import runs', async () => {
renderPage({
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
'GET /5': json(200, {...ready, status: 'MATCHING', rows: null}),
}, `/import#data=${storeData([['Hades', 'PC']])}`);
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('IMPORT.STORE_LIST_WAITS'));
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
});
it('treats a store list without a usable row as broken', async () => {
renderPage({'GET ': json(200, {imports: []})}, `/import#data=${btoa('[[1, "PC"]]')}`);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('offers the bookmarklet as a link, not run on click', async () => {
renderPage({'GET ': json(200, {imports: []})});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_BOOKMARKLET'}));
const link = screen.getByText('IMPORT.STORE_LINK');
expect(link.getAttribute('href')).toMatch(/^javascript:[^#]*$/);
expect(fireEvent.click(link)).toBe(false);
});
it('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
@@ -360,4 +421,18 @@ describe('ImportPage session', () => {
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
expect(toast.error).not.toHaveBeenCalled();
});
it('keeps the store list in the URL for the login when the session has expired', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/imports': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const entry = `/import#data=${storeData([['Hades', 'PC']])}`;
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: [entry], guarded: true,
routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: entry});
});
});