diff --git a/src/Router.tsx b/src/Router.tsx index ac4ec30a..123a73ee 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -84,6 +84,9 @@ const EventBreakdown = lazy( const EventFunnels = lazy( () => import(/* webpackChunkName: 'event-funnels' */ './pages/EventFunnels'), ) +const EventCatalogue = lazy( + () => import(/* webpackChunkName: 'event-catalogue' */ './pages/EventCatalogue'), +) const EventFunnelNew = lazy( () => import(/* webpackChunkName: 'event-funnel-new' */ './pages/EventFunnelNew'), ) @@ -193,6 +196,7 @@ function Router({ intendedRoute }: RouterProps) { )} } /> } /> + } /> } /> } /> } /> diff --git a/src/api/deleteEventRetention.ts b/src/api/deleteEventRetention.ts new file mode 100644 index 00000000..fb52d89b --- /dev/null +++ b/src/api/deleteEventRetention.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' +import api from './api' +import makeValidatedRequest from './makeValidatedRequest' + +export const deleteEventRetention = makeValidatedRequest( + (gameId: number, eventName: string) => + api.delete(`/games/${gameId}/events/retention`, { params: { eventName } }), + z.literal(''), +) diff --git a/src/api/purgeEvents.ts b/src/api/purgeEvents.ts new file mode 100644 index 00000000..88de14c7 --- /dev/null +++ b/src/api/purgeEvents.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import api from './api' +import makeValidatedRequest from './makeValidatedRequest' + +export const purgeEvents = makeValidatedRequest( + (gameId: number, eventName: string) => + api.delete(`/games/${gameId}/events/purge`, { params: { eventName } }), + z.object({ + purged: z.number(), + }), +) diff --git a/src/api/upsertEventRetention.ts b/src/api/upsertEventRetention.ts new file mode 100644 index 00000000..d732afbb --- /dev/null +++ b/src/api/upsertEventRetention.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { eventRetentionSchema } from '../entities/eventCatalogue' +import api from './api' +import makeValidatedRequest from './makeValidatedRequest' + +export const upsertEventRetention = makeValidatedRequest( + (gameId: number, eventName: string, retentionDays: number) => + api.put(`/games/${gameId}/events/retention`, { eventName, retentionDays }), + z.object({ + retention: eventRetentionSchema, + }), +) diff --git a/src/api/useEventCatalogue.ts b/src/api/useEventCatalogue.ts new file mode 100644 index 00000000..12b7a500 --- /dev/null +++ b/src/api/useEventCatalogue.ts @@ -0,0 +1,22 @@ +import useSWR from 'swr' +import { eventCatalogueSchema } from '../entities/eventCatalogue' +import { Game } from '../entities/game' +import buildError from '../utils/buildError' +import makeValidatedGetRequest from './makeValidatedGetRequest' + +export default function useEventCatalogue(activeGame: Game, page: number) { + const fetcher = async ([url, page]: [string, number]) => { + return makeValidatedGetRequest(`${url}?page=${page}`, eventCatalogueSchema) + } + + const { data, error, mutate } = useSWR([`games/${activeGame.id}/events/catalogue`, page], fetcher) + + return { + events: data?.events ?? [], + count: data?.count, + itemsPerPage: data?.itemsPerPage, + loading: !data && !error, + error: error && buildError(error), + mutate, + } +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 6d986290..0718b8a7 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -11,6 +11,7 @@ export default { dataExports: '/exports', eventsOverview: '/events', eventBreakdown: '/events/breakdown', + eventsCatalogue: '/events/catalogue', eventsFunnels: '/events/funnels', eventsFunnelNew: '/events/funnels/new', eventFunnel: '/events/funnels/:funnelId', diff --git a/src/constants/secondaryNavRoutes.ts b/src/constants/secondaryNavRoutes.ts index 49769d8e..144cfb04 100644 --- a/src/constants/secondaryNavRoutes.ts +++ b/src/constants/secondaryNavRoutes.ts @@ -7,3 +7,9 @@ export const secondaryNavRoutes = [ { title: 'Organisation', to: routes.organisation }, { title: 'Billing', to: routes.billing }, ] + +export const eventsSecondaryNavRoutes = [ + { title: 'Events overview', to: routes.eventsOverview }, + { title: 'Event funnels', to: routes.eventsFunnels }, + { title: 'Event catalogue', to: routes.eventsCatalogue }, +] diff --git a/src/entities/eventCatalogue.ts b/src/entities/eventCatalogue.ts new file mode 100644 index 00000000..68f34f88 --- /dev/null +++ b/src/entities/eventCatalogue.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +export const catalogueEventSchema = z.object({ + name: z.string(), + count: z.number(), + players: z.number(), + propKeys: z.array(z.string()), + retentionDays: z.number().nullable(), +}) + +export type CatalogueEvent = z.infer + +export const eventCatalogueSchema = z.object({ + events: z.array(catalogueEventSchema), + count: z.number(), + itemsPerPage: z.number(), + isLastPage: z.boolean(), +}) + +export const eventRetentionSchema = z.object({ + eventName: z.string(), + retentionDays: z.number(), + updatedAt: z.string(), +}) diff --git a/src/pages/EventCatalogue.tsx b/src/pages/EventCatalogue.tsx new file mode 100644 index 00000000..7b28c826 --- /dev/null +++ b/src/pages/EventCatalogue.tsx @@ -0,0 +1,233 @@ +import { IconCheck, IconPencil, IconX } from '@tabler/icons-react' +import { useAtomValue } from 'jotai' +import { useContext, useState } from 'react' +import { deleteEventRetention } from '../api/deleteEventRetention' +import { purgeEvents } from '../api/purgeEvents' +import { upsertEventRetention } from '../api/upsertEventRetention' +import useEventCatalogue from '../api/useEventCatalogue' +import Button from '../components/Button' +import ErrorMessage, { TaloError } from '../components/ErrorMessage' +import Page from '../components/Page' +import Pagination from '../components/Pagination' +import { SecondaryNav } from '../components/SecondaryNav' +import Table from '../components/tables/Table' +import TableBody from '../components/tables/TableBody' +import TableCell from '../components/tables/TableCell' +import TextInput from '../components/TextInput' +import ToastContext, { ToastType } from '../components/toast/ToastContext' +import { metaPropKeyMap } from '../constants/metaProps' +import { eventsSecondaryNavRoutes } from '../constants/secondaryNavRoutes' +import { CatalogueEvent } from '../entities/eventCatalogue' +import { activeGameState, SelectedActiveGame } from '../state/activeGameState' +import { AuthedUser, userState } from '../state/userState' +import buildError from '../utils/buildError' +import canPerformAction, { PermissionBasedAction } from '../utils/canPerformAction' + +const MIN_RETENTION_DAYS = 1 + +function EventCatalogue() { + const activeGame = useAtomValue(activeGameState) as SelectedActiveGame + const [page, setPage] = useState(0) + const { events, count, itemsPerPage, loading, error, mutate } = useEventCatalogue( + activeGame, + page, + ) + const toast = useContext(ToastContext) + const user = useAtomValue(userState) as AuthedUser + const canPurge = canPerformAction(user, PermissionBasedAction.PURGE_EVENTS) + const canChangeRetention = canPerformAction(user, PermissionBasedAction.CHANGE_EVENT_RETENTION) + + const [editingEventName, setEditingEventName] = useState(null) + const [retentionDaysInput, setRetentionDaysInput] = useState('') + const [editingError, setEditingError] = useState(null) + + const retentionDays = Number(retentionDaysInput) + const isValidRetention = Number.isInteger(retentionDays) && retentionDays >= MIN_RETENTION_DAYS + + const onStartEdit = (event: CatalogueEvent) => { + setEditingEventName(event.name) + setRetentionDaysInput(event.retentionDays?.toString() ?? '') + setEditingError(null) + } + + const onSaveRetention = async () => { + if (!isValidRetention) { + return + } + + try { + await upsertEventRetention(activeGame.id, editingEventName!, retentionDays) + await mutate() + toast.trigger('Retention updated', ToastType.SUCCESS) + setEditingEventName(null) + } catch (err) { + setEditingError(buildError(err)) + } + } + + const onClearRetention = async (event: CatalogueEvent) => { + try { + await deleteEventRetention(activeGame.id, event.name) + await mutate() + toast.trigger('Retention cleared', ToastType.SUCCESS) + } catch (err) { + setEditingError(buildError(err)) + } + } + + const onPurge = async (event: CatalogueEvent) => { + if ( + !window.confirm( + `Are you sure you want to purge all '${event.name}' events? This action cannot be undone.`, + ) + ) { + return + } + + try { + const { purged } = await purgeEvents(activeGame.id, event.name) + await mutate() + setPage(0) + toast.trigger(`Purged ${purged.toLocaleString()} events`, ToastType.SUCCESS) + } catch { + toast.trigger('Something went wrong while purging events', ToastType.ERROR) + } + } + + const onRetentionInputKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + await onSaveRetention() + } else if (e.key === 'Escape') { + setEditingEventName(null) + } + } + + return ( + } + > + {error && } + {editingError && } + + {events.length === 0 && !loading && !error && No events found} + + {events.length > 0 && ( + <> + + + {(event) => { + const propKeys = event.propKeys.filter((key) => !(key in metaPropKeyMap)) + + return ( + <> + {event.name} + {event.count.toLocaleString()} + {event.players.toLocaleString()} + + + {propKeys.map((propKey) => ( + + {propKey} + + ))} + {propKeys.length === 0 && '-'} + + + + + {editingEventName === event.name && ( + <> + + } + extra={{ 'aria-label': 'Save retention' }} + /> + setEditingEventName(null)} + icon={} + extra={{ 'aria-label': 'Cancel editing retention' }} + /> + > + )} + {editingEventName !== event.name && ( + <> + + {event.retentionDays ? `${event.retentionDays} days` : 'None'} + + {canChangeRetention && ( + <> + onStartEdit(event)} + icon={} + extra={{ 'aria-label': 'Edit retention' }} + /> + {event.retentionDays && ( + onClearRetention(event)} + icon={} + extra={{ 'aria-label': 'Clear retention' }} + /> + )} + > + )} + > + )} + + + {canPurge && ( + + onPurge(event)}> + Purge + + + )} + > + ) + }} + + + + + > + )} + + ) +} + +export default EventCatalogue diff --git a/src/pages/EventFunnelNew.tsx b/src/pages/EventFunnelNew.tsx index 8f918cb9..6924c02b 100644 --- a/src/pages/EventFunnelNew.tsx +++ b/src/pages/EventFunnelNew.tsx @@ -6,6 +6,7 @@ import { EventsProvider } from '../components/events/EventsContext' import Page from '../components/Page' import { SecondaryNav } from '../components/SecondaryNav' import routes from '../constants/routes' +import { eventsSecondaryNavRoutes } from '../constants/secondaryNavRoutes' import { activeGameState, SelectedActiveGame } from '../state/activeGameState' const localStorageKey = 'eventFunnelNew' @@ -15,14 +16,7 @@ export default function EventFunnelNew() { const navigate = useNavigate() const { mutate } = useSWRConfig() - const secondaryNav = ( - - ) + const secondaryNav = return ( diff --git a/src/pages/EventFunnels.tsx b/src/pages/EventFunnels.tsx index cc709c55..5fa705d9 100644 --- a/src/pages/EventFunnels.tsx +++ b/src/pages/EventFunnels.tsx @@ -14,6 +14,7 @@ import Table from '../components/tables/Table' import TableBody from '../components/tables/TableBody' import TableCell from '../components/tables/TableCell' import routes from '../constants/routes' +import { eventsSecondaryNavRoutes } from '../constants/secondaryNavRoutes' import { activeGameState, SelectedActiveGame } from '../state/activeGameState' const localStorageKey = 'eventFunnels' @@ -33,14 +34,7 @@ function EventFunnelsDisplay({ activeGame }: { activeGame: SelectedActiveGame }) const { funnels, loading, error } = useEventFunnels(activeGame) - const secondaryNav = ( - - ) + const secondaryNav = return ( - } + secondaryNav={} > ', () => { + const axiosMock = new MockAdapter(api) + + const user: Partial = { + id: 1, + email: 'me@talo.dev', + username: 'me', + emailConfirmed: true, + type: UserType.DEV, + createdAt: '2021-01-01T00:00:00Z', + organisation: { + id: 1, + name: 'Test Org', + games: [], + pricingPlan: { status: 'active' }, + }, + } + + const activeGame = { + id: 1, + name: 'Test Game', + apiKey: 'key', + devBuildApiKey: 'dev-key', + } + + const event = { + name: 'Open inventory', + count: 3, + players: 2, + propKeys: ['version'], + retentionDays: null as number | null, + } + + beforeEach(() => { + axiosMock.reset() + localStorage.clear() + }) + + const renderPage = (catalogueEvent = event, userOverrides: Partial = {}) => { + axiosMock.onGet('http://talo.api/games/1/events/catalogue?page=0').reply(200, { + events: [catalogueEvent], + count: 1, + itemsPerPage: 50, + isLastPage: true, + }) + + return render( + + + + + , + ) + } + + it('lists events with their data', async () => { + renderPage() + + expect(await screen.findByText('Open inventory')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('version')).toBeInTheDocument() + expect(screen.getByText('None')).toBeInTheDocument() + }) + + it('hides retention buttons for devs', async () => { + renderPage({ ...event, retentionDays: 30 }) + + expect(await screen.findByText('30 days')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit retention' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Clear retention' })).not.toBeInTheDocument() + }) + + it('updates retention inline', async () => { + renderPage(event, { type: UserType.ADMIN }) + + await screen.findByText('Open inventory') + axiosMock.onPut('http://talo.api/games/1/events/retention').replyOnce(200, { + retention: { + eventName: 'Open inventory', + retentionDays: 30, + updatedAt: '2026-01-01T00:00:00Z', + }, + }) + + await userEvent.click(screen.getByRole('button', { name: 'Edit retention' })) + await userEvent.type(screen.getByPlaceholderText('Days'), '30') + await userEvent.click(screen.getByRole('button', { name: 'Save retention' })) + + await waitFor(() => { + expect(axiosMock.history.put.length).toBe(1) + }) + expect(JSON.parse(axiosMock.history.put[0].data)).toEqual({ + eventName: 'Open inventory', + retentionDays: 30, + }) + expect(await screen.findByText('Retention updated')).toBeInTheDocument() + }) + + it('clears retention', async () => { + renderPage({ ...event, retentionDays: 30 }, { type: UserType.ADMIN }) + + await screen.findByText('30 days') + axiosMock.onDelete('http://talo.api/games/1/events/retention').replyOnce(204) + + await userEvent.click(screen.getByRole('button', { name: 'Clear retention' })) + + await waitFor(() => { + expect(axiosMock.history.delete.length).toBe(1) + }) + expect(axiosMock.history.delete[0].params).toEqual({ eventName: 'Open inventory' }) + expect(await screen.findByText('Retention cleared')).toBeInTheDocument() + }) + + it('hides the purge button for devs', async () => { + renderPage() + + expect(await screen.findByText('Open inventory')).toBeInTheDocument() + expect(screen.queryByText('Purge')).not.toBeInTheDocument() + }) + + it('purges an event as an admin', async () => { + renderPage(event, { type: UserType.ADMIN }) + + await screen.findByText('Open inventory') + axiosMock.onDelete('http://talo.api/games/1/events/purge').replyOnce(200, { purged: 3 }) + + const confirmMock = vi.spyOn(window, 'confirm').mockImplementation(() => true) + + await userEvent.click(screen.getByText('Purge')) + + await waitFor(() => { + expect(axiosMock.history.delete.length).toBe(1) + }) + expect(axiosMock.history.delete[0].params).toEqual({ eventName: 'Open inventory' }) + expect(confirmMock).toHaveBeenCalledWith( + "Are you sure you want to purge all 'Open inventory' events? This action cannot be undone.", + ) + expect(await screen.findByText('Purged 3 events')).toBeInTheDocument() + + confirmMock.mockRestore() + }) +}) diff --git a/src/utils/canPerformAction.ts b/src/utils/canPerformAction.ts index a5c36d63..8bf4ea87 100644 --- a/src/utils/canPerformAction.ts +++ b/src/utils/canPerformAction.ts @@ -13,6 +13,8 @@ export enum PermissionBasedAction { REMOVE_ORGANISATION_MEMBER, CHANGE_ORGANISATION_MEMBER_TYPE, RESEND_INVITE, + PURGE_EVENTS, + CHANGE_EVENT_RETENTION, } export default function canPerformAction(user: User, action: PermissionBasedAction) { @@ -30,6 +32,8 @@ export default function canPerformAction(user: User, action: PermissionBasedActi case PermissionBasedAction.DELETE_CHANNEL: case PermissionBasedAction.DELETE_PLAYER: case PermissionBasedAction.RESEND_INVITE: + case PermissionBasedAction.PURGE_EVENTS: + case PermissionBasedAction.CHANGE_EVENT_RETENTION: return user.type === UserType.ADMIN case PermissionBasedAction.DELETE_GROUP: return [UserType.DEV, UserType.ADMIN].includes(user.type)
No events found