Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
)
Expand Down Expand Up @@ -193,6 +196,7 @@ function Router({ intendedRoute }: RouterProps) {
)}
<Route path={routes.channels} element={<Channels />} />
<Route path={routes.eventBreakdown} element={<EventBreakdown />} />
<Route path={routes.eventsCatalogue} element={<EventCatalogue />} />
<Route path={routes.eventsFunnels} element={<EventFunnels />} />
<Route path={routes.eventsFunnelNew} element={<EventFunnelNew />} />
<Route path={routes.eventFunnel} element={<EventFunnel />} />
Expand Down
9 changes: 9 additions & 0 deletions src/api/deleteEventRetention.ts
Original file line number Diff line number Diff line change
@@ -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(''),
)
11 changes: 11 additions & 0 deletions src/api/purgeEvents.ts
Original file line number Diff line number Diff line change
@@ -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(),
}),
)
12 changes: 12 additions & 0 deletions src/api/upsertEventRetention.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
)
22 changes: 22 additions & 0 deletions src/api/useEventCatalogue.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
1 change: 1 addition & 0 deletions src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/constants/secondaryNavRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
24 changes: 24 additions & 0 deletions src/entities/eventCatalogue.ts
Original file line number Diff line number Diff line change
@@ -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<typeof catalogueEventSchema>

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(),
})
233 changes: 233 additions & 0 deletions src/pages/EventCatalogue.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const [retentionDaysInput, setRetentionDaysInput] = useState('')
const [editingError, setEditingError] = useState<TaloError | null>(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<HTMLInputElement>) => {
if (e.key === 'Enter') {
await onSaveRetention()
} else if (e.key === 'Escape') {
setEditingEventName(null)
}
}

return (
<Page
title='Event catalogue'
isLoading={loading}
secondaryNav={<SecondaryNav routes={eventsSecondaryNavRoutes} />}
>
{error && <ErrorMessage error={error} />}
{editingError && <ErrorMessage error={editingError} />}

{events.length === 0 && !loading && !error && <p>No events found</p>}

{events.length > 0 && (
<>
<Table
columns={[
'Event',
'Total count',
'Unique players',
'Prop keys',
'Retention',
...(canPurge ? [''] : []),
]}
>
<TableBody iterator={events}>
{(event) => {
const propKeys = event.propKeys.filter((key) => !(key in metaPropKeyMap))

return (
<>
<TableCell>{event.name}</TableCell>
<TableCell className='font-mono'>{event.count.toLocaleString()}</TableCell>
<TableCell className='font-mono'>{event.players.toLocaleString()}</TableCell>
<TableCell>
<div className='flex max-w-[240px] flex-wrap items-center gap-1 lg:max-w-[400px]'>
{propKeys.map((propKey) => (
<span
key={propKey}
className='max-w-full truncate rounded bg-gray-900 p-2 font-mono text-xs'
>
{propKey}
</span>
))}
{propKeys.length === 0 && '-'}
</div>
</TableCell>
<TableCell className='w-64'>
<div className='flex items-center space-x-2'>
{editingEventName === event.name && (
<>
<TextInput
id={`edit-retention-${event.name}`}
type='number'
variant='light'
placeholder='Days'
containerClassName='w-24'
onChange={setRetentionDaysInput}
value={retentionDaysInput}
inputExtra={{
onKeyDown: onRetentionInputKeyDown,
min: MIN_RETENTION_DAYS,
}}
/>
<Button
variant='icon'
className='rounded-full bg-indigo-900 p-1'
disabled={!isValidRetention}
onClick={onSaveRetention}
icon={<IconCheck size={16} />}
extra={{ 'aria-label': 'Save retention' }}
/>
<Button
variant='icon'
className='rounded-full bg-indigo-900 p-1'
onClick={() => setEditingEventName(null)}
icon={<IconX size={16} />}
extra={{ 'aria-label': 'Cancel editing retention' }}
/>
</>
)}
{editingEventName !== event.name && (
<>
<span>
{event.retentionDays ? `${event.retentionDays} days` : 'None'}
</span>
{canChangeRetention && (
<>
<Button
variant='icon'
className='rounded-full bg-indigo-900 p-1'
onClick={() => onStartEdit(event)}
icon={<IconPencil size={16} />}
extra={{ 'aria-label': 'Edit retention' }}
/>
{event.retentionDays && (
<Button
variant='icon'
className='rounded-full bg-indigo-900 p-1'
onClick={() => onClearRetention(event)}
icon={<IconX size={16} />}
extra={{ 'aria-label': 'Clear retention' }}
/>
)}
</>
)}
</>
)}
</div>
</TableCell>
{canPurge && (
<TableCell className='w-32'>
<Button variant='grey' onClick={() => onPurge(event)}>
<span>Purge</span>
</Button>
</TableCell>
)}
</>
)
}}
</TableBody>
</Table>

<Pagination count={count!} pageState={[page, setPage]} itemsPerPage={itemsPerPage!} />
</>
)}
</Page>
)
}

export default EventCatalogue
10 changes: 2 additions & 8 deletions src/pages/EventFunnelNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -15,14 +16,7 @@ export default function EventFunnelNew() {
const navigate = useNavigate()
const { mutate } = useSWRConfig()

const secondaryNav = (
<SecondaryNav
routes={[
{ title: 'Events overview', to: routes.eventsOverview },
{ title: 'Event funnels', to: routes.eventsFunnels },
]}
/>
)
const secondaryNav = <SecondaryNav routes={eventsSecondaryNavRoutes} />

return (
<EventsProvider localStorageKey={localStorageKey}>
Expand Down
Loading
Loading