diff --git a/.storybook/main.ts b/.storybook/main.ts index 225fb9a1..8ebfec7a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -18,6 +18,7 @@ export default defineMain({ // https://github.com/storybookjs/storybook/discussions/33279 sidebarOnboardingChecklist: false, }, + staticDirs: ['../public'], "framework": "@storybook/react-vite", viteFinal: async (config) => { return mergeConfig(config, { diff --git a/.storybook/preview.ts b/.storybook/preview.ts index f705b63c..8e584a59 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -3,8 +3,12 @@ import addonA11y from "@storybook/addon-a11y"; import { definePreview } from "@storybook/react-vite"; import { themes } from 'storybook/theming'; +import { initialize, mswLoader } from 'msw-storybook-addon'; + +initialize(); export default definePreview({ + loaders: [mswLoader], parameters: { controls: { matchers: { diff --git a/package-lock.json b/package-lock.json index a4c516ab..d4699f5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -124,6 +124,7 @@ "@storybook/addon-docs": "^10.4.3", "@storybook/react-vite": "^10.4.3", "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.101.0", "@tsconfig/node18": "^1.0.1", "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.5", @@ -159,9 +160,11 @@ "eslint-plugin-prefer-arrow-functions": "^3.2.4", "eslint-plugin-storybook": "^10.3.6", "git-cliff": "^2.12.0", + "ky": "^2.0.2", "mocha": "^10.3.0", "mockdate": "^3.0.5", - "msw": "^2.12.10", + "msw": "^2.14.6", + "msw-storybook-addon": "^2.0.7", "next-themes": "^0.4.6", "nodemon": "^3.0.3", "playwright": "^1.58.2", @@ -5170,6 +5173,34 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -12353,6 +12384,19 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/ky": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/lastfm-ts-api": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/lastfm-ts-api/-/lastfm-ts-api-2.6.2.tgz", @@ -13326,6 +13370,19 @@ } } }, + "node_modules/msw-storybook-addon": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz", + "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^2.0.0" + } + }, "node_modules/msw/node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", diff --git a/package.json b/package.json index 867ae467..b4ab3343 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "@storybook/addon-docs": "^10.4.3", "@storybook/react-vite": "^10.4.3", "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.101.0", "@tsconfig/node18": "^1.0.1", "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.5", @@ -198,9 +199,11 @@ "eslint-plugin-prefer-arrow-functions": "^3.2.4", "eslint-plugin-storybook": "^10.3.6", "git-cliff": "^2.12.0", + "ky": "^2.0.2", "mocha": "^10.3.0", "mockdate": "^3.0.5", - "msw": "^2.12.10", + "msw": "^2.14.6", + "msw-storybook-addon": "^2.0.7", "next-themes": "^0.4.6", "nodemon": "^3.0.3", "playwright": "^1.58.2", @@ -242,5 +245,10 @@ "musicbrainz-api": { "rate-limit-threshold": "0.2.1" } + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..33dde9e7 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index b99b9653..1d50ee2f 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -1,5 +1,5 @@ -import { ComponentProps, useState } from "react" -import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext } from '@chakra-ui/react'; +import { ComponentProps, useState, Fragment } from "react" +import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton } from '@chakra-ui/react'; import { ErrorLike, PlayActivity } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; @@ -7,6 +7,9 @@ import { AiOutlineExclamationCircle } from "react-icons/ai"; import { ActivityTimeline } from "./ActivityTimeline"; import { ExpandCollapse } from "./ExpandCollapse"; import { PlayApiCommon, PlayApiCommonDetailed } from "../../core/Api"; +import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; +import ky from 'ky'; +import { baseUrl } from "../utils"; export interface ActivityDetailProps { activity: PlayApiCommonDetailed @@ -74,4 +77,31 @@ export const ActivityDetails = (props: ActivityDetailProps) => { {error !== undefined ? : null} ) +} + +export interface ActivityDetailFetchableProps { + uid: string + componentType: 'source' | 'client' +} + +export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { + const { isPending, isError, data, error } = useQuery({ + queryKey: ['plays', props.uid], + queryFn: queryFn + }); + + if(isPending) { + return + } + + if(isError) { + return + } + + return +} + +type PlayQueryKey = ['plays', string]; +const queryFn = async (context: QueryFunctionContext) => { + return await ky.get(`plays/${context.queryKey[1]}`, { baseUrl }).json() as PlayApiCommonDetailed; } \ No newline at end of file diff --git a/src/client/components/Provider.tsx b/src/client/components/Provider.tsx index b162c459..e3a5fc02 100644 --- a/src/client/components/Provider.tsx +++ b/src/client/components/Provider.tsx @@ -5,11 +5,22 @@ import { ColorModeProvider, type ColorModeProviderProps, } from "./Color-Mode" +import { + useQuery, + useMutation, + useQueryClient, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' + +const queryClient = new QueryClient() export function Provider(props: ColorModeProviderProps) { return ( - - - + + + + + ) } \ No newline at end of file diff --git a/src/client/components/playActivity/PlayList.tsx b/src/client/components/playActivity/PlayList.tsx index 4da4470c..99a460f6 100644 --- a/src/client/components/playActivity/PlayList.tsx +++ b/src/client/components/playActivity/PlayList.tsx @@ -9,7 +9,7 @@ import dayjs, { Dayjs } from 'dayjs'; import doy from 'dayjs/plugin/dayOfYear.js'; import { VscDebugRestart } from "react-icons/vsc"; import { GroupedVirtuoso } from 'react-virtuoso' -import { ActivityDetails } from '../ActivityDetail.js'; +import { ActivityDetailFetchable, ActivityDetails } from '../ActivityDetail.js'; import { sortByNewestPlayDate, sortByNewestSeenDate } from '../../../core/PlayUtils.js'; import "./PlayList.scss"; import { PlayApiCommon } from '../../../core/Api.js'; @@ -352,7 +352,7 @@ const PlainAccordian = (props: { data: PlayApiCommon[], sortBy: 'played' | 'seen - + diff --git a/src/client/utils/index.tsx b/src/client/utils/index.tsx index 06473dee..341a7ea6 100644 --- a/src/client/utils/index.tsx +++ b/src/client/utils/index.tsx @@ -76,3 +76,5 @@ export const isoToHuman = (iso?: string | null, opts?: DateFormatOptions) => { return parts.join(' '); } + +export const baseUrl = '/api/'; \ No newline at end of file diff --git a/src/stories/List.stories.tsx b/src/stories/List.stories.tsx index f64f9a85..7a0a15ba 100644 --- a/src/stories/List.stories.tsx +++ b/src/stories/List.stories.tsx @@ -1,5 +1,6 @@ import preview from "../../.storybook/preview.js"; import React from 'react'; +import { http, HttpResponse, delay } from 'msw'; import { fn } from 'storybook/test'; import { Container } from '@chakra-ui/react'; @@ -11,7 +12,8 @@ import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/test import { generateArray } from "../core/DataUtils.js"; import dayjs from "dayjs"; import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; -import { generatePlayApiCommon } from "../core/tests/utils/apiFixtures.js"; +import { generatePlayApiCommon, generatePlayApiCommonDetailed } from "../core/tests/utils/apiFixtures.js"; +import { PlayApiCommonDetailed } from "../core/Api.js"; const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; @@ -52,32 +54,59 @@ decorators: [ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args }); +let playData: PlayApiCommonDetailed[] = []; + // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const List = meta.story({ + parameters: { + msw: { + handlers: [ + http.get<{uid: string}>('/api/plays/:uid', async ({ params }) => { + const existing = playData.find(x => x.uid === params.uid); + if(existing !== undefined) { + return HttpResponse.json(existing); + } + return HttpResponse.json(generatePlayApiCommonDetailed()); + }), + ], + }, + }, loaders: [ async () => { - const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'queued'})); + const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => { + const jsonPlay = asJsonPlayObject(x); + return generatePlayApiCommonDetailed({playOpts: [{state: 'queued', play: jsonPlay}], inputOpts: [{play: jsonPlay}]}) + }); - const scrobbled = generatePlayApiCommon({ - play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))), - state: 'scrobbled' + const scrobbledPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); + const scrobbledApi = generatePlayApiCommonDetailed({ + playOpts: [{ play: scrobbledPlay, state: 'scrobbled'}], + inputOpts: [{play: scrobbledPlay}] }); - const scrobbleError = generatePlayApiCommon({ - play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})), - state: 'failed' + + const scrobbleErrorPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})); + + const scrobbleError = generatePlayApiCommonDetailed({ + playOpts: [{ play: scrobbleErrorPlay, state: 'failed'}], + inputOpts: [{play: scrobbleErrorPlay}] }); const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); const promised = await Promise.all(promisedScrobbled); - const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'scrobbled'})); - return {data: [ + const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => { + const jPlay = asJsonPlayObject(x); + return generatePlayApiCommonDetailed({ + playOpts: [{ play: jPlay, state: 'scrobbled'}], + inputOpts: [{play: jPlay}] + }); + }); + playData = [ ...queued, - scrobbled, + scrobbledApi, scrobbleError, - // {play: scrobbled, status: 'scrobbled'}, - // {play: scrobbleError, status: 'error'}, ...yesterdayScrobbled - ]}; + ]; + return {data: playData}; } ], //render: function Render(args) { return () }