feat(ui): Implement plays refresh

This commit is contained in:
FoxxMD 2026-07-02 15:55:37 +00:00
parent fb452eb40b
commit c4b1d72f3f
4 changed files with 106 additions and 27 deletions

View file

@ -18,20 +18,27 @@ import {
LuPowerOff,
LuEye,
LuEyeClosed,
LuCalendar
LuCalendar,
LuRefreshCw
} from "react-icons/lu"
import { VscDebugRestart } from 'react-icons/vsc';
import { RiZzzFill } from "react-icons/ri";
import { SiGoogledocs } from "react-icons/si";
import { IconButton, Clipboard, useClipboard } from "@chakra-ui/react"
import { IconButton, Clipboard, useClipboard, Spinner } from "@chakra-ui/react"
import { ComponentProps, PropsWithChildren } from 'react';
import { IconBaseProps, IconType } from "react-icons/lib";
export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren<ComponentProps<typeof IconButton>> & { iconProps?: IconBaseProps }) => {
const { iconProps = {}, children, ...rest } = props;
export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren<ComponentProps<typeof IconButton>> & { iconProps?: IconBaseProps, loading?: boolean }) => {
const {
iconProps = {},
children,
loading = false,
size = 'xs',
...rest
} = props;
return (
<IconButton variant="surface" size="xs" {...rest}>
<Icon {...iconProps} />{children}
<IconButton variant="surface" disabled={loading} size={size} {...rest}>
{loading ? <Spinner/> : <Icon {...iconProps} />}{children}
</IconButton>
);
}
@ -142,4 +149,7 @@ export const EyeClosedIcon = LuEyeClosed;
export const EyeClosedButton = makeIconButton(LuEyeClosed);
export const CalendarIcon = LuCalendar;
export const CalendarButton = makeIconButton(CalendarIcon);
export const CalendarButton = makeIconButton(CalendarIcon);
export const RefreshIcon = LuRefreshCw;
export const RefreshButton = makeIconButton(RefreshIcon);

View file

@ -8,7 +8,7 @@ import "./PlayList.scss";
import { MsSseEvent, MsSseEventPayload, PlayApiCommon, PlayApiCommonDetailed } from '../../../core/Api.js';
import { useQuery, useInfiniteQuery, UseInfiniteQueryResult, InfiniteData, useQueryClient } from '@tanstack/react-query';
import { ErrorAlert } from '../ErrorAlert.js';
import { tanQueries } from '../../queries/index.js';
import { QueryPlaysOptsJsonRefreshable, tanQueries } from '../../queries/index.js';
import { VirtualizedListNormal } from './VirtualListNormal.js';
import { NoPlayResults, VirtualizedListDynamic } from './VirtualListDynamic.js';
import { VirtualizedListExp } from './VirtualListExperimental.js';
@ -67,7 +67,7 @@ const PlainAccordian = (props: ActivityLogProps) => {
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody borderTopColor="gray.border" >
{live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} query={props.query} uid={activity.uid} /> : <ActivityDetails activity={activity as PlayApiCommonDetailed} {...props} />}
{live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} uid={activity.uid} /> : <ActivityDetails activity={activity as PlayApiCommonDetailed} {...props} />}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
@ -86,12 +86,12 @@ export const ListContainer = (props?: ComponentProps<typeof ActivityList>) => {
return <Container maxWidth="3xl"><ActivityList {...props} /></Container>
}
export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJson } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => {
export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJsonRefreshable } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => {
const {
componentId,
filters = {}
} = props;
const query: QueryPlaysOptsJson = { ...filters, order: 'desc', sort: 'playedAt' };
const query: QueryPlaysOptsJsonRefreshable = filters; // { ...filters, order: 'desc', sort: 'playedAt' };
const {
isPending,
isError,
@ -133,8 +133,8 @@ export const ListContainerFetchable = (props: { componentId: number, componentTy
});
let rendered;
if (status === 'pending' && data === undefined) {
rendered = <Stack><ActivitySummarySkeleton /><ActivitySummarySkeleton /><ActivitySummarySkeleton /></Stack>;
if (isPending) {
rendered = <Stack width="100%"><ActivitySummarySkeleton /><ActivitySummarySkeleton /><ActivitySummarySkeleton /></Stack>;
} else if (isError || status === 'error') {
rendered = <ErrorAlert error={error} />
} else if(!isFetching && allPlays.length === 0) {
@ -263,16 +263,18 @@ const playInWindow = (data: PlayApiCommonDetailed, query: QueryPlaysOptsJson): b
}
export const ListContainerFilterable = (props: { componentId: number, componentType: ComponentType } & Pick<ComponentProps<typeof ActivityList>, 'render'>) => {
const [filters, setFilter] = useState<QueryPlaysOptsJson>({
const [filters, setFilter] = useState<QueryPlaysOptsJsonRefreshable>({
playedAt: {
type: 'between',
range: todayRange,
inclusive: true
}
},
order: 'desc',
sort: 'playedAt'
});
return (
<Stack width="100%" gap="4">
<ListFilters componentType={props.componentType} filters={filters} onChange={setFilter}/>
<ListFilters componentType={props.componentType} componentId={props.componentId} filters={filters} onChange={setFilter}/>
<ListContainerFetchable {...props} filters={filters} />
</Stack>
)

View file

@ -1,4 +1,4 @@
import { Span, Stack, Text, Box, HStack, Wrap, Flex, Container, Select, Portal, createListCollection, useSelectContext, DatePicker, VStack, Button, Spacer, TagsInput, Card } from '@chakra-ui/react';
import { Span, Stack, Text, Box, Spinner, HStack, Wrap, Flex, Container, Select, Portal, createListCollection, useSelectContext, DatePicker, VStack, Button, Spacer, TagsInput, Card } from '@chakra-ui/react';
import { ComponentType, isComponentTypeSource, PLAY_CLIENT_STATE, PLAY_SOURCE_STATE, PlayState } from '../../../core/Atomic.js';
import React, { ComponentProps, Fragment, useMemo, useCallback, useState } from "react"
import dayjs, { Dayjs } from 'dayjs';
@ -24,7 +24,10 @@ import {
import { QueryPlaysOptsJson } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js';
import { cardHeaderSeparator } from '../../utils/ComponentUtils.js';
import { CompareDateBetween } from '../../../backend/common/database/drizzle/repositories/BaseRepository.js';
import { CalendarButton } from '../icons/ChakraIcons.js';
import { CalendarButton, RefreshButton } from '../icons/ChakraIcons.js';
import { nanoid } from 'nanoid';
import { QueryPlaysOptsJsonRefreshable, tanQueries, useQueryWatcher } from '../../queries/index.js';
import { useQueryClient } from '@tanstack/react-query';
const noop = (_) => null;
@ -256,15 +259,20 @@ export const PhraseFilter = (props: PhraseFilterProps) => {
}
export const ListFilters = (props: {
onChange: (e: QueryPlaysOptsJson) => void
filters: QueryPlaysOptsJson
onChange: (e: QueryPlaysOptsJsonRefreshable) => void,
loading?: boolean
filters: QueryPlaysOptsJsonRefreshable
componentType: ComponentType,
componentId: number
}) => {
const {
filters,
onChange,
componentId,
} = props;
const queryClient = useQueryClient();
const setState = useCallback((val: PlayState[]) => {
const {
state,
@ -299,10 +307,24 @@ export const ListFilters = (props: {
onChange({...rest, text: val});
}, [onChange, filters]);
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({
queryKey: tanQueries.activities.list(componentId, filters).queryKey
});
// const nonce = nanoid();
// const {
// ...rest
// } = filters;
// console.log(`Adding nonce for refresh ${nonce}`);
// onChange({...rest, nonce});
}, [componentId, filters]);
const { isFetching } = useQueryWatcher(tanQueries.activities.list(componentId, filters).queryKey)
return (
<Card.Root size="sm" variant="outline">
<Card.Header {...cardHeaderSeparator}>
Filters
<HStack>Filters <RefreshButton variant="ghost" size="sm" loading={isFetching} onClick={(e) => onRefresh()}/></HStack>
</Card.Header>
<Card.Body px="3" py="4">
<Wrap gap="5">

View file

@ -1,4 +1,6 @@
import { createQueryKeys, mergeQueryKeys } from "@lukemorales/query-key-factory";
import { useQueryClient, hashKey, QueryObserver } from '@tanstack/react-query'
import { useEffect, useState, useMemo } from 'react';
import ky from 'ky';
import { QueryPlaysOpts, QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository";
import qs from 'qs';
@ -7,6 +9,8 @@ import { PaginatedResponse } from "../../backend/common/database/drizzle/reposit
import { ComponentsApiJson, PlayApiCommonDetailed } from "../../core/Api";
import { SourcePlayerJson } from "../../core/Atomic";
export type QueryPlaysOptsJsonRefreshable = QueryPlaysOptsJson & {nonce?: string};
const components = createQueryKeys('components', {
list: () => ({
queryKey: ['components'],
@ -23,14 +27,18 @@ const components = createQueryKeys('components', {
})
const activities = createQueryKeys('activities', {
list: (componentId: number, filters: QueryPlaysOptsJson) => ({
list: (componentId: number, filters: QueryPlaysOptsJsonRefreshable) => ({
queryKey: ['components', componentId, 'plays', filters],
queryFn: (ctx) => {
const {
nonce,
...rest
} = filters;
return ky.get(`components/${componentId}/plays`, {
baseUrl: baseUrl,
searchParams: qs.stringify({...filters, offset: ctx.pageParam})
}).json<PaginatedResponse<PlayApiCommonDetailed>>()
}
baseUrl: baseUrl,
searchParams: qs.stringify({...rest, offset: ctx.pageParam})
}).json<PaginatedResponse<PlayApiCommonDetailed>>()
}
}),
single: (componentId: number, activityUid: string) => ({
queryKey: ['components', componentId, 'play', activityUid],
@ -53,4 +61,41 @@ const players = createQueryKeys('players', {
})
})
export const tanQueries = mergeQueryKeys(components, activities, players);
export const tanQueries = mergeQueryKeys(components, activities, players);
export const useQueryState = (queryKey: Readonly<unknown[]>) => {
const queryClient = useQueryClient()
const [state, setState] = useState(() => queryClient.getQueryState(queryKey))
useEffect(() => {
const targetHash = hashKey(queryKey)
return queryClient.getQueryCache().subscribe((event) => {
if (event.query.queryHash === targetHash) {
setState(event.query.state)
}
})
}, [queryClient, queryKey])
return state // { status, data, error, fetchStatus, ... }
}
export const useQueryWatcher = <T>(queryKey: Readonly<unknown[]>) => {
const queryClient = useQueryClient()
const observer = useMemo(
() =>
new QueryObserver<T>(queryClient, {
queryKey,
enabled: false, // never triggers its own fetch
}),
[queryClient, queryKey]
)
const [result, setResult] = useState(() => observer.getCurrentResult())
useEffect(() => {
return observer.subscribe(setResult)
}, [observer])
return result // { status, data, error, isPending, isSuccess, ... }
}