Public Access
feat(import): bookmarklet for store pages
This commit is contained in:
1 parent
c6d76b4cb5
commit
f01ad03f6e
8 files changed
+411
-22
No files matched your search
@@ -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();
|
||||
}}/>
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user