mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-07-09 17:28:28 +00:00
fea(ui): Implement tanstack-query for ActivityDetails and mock in storybook
This commit is contained in:
parent
a6a019e9fd
commit
63c7ea7809
10 changed files with 515 additions and 24 deletions
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
package.json
12
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
349
public/mockServiceWorker.js
Normal file
349
public/mockServiceWorker.js
Normal file
|
|
@ -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<Client | undefined>}
|
||||
*/
|
||||
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<Response>}
|
||||
*/
|
||||
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<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ? <ErrorAlert error={error} /> : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 <Fragment><Skeleton height="100px"/><Skeleton height="100px"/></Fragment>
|
||||
}
|
||||
|
||||
if(isError) {
|
||||
return <ErrorAlert error={error}/>
|
||||
}
|
||||
|
||||
return <ActivityDetails componentType={props.componentType} activity={data}/>
|
||||
}
|
||||
|
||||
type PlayQueryKey = ['plays', string];
|
||||
const queryFn = async (context: QueryFunctionContext<PlayQueryKey>) => {
|
||||
return await ky.get(`plays/${context.queryKey[1]}`, { baseUrl }).json() as PlayApiCommonDetailed;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|||
</Flex>
|
||||
<Accordion.ItemContent>
|
||||
<Accordion.ItemBody borderTopColor="gray.border" >
|
||||
<ActivityDetails activity={activity} />
|
||||
<ActivityDetailFetchable componentType='source' uid={activity.uid} />
|
||||
</Accordion.ItemBody>
|
||||
</Accordion.ItemContent>
|
||||
</Accordion.Item>
|
||||
|
|
|
|||
|
|
@ -76,3 +76,5 @@ export const isoToHuman = (iso?: string | null, opts?: DateFormatOptions) => {
|
|||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export const baseUrl = '/api/';
|
||||
|
|
@ -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 (<ChakraProvider><MyList></MyList></ChakraProvider>) }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue