[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

fea(ui): Implement tanstack-query for ActivityDetails and mock in storybook

FoxxMD (Jun 10, 2026, 8:44 PM UTC) 63c7ea78 a6a019e9

+515 -24
+1
.storybook/main.ts
··· 18 18 // https://github.com/storybookjs/storybook/discussions/33279 19 19 sidebarOnboardingChecklist: false, 20 20 }, 21 + staticDirs: ['../public'], 21 22 "framework": "@storybook/react-vite", 22 23 viteFinal: async (config) => { 23 24 return mergeConfig(config, {
+4
.storybook/preview.ts
··· 3 3 import { definePreview } from "@storybook/react-vite"; 4 4 5 5 import { themes } from 'storybook/theming'; 6 + import { initialize, mswLoader } from 'msw-storybook-addon'; 7 + 8 + initialize(); 6 9 7 10 export default definePreview({ 11 + loaders: [mswLoader], 8 12 parameters: { 9 13 controls: { 10 14 matchers: {
+58 -1
package-lock.json
··· 124 124 "@storybook/addon-docs": "^10.4.3", 125 125 "@storybook/react-vite": "^10.4.3", 126 126 "@tailwindcss/vite": "^4.2.2", 127 + "@tanstack/react-query": "^5.101.0", 127 128 "@tsconfig/node18": "^1.0.1", 128 129 "@types/chai": "^4.3.0", 129 130 "@types/chai-as-promised": "^7.1.5", ··· 159 160 "eslint-plugin-prefer-arrow-functions": "^3.2.4", 160 161 "eslint-plugin-storybook": "^10.3.6", 161 162 "git-cliff": "^2.12.0", 163 + "ky": "^2.0.2", 162 164 "mocha": "^10.3.0", 163 165 "mockdate": "^3.0.5", 164 - "msw": "^2.12.10", 166 + "msw": "^2.14.6", 167 + "msw-storybook-addon": "^2.0.7", 165 168 "next-themes": "^0.4.6", 166 169 "nodemon": "^3.0.3", 167 170 "playwright": "^1.58.2", ··· 5168 5171 }, 5169 5172 "peerDependencies": { 5170 5173 "vite": "^5.2.0 || ^6 || ^7 || ^8" 5174 + } 5175 + }, 5176 + "node_modules/@tanstack/query-core": { 5177 + "version": "5.101.0", 5178 + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", 5179 + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", 5180 + "dev": true, 5181 + "license": "MIT", 5182 + "funding": { 5183 + "type": "github", 5184 + "url": "https://github.com/sponsors/tannerlinsley" 5185 + } 5186 + }, 5187 + "node_modules/@tanstack/react-query": { 5188 + "version": "5.101.0", 5189 + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", 5190 + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", 5191 + "dev": true, 5192 + "license": "MIT", 5193 + "dependencies": { 5194 + "@tanstack/query-core": "5.101.0" 5195 + }, 5196 + "funding": { 5197 + "type": "github", 5198 + "url": "https://github.com/sponsors/tannerlinsley" 5199 + }, 5200 + "peerDependencies": { 5201 + "react": "^18 || ^19" 5171 5202 } 5172 5203 }, 5173 5204 "node_modules/@testing-library/dom": { ··· 12353 12384 "uuid": "dist/bin/uuid" 12354 12385 } 12355 12386 }, 12387 + "node_modules/ky": { 12388 + "version": "2.0.2", 12389 + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", 12390 + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", 12391 + "dev": true, 12392 + "license": "MIT", 12393 + "engines": { 12394 + "node": ">=22" 12395 + }, 12396 + "funding": { 12397 + "url": "https://github.com/sindresorhus/ky?sponsor=1" 12398 + } 12399 + }, 12356 12400 "node_modules/lastfm-ts-api": { 12357 12401 "version": "2.6.2", 12358 12402 "resolved": "https://registry.npmjs.org/lastfm-ts-api/-/lastfm-ts-api-2.6.2.tgz", ··· 13324 13368 "typescript": { 13325 13369 "optional": true 13326 13370 } 13371 + } 13372 + }, 13373 + "node_modules/msw-storybook-addon": { 13374 + "version": "2.0.7", 13375 + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz", 13376 + "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==", 13377 + "dev": true, 13378 + "license": "MIT", 13379 + "dependencies": { 13380 + "is-node-process": "^1.0.1" 13381 + }, 13382 + "peerDependencies": { 13383 + "msw": "^2.0.0" 13327 13384 } 13328 13385 }, 13329 13386 "node_modules/msw/node_modules/cliui": {
+10 -2
package.json
··· 163 163 "@storybook/addon-docs": "^10.4.3", 164 164 "@storybook/react-vite": "^10.4.3", 165 165 "@tailwindcss/vite": "^4.2.2", 166 + "@tanstack/react-query": "^5.101.0", 166 167 "@tsconfig/node18": "^1.0.1", 167 168 "@types/chai": "^4.3.0", 168 169 "@types/chai-as-promised": "^7.1.5", ··· 198 199 "eslint-plugin-prefer-arrow-functions": "^3.2.4", 199 200 "eslint-plugin-storybook": "^10.3.6", 200 201 "git-cliff": "^2.12.0", 202 + "ky": "^2.0.2", 201 203 "mocha": "^10.3.0", 202 204 "mockdate": "^3.0.5", 203 - "msw": "^2.12.10", 205 + "msw": "^2.14.6", 206 + "msw-storybook-addon": "^2.0.7", 204 207 "next-themes": "^0.4.6", 205 208 "nodemon": "^3.0.3", 206 209 "playwright": "^1.58.2", ··· 242 245 "musicbrainz-api": { 243 246 "rate-limit-threshold": "0.2.1" 244 247 } 248 + }, 249 + "msw": { 250 + "workerDirectory": [ 251 + "public" 252 + ] 245 253 } 246 - } 254 + }
+349
public/mockServiceWorker.js
··· 1 + /* eslint-disable */ 2 + /* tslint:disable */ 3 + 4 + /** 5 + * Mock Service Worker. 6 + * @see https://github.com/mswjs/msw 7 + * - Please do NOT modify this file. 8 + */ 9 + 10 + const PACKAGE_VERSION = '2.14.6' 11 + const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' 12 + const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 13 + const activeClientIds = new Set() 14 + 15 + addEventListener('install', function () { 16 + self.skipWaiting() 17 + }) 18 + 19 + addEventListener('activate', function (event) { 20 + event.waitUntil(self.clients.claim()) 21 + }) 22 + 23 + addEventListener('message', async function (event) { 24 + const clientId = Reflect.get(event.source || {}, 'id') 25 + 26 + if (!clientId || !self.clients) { 27 + return 28 + } 29 + 30 + const client = await self.clients.get(clientId) 31 + 32 + if (!client) { 33 + return 34 + } 35 + 36 + const allClients = await self.clients.matchAll({ 37 + type: 'window', 38 + }) 39 + 40 + switch (event.data) { 41 + case 'KEEPALIVE_REQUEST': { 42 + sendToClient(client, { 43 + type: 'KEEPALIVE_RESPONSE', 44 + }) 45 + break 46 + } 47 + 48 + case 'INTEGRITY_CHECK_REQUEST': { 49 + sendToClient(client, { 50 + type: 'INTEGRITY_CHECK_RESPONSE', 51 + payload: { 52 + packageVersion: PACKAGE_VERSION, 53 + checksum: INTEGRITY_CHECKSUM, 54 + }, 55 + }) 56 + break 57 + } 58 + 59 + case 'MOCK_ACTIVATE': { 60 + activeClientIds.add(clientId) 61 + 62 + sendToClient(client, { 63 + type: 'MOCKING_ENABLED', 64 + payload: { 65 + client: { 66 + id: client.id, 67 + frameType: client.frameType, 68 + }, 69 + }, 70 + }) 71 + break 72 + } 73 + 74 + case 'CLIENT_CLOSED': { 75 + activeClientIds.delete(clientId) 76 + 77 + const remainingClients = allClients.filter((client) => { 78 + return client.id !== clientId 79 + }) 80 + 81 + // Unregister itself when there are no more clients 82 + if (remainingClients.length === 0) { 83 + self.registration.unregister() 84 + } 85 + 86 + break 87 + } 88 + } 89 + }) 90 + 91 + addEventListener('fetch', function (event) { 92 + const requestInterceptedAt = Date.now() 93 + 94 + // Bypass navigation requests. 95 + if (event.request.mode === 'navigate') { 96 + return 97 + } 98 + 99 + // Opening the DevTools triggers the "only-if-cached" request 100 + // that cannot be handled by the worker. Bypass such requests. 101 + if ( 102 + event.request.cache === 'only-if-cached' && 103 + event.request.mode !== 'same-origin' 104 + ) { 105 + return 106 + } 107 + 108 + // Bypass all requests when there are no active clients. 109 + // Prevents the self-unregistered worked from handling requests 110 + // after it's been terminated (still remains active until the next reload). 111 + if (activeClientIds.size === 0) { 112 + return 113 + } 114 + 115 + const requestId = crypto.randomUUID() 116 + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) 117 + }) 118 + 119 + /** 120 + * @param {FetchEvent} event 121 + * @param {string} requestId 122 + * @param {number} requestInterceptedAt 123 + */ 124 + async function handleRequest(event, requestId, requestInterceptedAt) { 125 + const client = await resolveMainClient(event) 126 + const requestCloneForEvents = event.request.clone() 127 + const response = await getResponse( 128 + event, 129 + client, 130 + requestId, 131 + requestInterceptedAt, 132 + ) 133 + 134 + // Send back the response clone for the "response:*" life-cycle events. 135 + // Ensure MSW is active and ready to handle the message, otherwise 136 + // this message will pend indefinitely. 137 + if (client && activeClientIds.has(client.id)) { 138 + const serializedRequest = await serializeRequest(requestCloneForEvents) 139 + 140 + // Clone the response so both the client and the library could consume it. 141 + const responseClone = response.clone() 142 + 143 + sendToClient( 144 + client, 145 + { 146 + type: 'RESPONSE', 147 + payload: { 148 + isMockedResponse: IS_MOCKED_RESPONSE in response, 149 + request: { 150 + id: requestId, 151 + ...serializedRequest, 152 + }, 153 + response: { 154 + type: responseClone.type, 155 + status: responseClone.status, 156 + statusText: responseClone.statusText, 157 + headers: Object.fromEntries(responseClone.headers.entries()), 158 + body: responseClone.body, 159 + }, 160 + }, 161 + }, 162 + responseClone.body ? [serializedRequest.body, responseClone.body] : [], 163 + ) 164 + } 165 + 166 + return response 167 + } 168 + 169 + /** 170 + * Resolve the main client for the given event. 171 + * Client that issues a request doesn't necessarily equal the client 172 + * that registered the worker. It's with the latter the worker should 173 + * communicate with during the response resolving phase. 174 + * @param {FetchEvent} event 175 + * @returns {Promise<Client | undefined>} 176 + */ 177 + async function resolveMainClient(event) { 178 + const client = await self.clients.get(event.clientId) 179 + 180 + if (activeClientIds.has(event.clientId)) { 181 + return client 182 + } 183 + 184 + if (client?.frameType === 'top-level') { 185 + return client 186 + } 187 + 188 + const allClients = await self.clients.matchAll({ 189 + type: 'window', 190 + }) 191 + 192 + return allClients 193 + .filter((client) => { 194 + // Get only those clients that are currently visible. 195 + return client.visibilityState === 'visible' 196 + }) 197 + .find((client) => { 198 + // Find the client ID that's recorded in the 199 + // set of clients that have registered the worker. 200 + return activeClientIds.has(client.id) 201 + }) 202 + } 203 + 204 + /** 205 + * @param {FetchEvent} event 206 + * @param {Client | undefined} client 207 + * @param {string} requestId 208 + * @param {number} requestInterceptedAt 209 + * @returns {Promise<Response>} 210 + */ 211 + async function getResponse(event, client, requestId, requestInterceptedAt) { 212 + // Clone the request because it might've been already used 213 + // (i.e. its body has been read and sent to the client). 214 + const requestClone = event.request.clone() 215 + 216 + function passthrough() { 217 + // Cast the request headers to a new Headers instance 218 + // so the headers can be manipulated with. 219 + const headers = new Headers(requestClone.headers) 220 + 221 + // Remove the "accept" header value that marked this request as passthrough. 222 + // This prevents request alteration and also keeps it compliant with the 223 + // user-defined CORS policies. 224 + const acceptHeader = headers.get('accept') 225 + if (acceptHeader) { 226 + const values = acceptHeader.split(',').map((value) => value.trim()) 227 + const filteredValues = values.filter( 228 + (value) => value !== 'msw/passthrough', 229 + ) 230 + 231 + if (filteredValues.length > 0) { 232 + headers.set('accept', filteredValues.join(', ')) 233 + } else { 234 + headers.delete('accept') 235 + } 236 + } 237 + 238 + return fetch(requestClone, { headers }) 239 + } 240 + 241 + // Bypass mocking when the client is not active. 242 + if (!client) { 243 + return passthrough() 244 + } 245 + 246 + // Bypass initial page load requests (i.e. static assets). 247 + // The absence of the immediate/parent client in the map of the active clients 248 + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 249 + // and is not ready to handle requests. 250 + if (!activeClientIds.has(client.id)) { 251 + return passthrough() 252 + } 253 + 254 + // Notify the client that a request has been intercepted. 255 + const serializedRequest = await serializeRequest(event.request) 256 + const clientMessage = await sendToClient( 257 + client, 258 + { 259 + type: 'REQUEST', 260 + payload: { 261 + id: requestId, 262 + interceptedAt: requestInterceptedAt, 263 + ...serializedRequest, 264 + }, 265 + }, 266 + [serializedRequest.body], 267 + ) 268 + 269 + switch (clientMessage.type) { 270 + case 'MOCK_RESPONSE': { 271 + return respondWithMock(clientMessage.data) 272 + } 273 + 274 + case 'PASSTHROUGH': { 275 + return passthrough() 276 + } 277 + } 278 + 279 + return passthrough() 280 + } 281 + 282 + /** 283 + * @param {Client} client 284 + * @param {any} message 285 + * @param {Array<Transferable>} transferrables 286 + * @returns {Promise<any>} 287 + */ 288 + function sendToClient(client, message, transferrables = []) { 289 + return new Promise((resolve, reject) => { 290 + const channel = new MessageChannel() 291 + 292 + channel.port1.onmessage = (event) => { 293 + if (event.data && event.data.error) { 294 + return reject(event.data.error) 295 + } 296 + 297 + resolve(event.data) 298 + } 299 + 300 + client.postMessage(message, [ 301 + channel.port2, 302 + ...transferrables.filter(Boolean), 303 + ]) 304 + }) 305 + } 306 + 307 + /** 308 + * @param {Response} response 309 + * @returns {Response} 310 + */ 311 + function respondWithMock(response) { 312 + // Setting response status code to 0 is a no-op. 313 + // However, when responding with a "Response.error()", the produced Response 314 + // instance will have status code set to 0. Since it's not possible to create 315 + // a Response instance with status code 0, handle that use-case separately. 316 + if (response.status === 0) { 317 + return Response.error() 318 + } 319 + 320 + const mockedResponse = new Response(response.body, response) 321 + 322 + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 323 + value: true, 324 + enumerable: true, 325 + }) 326 + 327 + return mockedResponse 328 + } 329 + 330 + /** 331 + * @param {Request} request 332 + */ 333 + async function serializeRequest(request) { 334 + return { 335 + url: request.url, 336 + mode: request.mode, 337 + method: request.method, 338 + headers: Object.fromEntries(request.headers.entries()), 339 + cache: request.cache, 340 + credentials: request.credentials, 341 + destination: request.destination, 342 + integrity: request.integrity, 343 + redirect: request.redirect, 344 + referrer: request.referrer, 345 + referrerPolicy: request.referrerPolicy, 346 + body: await request.arrayBuffer(), 347 + keepalive: request.keepalive, 348 + } 349 + }
+32 -2
src/client/components/ActivityDetail.tsx
··· 1 - import { ComponentProps, useState } from "react" 2 - import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext } from '@chakra-ui/react'; 1 + import { ComponentProps, useState, Fragment } from "react" 2 + import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton } from '@chakra-ui/react'; 3 3 import { ErrorLike, PlayActivity } from "../../core/Atomic"; 4 4 import { PlayData } from "./PlayData"; 5 5 import { ErrorAlert } from "./ErrorAlert"; ··· 7 7 import { ActivityTimeline } from "./ActivityTimeline"; 8 8 import { ExpandCollapse } from "./ExpandCollapse"; 9 9 import { PlayApiCommon, PlayApiCommonDetailed } from "../../core/Api"; 10 + import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; 11 + import ky from 'ky'; 12 + import { baseUrl } from "../utils"; 10 13 11 14 export interface ActivityDetailProps { 12 15 activity: PlayApiCommonDetailed ··· 74 77 {error !== undefined ? <ErrorAlert error={error} /> : null} 75 78 </Box> 76 79 ) 80 + } 81 + 82 + export interface ActivityDetailFetchableProps { 83 + uid: string 84 + componentType: 'source' | 'client' 85 + } 86 + 87 + export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { 88 + const { isPending, isError, data, error } = useQuery({ 89 + queryKey: ['plays', props.uid], 90 + queryFn: queryFn 91 + }); 92 + 93 + if(isPending) { 94 + return <Fragment><Skeleton height="100px"/><Skeleton height="100px"/></Fragment> 95 + } 96 + 97 + if(isError) { 98 + return <ErrorAlert error={error}/> 99 + } 100 + 101 + return <ActivityDetails componentType={props.componentType} activity={data}/> 102 + } 103 + 104 + type PlayQueryKey = ['plays', string]; 105 + const queryFn = async (context: QueryFunctionContext<PlayQueryKey>) => { 106 + return await ky.get(`plays/${context.queryKey[1]}`, { baseUrl }).json() as PlayApiCommonDetailed; 77 107 }
+14 -3
src/client/components/Provider.tsx
··· 5 5 ColorModeProvider, 6 6 type ColorModeProviderProps, 7 7 } from "./Color-Mode" 8 + import { 9 + useQuery, 10 + useMutation, 11 + useQueryClient, 12 + QueryClient, 13 + QueryClientProvider, 14 + } from '@tanstack/react-query' 15 + 16 + const queryClient = new QueryClient() 8 17 9 18 export function Provider(props: ColorModeProviderProps) { 10 19 return ( 11 - <ChakraProvider value={defaultSystem}> 12 - <ColorModeProvider {...props} /> 13 - </ChakraProvider> 20 + <QueryClientProvider client={queryClient}> 21 + <ChakraProvider value={defaultSystem}> 22 + <ColorModeProvider {...props} /> 23 + </ChakraProvider> 24 + </QueryClientProvider> 14 25 ) 15 26 }
+2 -2
src/client/components/playActivity/PlayList.tsx
··· 9 9 import doy from 'dayjs/plugin/dayOfYear.js'; 10 10 import { VscDebugRestart } from "react-icons/vsc"; 11 11 import { GroupedVirtuoso } from 'react-virtuoso' 12 - import { ActivityDetails } from '../ActivityDetail.js'; 12 + import { ActivityDetailFetchable, ActivityDetails } from '../ActivityDetail.js'; 13 13 import { sortByNewestPlayDate, sortByNewestSeenDate } from '../../../core/PlayUtils.js'; 14 14 import "./PlayList.scss"; 15 15 import { PlayApiCommon } from '../../../core/Api.js'; ··· 352 352 </Flex> 353 353 <Accordion.ItemContent> 354 354 <Accordion.ItemBody borderTopColor="gray.border" > 355 - <ActivityDetails activity={activity} /> 355 + <ActivityDetailFetchable componentType='source' uid={activity.uid} /> 356 356 </Accordion.ItemBody> 357 357 </Accordion.ItemContent> 358 358 </Accordion.Item>
+2
src/client/utils/index.tsx
··· 76 76 77 77 return parts.join(' '); 78 78 } 79 + 80 + export const baseUrl = '/api/';
+43 -14
src/stories/List.stories.tsx
··· 1 1 import preview from "../../.storybook/preview.js"; 2 2 import React from 'react'; 3 + import { http, HttpResponse, delay } from 'msw'; 3 4 4 5 import { fn } from 'storybook/test'; 5 6 import { Container } from '@chakra-ui/react'; ··· 11 12 import { generateArray } from "../core/DataUtils.js"; 12 13 import dayjs from "dayjs"; 13 14 import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; 14 - import { generatePlayApiCommon } from "../core/tests/utils/apiFixtures.js"; 15 + import { generatePlayApiCommon, generatePlayApiCommonDetailed } from "../core/tests/utils/apiFixtures.js"; 16 + import { PlayApiCommonDetailed } from "../core/Api.js"; 15 17 16 18 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)"; 17 19 ··· 52 54 // 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 53 55 }); 54 56 57 + let playData: PlayApiCommonDetailed[] = []; 58 + 55 59 // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 56 60 export const List = meta.story({ 61 + parameters: { 62 + msw: { 63 + handlers: [ 64 + http.get<{uid: string}>('/api/plays/:uid', async ({ params }) => { 65 + const existing = playData.find(x => x.uid === params.uid); 66 + if(existing !== undefined) { 67 + return HttpResponse.json(existing); 68 + } 69 + return HttpResponse.json(generatePlayApiCommonDetailed()); 70 + }), 71 + ], 72 + }, 73 + }, 57 74 loaders: [ 58 75 async () => { 59 - const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'queued'})); 76 + const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => { 77 + const jsonPlay = asJsonPlayObject(x); 78 + return generatePlayApiCommonDetailed({playOpts: [{state: 'queued', play: jsonPlay}], inputOpts: [{play: jsonPlay}]}) 79 + }); 60 80 61 - const scrobbled = generatePlayApiCommon({ 62 - play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))), 63 - state: 'scrobbled' 81 + const scrobbledPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); 82 + const scrobbledApi = generatePlayApiCommonDetailed({ 83 + playOpts: [{ play: scrobbledPlay, state: 'scrobbled'}], 84 + inputOpts: [{play: scrobbledPlay}] 64 85 }); 65 - const scrobbleError = generatePlayApiCommon({ 66 - play: asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})), 67 - state: 'failed' 86 + 87 + const scrobbleErrorPlay = asJsonPlayObject(await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true})); 88 + 89 + const scrobbleError = generatePlayApiCommonDetailed({ 90 + playOpts: [{ play: scrobbleErrorPlay, state: 'failed'}], 91 + inputOpts: [{play: scrobbleErrorPlay}] 68 92 }); 69 93 70 94 const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}))); 71 95 const promised = await Promise.all(promisedScrobbled); 72 - const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => generatePlayApiCommon({play: asJsonPlayObject(x), state: 'scrobbled'})); 73 - return {data: [ 96 + const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => { 97 + const jPlay = asJsonPlayObject(x); 98 + return generatePlayApiCommonDetailed({ 99 + playOpts: [{ play: jPlay, state: 'scrobbled'}], 100 + inputOpts: [{play: jPlay}] 101 + }); 102 + }); 103 + playData = [ 74 104 ...queued, 75 - scrobbled, 105 + scrobbledApi, 76 106 scrobbleError, 77 - // {play: scrobbled, status: 'scrobbled'}, 78 - // {play: scrobbleError, status: 'error'}, 79 107 ...yesterdayScrobbled 80 - ]}; 108 + ]; 109 + return {data: playData}; 81 110 } 82 111 ], 83 112 //render: function Render(args) { return (<ChakraProvider><MyList></MyList></ChakraProvider>) }