From 434ceba21623ad7dafa72954087f5de21ed62131 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 21:14:38 -0700 Subject: [PATCH 1/3] Add durable NotifyComp push sessions --- .../MyCompetitions/MyCompetitions.query.ts | 18 +- .../notifications/assignmentNotifications.ts | 186 +++++++++++++++--- .../notifyCompPushSession.test.ts | 53 +++++ .../notifications/notifyCompPushSession.ts | 45 +++++ src/providers/AuthProvider/AuthProvider.tsx | 7 +- 5 files changed, 278 insertions(+), 31 deletions(-) create mode 100644 src/lib/notifications/notifyCompPushSession.test.ts create mode 100644 src/lib/notifications/notifyCompPushSession.ts diff --git a/src/containers/MyCompetitions/MyCompetitions.query.ts b/src/containers/MyCompetitions/MyCompetitions.query.ts index 1be874c..1932a9b 100644 --- a/src/containers/MyCompetitions/MyCompetitions.query.ts +++ b/src/containers/MyCompetitions/MyCompetitions.query.ts @@ -25,12 +25,18 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo return undefined; } - const upcoming_competitions = JSON.parse( - getLocalStorage('my.upcoming_competitions') || '[]', - ) as ApiCompetition[]; - const ongoing_competitions = JSON.parse( - getLocalStorage('my.ongoing_competitions') || '[]', - ) as ApiCompetition[]; + const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions'); + const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions'); + if (!rawUpcomingCompetitions && !rawOngoingCompetitions) { + return undefined; + } + + const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[]; + const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[]; + + if (!upcoming_competitions.length && !ongoing_competitions.length) { + return undefined; + } return { user: user, upcoming_competitions, ongoing_competitions }; }, diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts index e97cb13..ebfe566 100644 --- a/src/lib/notifications/assignmentNotifications.ts +++ b/src/lib/notifications/assignmentNotifications.ts @@ -1,5 +1,10 @@ import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken'; +import { + clearNotifyCompPushSessionToken, + getNotifyCompPushSessionToken, + setNotifyCompPushSessionToken, +} from './notifyCompPushSession'; const NOTIFY_COMP_ORIGIN = import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api'; @@ -15,6 +20,17 @@ interface PushSubscriptionJson { }; } +interface PushSessionResponse { + sessionToken?: string; + token?: string; +} + +interface PushSubscriptionPayload { + endpoint: string; + p256dh: string; + auth: string; +} + export interface AssignmentNotificationWatch { competitionId: string; wcaUserId: number; @@ -25,7 +41,7 @@ export type AssignmentNotificationStatus = NotificationPermission | 'reauthorize const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`; export const isAssignmentNotificationsEnabled = () => - getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; + Boolean(getNotifyCompPushSessionToken()) || getLocalStorage(ENABLED_STORAGE_KEY) === 'true'; const toUint8Array = (base64: string) => { const padding = '='.repeat((4 - (base64.length % 4)) % 4); @@ -49,7 +65,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus return 'unsupported'; } - if (!getStoredWcaAccessToken()) { + if (!getNotifyCompPushSessionToken() && !getStoredWcaAccessToken()) { return 'reauthorize'; } @@ -105,6 +121,9 @@ const fetchNotifyCompToken = async () => { return payload.token; }; +const canFallbackToLegacySubscriptions = (response: Response) => + response.status === 404 || response.status === 405; + const fetchVapidPublicKey = async () => { const response = await fetch(notifyCompUrl('/v0/external/push/vapid-public-key')); @@ -153,30 +172,33 @@ const getPushSubscription = async () => { }); }; -export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => { - const permission = await requestAssignmentNotificationPermission(); - if (permission !== 'granted') { - return permission; - } - - const token = await fetchNotifyCompToken(); - const subscription = await getPushSubscription(); +const pushSubscriptionPayload = (subscription: PushSubscription): PushSubscriptionPayload => { const payload = subscription.toJSON() as PushSubscriptionJson; if (!payload.endpoint || !payload.keys?.p256dh || !payload.keys.auth) { throw new Error('Browser push subscription is missing required keys.'); } + return { + auth: payload.keys.auth, + endpoint: payload.endpoint, + p256dh: payload.keys.p256dh, + }; +}; + +const registerLegacySubscription = async ( + authToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { const response = await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { method: 'POST', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ - endpoint: payload.endpoint, - p256dh: payload.keys.p256dh, - auth: payload.keys.auth, + ...payload, watches, }), }); @@ -185,34 +207,150 @@ export const enableAssignmentNotifications = async (watches: AssignmentNotificat deleteLocalStorage(ENABLED_STORAGE_KEY); throw new Error(await readErrorMessage(response)); } +}; + +const createPushSession = async ( + authToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions'), { + method: 'POST', + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...payload, + watches, + }), + }); + + if (canFallbackToLegacySubscriptions(response)) { + await registerLegacySubscription(authToken, payload, watches); + return; + } + + if (!response.ok) { + clearNotifyCompPushSessionToken(); + deleteLocalStorage(ENABLED_STORAGE_KEY); + throw new Error(await readErrorMessage(response)); + } + + const session = (await response.json()) as PushSessionResponse; + const sessionToken = session.sessionToken || session.token; + if (!sessionToken) { + clearNotifyCompPushSessionToken(); + deleteLocalStorage(ENABLED_STORAGE_KEY); + throw new Error('NotifyComp push session response was missing a session token.'); + } + + setNotifyCompPushSessionToken(sessionToken); +}; + +const updatePushSession = async ( + sessionToken: string, + payload: PushSubscriptionPayload, + watches: AssignmentNotificationWatch[], +) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), { + method: 'PUT', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...payload, + watches, + }), + }); + + if (response.status === 401 || response.status === 403) { + clearNotifyCompPushSessionToken(); + return false; + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return true; +}; + +export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => { + const permission = await requestAssignmentNotificationPermission(); + if (permission !== 'granted') { + return permission; + } + + const subscription = await getPushSubscription(); + const payload = pushSubscriptionPayload(subscription); + const sessionToken = getNotifyCompPushSessionToken(); + + if (sessionToken && (await updatePushSession(sessionToken, payload, watches))) { + setLocalStorage(ENABLED_STORAGE_KEY, 'true'); + return permission; + } + + await createPushSession(await fetchNotifyCompToken(), payload, watches); setLocalStorage(ENABLED_STORAGE_KEY, 'true'); return permission; }; +const deletePushSession = async (sessionToken: string, endpoint?: string) => { + const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); + + if ( + !response.ok && + response.status !== 401 && + response.status !== 403 && + response.status !== 404 + ) { + throw new Error(await readErrorMessage(response)); + } +}; + +const deleteLegacySubscription = async (endpoint: string) => { + const token = await fetchNotifyCompToken(); + + await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); +}; + export const disableAssignmentNotifications = async () => { const registration = await getServiceWorkerRegistration(); const subscription = await registration.pushManager.getSubscription(); + const sessionToken = getNotifyCompPushSessionToken(); if (!subscription) { + clearNotifyCompPushSessionToken(); deleteLocalStorage(ENABLED_STORAGE_KEY); return; } - const token = await fetchNotifyCompToken(); const payload = subscription.toJSON() as PushSubscriptionJson; - if (payload.endpoint) { - await fetch(notifyCompUrl('/v0/external/push/subscriptions'), { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ endpoint: payload.endpoint }), - }); + if (sessionToken) { + await deletePushSession(sessionToken, payload.endpoint); + } else if (payload.endpoint) { + await deleteLegacySubscription(payload.endpoint); } await subscription.unsubscribe(); + clearNotifyCompPushSessionToken(); deleteLocalStorage(ENABLED_STORAGE_KEY); }; diff --git a/src/lib/notifications/notifyCompPushSession.test.ts b/src/lib/notifications/notifyCompPushSession.test.ts new file mode 100644 index 0000000..6c01dc4 --- /dev/null +++ b/src/lib/notifications/notifyCompPushSession.test.ts @@ -0,0 +1,53 @@ +import { + clearNotifyCompPushSessionToken, + getNotifyCompPushSessionToken, + setNotifyCompPushSessionToken, +} from './notifyCompPushSession'; + +jest.mock('@/lib/localStorage', () => ({ + deleteLocalStorage: (key: string) => localStorage.removeItem(key), + getLocalStorage: (key: string) => localStorage.getItem(key), + setLocalStorage: (key: string, value: string) => localStorage.setItem(key, value), +})); + +const base64Url = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=/g, ''); + +const jwtWithClaims = (claims: Record) => + `${base64Url({ alg: 'HS256', typ: 'JWT' })}.${base64Url(claims)}.signature`; + +describe('notifyCompPushSession', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + clearNotifyCompPushSessionToken(); + }); + + it('stores opaque NotifyComp push session tokens', () => { + setNotifyCompPushSessionToken('opaque-session-token'); + + expect(getNotifyCompPushSessionToken()).toBe('opaque-session-token'); + }); + + it('reads unpadded JWT push session tokens', () => { + const token = jwtWithClaims({ + exp: Math.floor(Date.now() / 1000) + 60, + }); + + setNotifyCompPushSessionToken(token); + + expect(getNotifyCompPushSessionToken()).toBe(token); + }); + + it('clears expired JWT push session tokens', () => { + setNotifyCompPushSessionToken( + jwtWithClaims({ + exp: Math.floor(Date.now() / 1000) - 60, + }), + ); + + expect(getNotifyCompPushSessionToken()).toBe(null); + }); +}); diff --git a/src/lib/notifications/notifyCompPushSession.ts b/src/lib/notifications/notifyCompPushSession.ts new file mode 100644 index 0000000..95f2e07 --- /dev/null +++ b/src/lib/notifications/notifyCompPushSession.ts @@ -0,0 +1,45 @@ +import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage'; + +const PUSH_SESSION_TOKEN_KEY = 'notifyComp.pushSessionToken'; + +interface JwtClaims { + exp?: number; +} + +const decodeJwtPayload = (token: string): JwtClaims | null => { + try { + const payload = token.split('.')[1]; + if (!payload) { + return null; + } + + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const normalized = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return JSON.parse(window.atob(normalized)) as JwtClaims; + } catch { + return null; + } +}; + +export const getNotifyCompPushSessionToken = () => { + const token = getLocalStorage(PUSH_SESSION_TOKEN_KEY); + if (!token) { + return null; + } + + const claims = decodeJwtPayload(token); + if (claims?.exp && claims.exp * 1000 <= Date.now()) { + deleteLocalStorage(PUSH_SESSION_TOKEN_KEY); + return null; + } + + return token; +}; + +export const setNotifyCompPushSessionToken = (token: string) => { + setLocalStorage(PUSH_SESSION_TOKEN_KEY, token); +}; + +export const clearNotifyCompPushSessionToken = () => { + deleteLocalStorage(PUSH_SESSION_TOKEN_KEY); +}; diff --git a/src/providers/AuthProvider/AuthProvider.tsx b/src/providers/AuthProvider/AuthProvider.tsx index e46ac1c..524a42f 100644 --- a/src/providers/AuthProvider/AuthProvider.tsx +++ b/src/providers/AuthProvider/AuthProvider.tsx @@ -84,7 +84,10 @@ export function AuthProvider({ children }: PropsWithChildren) { return; } - fetchMe(accessToken) + fetchMe(accessToken, { + upcoming_competitions: true, + ongoing_competitions: true, + }) .then(({ me, ongoing_competitions, upcoming_competitions }) => { setLocalStorage('accessToken', accessToken); setLocalStorage( @@ -92,6 +95,8 @@ export function AuthProvider({ children }: PropsWithChildren) { String(Date.now() + Number(hashParams.get('expires_in') ?? 0) * 1000), ); setUserAndSave(me); + setLocalStorage('my.upcoming_competitions', JSON.stringify(upcoming_competitions || [])); + setLocalStorage('my.ongoing_competitions', JSON.stringify(ongoing_competitions || [])); queryClient.setQueryData(['userCompetitions', me.id], { user: me, ongoing_competitions, From 23841b89a0ee8d76c3f6097ba45388a774306e97 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Wed, 27 May 2026 21:18:07 -0700 Subject: [PATCH 2/3] Add remote webhook management --- src/App.tsx | 3 + .../useNotifyCompRemoteWebhooks/index.ts | 1 + .../useNotifyCompRemoteWebhooks.test.tsx | 224 +++++++++++ .../useNotifyCompRemoteWebhooks.ts | 133 +++++++ .../CompetitionLayout.tabs.tsx | 20 +- src/lib/notifyCompRemoteGraphql.ts | 91 +++++ src/pages/Competition/Admin/index.stories.tsx | 56 +++ src/pages/Competition/Admin/index.tsx | 1 + .../RemoteAutoAdvanceToggle.stories.tsx | 66 ++++ .../Remote/RemoteWebhookSetup.stories.tsx | 157 ++++++++ .../Remote/RemoteWebhookSetup.test.tsx | 198 ++++++++++ .../Competition/Remote/RemoteWebhookSetup.tsx | 347 ++++++++++++++++++ .../Competition/Remote/Webhooks.stories.tsx | 127 +++++++ .../Competition/Remote/Webhooks.test.tsx | 124 +++++++ src/pages/Competition/Remote/Webhooks.tsx | 107 ++++++ .../Competition/Remote/index.stories.tsx | 126 +++++++ src/pages/Competition/Remote/index.test.tsx | 124 +++++++ src/pages/Competition/Remote/index.tsx | 31 +- 18 files changed, 1922 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useNotifyCompRemoteWebhooks/index.ts create mode 100644 src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx create mode 100644 src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts create mode 100644 src/pages/Competition/Admin/index.stories.tsx create mode 100644 src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx create mode 100644 src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx create mode 100644 src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx create mode 100644 src/pages/Competition/Remote/RemoteWebhookSetup.tsx create mode 100644 src/pages/Competition/Remote/Webhooks.stories.tsx create mode 100644 src/pages/Competition/Remote/Webhooks.test.tsx create mode 100644 src/pages/Competition/Remote/Webhooks.tsx create mode 100644 src/pages/Competition/Remote/index.stories.tsx create mode 100644 src/pages/Competition/Remote/index.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 648f747..f7eb726 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import CompetitionPerson from './pages/Competition/Person'; import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests'; import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent'; import CompetitionRemote from './pages/Competition/Remote'; +import CompetitionRemoteWebhooks from './pages/Competition/Remote/Webhooks'; import CompetitionResults from './pages/Competition/Results'; import { CompetitionActivity, @@ -152,10 +153,12 @@ const Navigation = () => { } /> } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useNotifyCompRemoteWebhooks/index.ts b/src/hooks/useNotifyCompRemoteWebhooks/index.ts new file mode 100644 index 0000000..66cb51e --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/index.ts @@ -0,0 +1 @@ +export { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks'; diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx new file mode 100644 index 0000000..6d8c5ab --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx @@ -0,0 +1,224 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; +import { + CreateRemoteWebhookDocument, + DeleteRemoteWebhookDocument, + RemoteWebhooksDocument, + TestEditingRemoteWebhookDocument, + TestRemoteWebhookDocument, + UpdateRemoteWebhookDocument, +} from '@/lib/notifyCompRemoteGraphql'; +import { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks'; + +const competitionId = 'ExampleComp2026'; + +const webhooksMock = (webhooks: unknown[]): MockedResponse => ({ + request: { + query: RemoteWebhooksDocument, + variables: { competitionId }, + }, + result: { + data: { + competition: { + __typename: 'Competition', + id: competitionId, + webhooks, + }, + }, + }, +}); + +const createWrapper = (mocks: MockedResponse[]) => + function MockedApolloWrapper({ children }: PropsWithChildren) { + return {children}; + }; + +describe('useNotifyCompRemoteWebhooks', () => { + it('loads competition webhooks', async () => { + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([ + { + __typename: 'Webhook', + id: 1, + method: 'POST', + url: 'https://example.com/notify', + }, + ]), + ]), + }); + + await waitFor(() => { + expect(result.current.webhooks).toHaveLength(1); + }); + + expect(result.current.webhooks[0]).toMatchObject({ + method: 'POST', + url: 'https://example.com/notify', + }); + }); + + it('creates, updates, and deletes webhooks with NotifyComp variables', async () => { + const createVariables = { + competitionId, + webhook: { + method: 'POST' as const, + url: 'https://example.com/created', + }, + }; + const updateVariables = { + id: 4, + webhook: { + method: 'GET' as const, + url: 'https://example.com/updated', + }, + }; + + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([]), + { + request: { + query: CreateRemoteWebhookDocument, + variables: createVariables, + }, + result: { + data: { + createWebhook: { + __typename: 'Webhook', + id: 4, + ...createVariables.webhook, + }, + }, + }, + }, + webhooksMock([ + { + __typename: 'Webhook', + id: 4, + ...createVariables.webhook, + }, + ]), + { + request: { + query: UpdateRemoteWebhookDocument, + variables: updateVariables, + }, + result: { + data: { + updateWebhook: { + __typename: 'Webhook', + id: 4, + ...updateVariables.webhook, + }, + }, + }, + }, + webhooksMock([ + { + __typename: 'Webhook', + id: 4, + ...updateVariables.webhook, + }, + ]), + { + request: { + query: DeleteRemoteWebhookDocument, + variables: { id: 4 }, + }, + result: { + data: { + deleteWebhook: null, + }, + }, + }, + webhooksMock([]), + ]), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveWebhook(createVariables.webhook); + }); + await act(async () => { + await result.current.saveWebhook(updateVariables.webhook, 4); + }); + await act(async () => { + await result.current.removeWebhook(4); + }); + + expect(result.current.error).toBeNull(); + }); + + it('tests saved and unsaved webhook settings', async () => { + const webhook = { + method: 'PUT' as const, + url: 'https://example.com/test', + }; + const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), { + wrapper: createWrapper([ + webhooksMock([]), + { + request: { + query: TestRemoteWebhookDocument, + variables: { id: 8 }, + }, + result: { + data: { + testWebhook: { + __typename: 'WebhookResponse', + body: 'ok', + status: 200, + statusText: 'OK', + url: webhook.url, + }, + }, + }, + }, + { + request: { + query: TestEditingRemoteWebhookDocument, + variables: { competitionId, webhook }, + }, + result: { + data: { + testEditingWebhook: { + __typename: 'WebhookResponse', + body: 'failed', + status: 500, + statusText: 'Server Error', + url: webhook.url, + }, + }, + }, + }, + ]), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.testSavedWebhook(8); + }); + + expect(result.current.testResult).toMatchObject({ + status: 200, + statusText: 'OK', + }); + + await act(async () => { + await result.current.testWebhookSettings(webhook); + }); + + expect(result.current.testResult).toMatchObject({ + status: 500, + statusText: 'Server Error', + }); + }); +}); diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts new file mode 100644 index 0000000..72f96b1 --- /dev/null +++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts @@ -0,0 +1,133 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { useMemo, useState } from 'react'; +import { + CreateRemoteWebhookDocument, + DeleteRemoteWebhookDocument, + NotifyCompWebhook, + NotifyCompWebhookInput, + NotifyCompWebhookResponse, + RemoteWebhooksDocument, + TestEditingRemoteWebhookDocument, + TestRemoteWebhookDocument, + UpdateRemoteWebhookDocument, +} from '@/lib/notifyCompRemoteGraphql'; + +interface UseNotifyCompRemoteWebhooksParams { + competitionId: string; + enabled?: boolean; +} + +interface RemoteWebhooksQueryData { + competition: { + id: string; + webhooks: NotifyCompWebhook[]; + } | null; +} + +export function useNotifyCompRemoteWebhooks({ + competitionId, + enabled = true, +}: UseNotifyCompRemoteWebhooksParams) { + const [mutationError, setMutationError] = useState(null); + const [testResult, setTestResult] = useState(null); + + const webhooksQuery = useQuery(RemoteWebhooksDocument, { + variables: { competitionId }, + skip: !competitionId || !enabled, + }); + + const refetchQueries = useMemo( + () => [ + { + query: RemoteWebhooksDocument, + variables: { competitionId }, + }, + ], + [competitionId], + ); + + const mutationOptions = { + refetchQueries, + }; + + const [createWebhook, createWebhookStatus] = useMutation< + { createWebhook: NotifyCompWebhook }, + { competitionId: string; webhook: NotifyCompWebhookInput } + >(CreateRemoteWebhookDocument, mutationOptions); + const [updateWebhook, updateWebhookStatus] = useMutation< + { updateWebhook: NotifyCompWebhook }, + { id: number; webhook: NotifyCompWebhookInput } + >(UpdateRemoteWebhookDocument, mutationOptions); + const [deleteWebhook, deleteWebhookStatus] = useMutation< + { deleteWebhook?: null }, + { id: number } + >(DeleteRemoteWebhookDocument, mutationOptions); + const [testWebhook, testWebhookStatus] = useMutation< + { testWebhook: NotifyCompWebhookResponse | null }, + { id: number } + >(TestRemoteWebhookDocument); + const [testEditingWebhook, testEditingWebhookStatus] = useMutation< + { testEditingWebhook: NotifyCompWebhookResponse | null }, + { competitionId: string; webhook: NotifyCompWebhookInput } + >(TestEditingRemoteWebhookDocument); + + const runMutation = async (operation: () => Promise) => { + setMutationError(null); + setTestResult(null); + + try { + await operation(); + } catch (err) { + setMutationError(err instanceof Error ? err.message : 'Webhook operation failed.'); + throw err; + } + }; + + const saveWebhook = (webhook: NotifyCompWebhookInput, id?: number) => + runMutation(() => + id + ? updateWebhook({ + variables: { id, webhook }, + }) + : createWebhook({ + variables: { competitionId, webhook }, + }), + ); + + const removeWebhook = (id: number) => + runMutation(() => + deleteWebhook({ + variables: { id }, + }), + ); + + const testSavedWebhook = (id: number) => + runMutation(async () => { + const result = await testWebhook({ + variables: { id }, + }); + setTestResult(result.data?.testWebhook || null); + }); + + const testWebhookSettings = (webhook: NotifyCompWebhookInput) => + runMutation(async () => { + const result = await testEditingWebhook({ + variables: { competitionId, webhook }, + }); + setTestResult(result.data?.testEditingWebhook || null); + }); + + return { + error: mutationError || webhooksQuery.error?.message || null, + isLoading: webhooksQuery.loading, + isSaving: + createWebhookStatus.loading || updateWebhookStatus.loading || deleteWebhookStatus.loading, + isTesting: testWebhookStatus.loading || testEditingWebhookStatus.loading, + removeWebhook, + saveWebhook, + testResult, + testSavedWebhook, + testWebhookSettings, + webhooks: webhooksQuery.data?.competition?.webhooks || [], + }; +} diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx index 7e66508..22db621 100644 --- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx +++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx @@ -71,12 +71,20 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay } if (canManageRemote) { - _adminTabs.push({ - href: `/competitions/${competitionId}/admin/remote`, - text: t('header.tabs.remote', { - defaultValue: 'Remote', - }), - }); + _adminTabs.push( + { + href: `/competitions/${competitionId}/admin/remote`, + text: t('header.tabs.remote', { + defaultValue: 'Remote', + }), + }, + { + href: `/competitions/${competitionId}/admin/webhooks`, + text: t('header.tabs.webhooks', { + defaultValue: 'Webhooks', + }), + }, + ); } if (hasStream) { diff --git a/src/lib/notifyCompRemoteGraphql.ts b/src/lib/notifyCompRemoteGraphql.ts index ddaf9ce..1cb945b 100644 --- a/src/lib/notifyCompRemoteGraphql.ts +++ b/src/lib/notifyCompRemoteGraphql.ts @@ -14,6 +14,26 @@ export interface NotifyCompCompetition { autoAdvanceDelay?: number | null; } +export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + +export interface NotifyCompWebhook { + id: number; + method: HttpMethod; + url: string; +} + +export interface NotifyCompWebhookInput { + method: HttpMethod; + url: string; +} + +export interface NotifyCompWebhookResponse { + body?: string | null; + status: number; + statusText: string; + url: string; +} + export const RemoteActivityFragment = gql` fragment RemoteActivityFragment on Activity { activityId @@ -34,6 +54,23 @@ export const RemoteCompetitionDocument = gql` } `; +export const RemoteWebhookFragment = gql` + fragment RemoteWebhookFragment on Webhook { + id + url + method + } +`; + +export const RemoteWebhookResponseFragment = gql` + fragment RemoteWebhookResponseFragment on WebhookResponse { + url + status + statusText + body + } +`; + export const RemoteActivitiesDocument = gql` query RemoteActivities($competitionId: String!, $roomId: Int) { activities(competitionId: $competitionId, roomId: $roomId) { @@ -43,6 +80,18 @@ export const RemoteActivitiesDocument = gql` ${RemoteActivityFragment} `; +export const RemoteWebhooksDocument = gql` + query RemoteWebhooks($competitionId: String!) { + competition(competitionId: $competitionId) { + id + webhooks { + ...RemoteWebhookFragment + } + } + } + ${RemoteWebhookFragment} +`; + export const RemoteActivitiesSubscriptionDocument = gql` subscription RemoteActivities($competitionIds: [String!]!) { activity: activityUpdated(competitionIds: $competitionIds) { @@ -52,6 +101,48 @@ export const RemoteActivitiesSubscriptionDocument = gql` ${RemoteActivityFragment} `; +export const CreateRemoteWebhookDocument = gql` + mutation CreateRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) { + createWebhook(competitionId: $competitionId, webhook: $webhook) { + ...RemoteWebhookFragment + } + } + ${RemoteWebhookFragment} +`; + +export const UpdateRemoteWebhookDocument = gql` + mutation UpdateRemoteWebhook($id: Int!, $webhook: WebhookInput!) { + updateWebhook(id: $id, webhook: $webhook) { + ...RemoteWebhookFragment + } + } + ${RemoteWebhookFragment} +`; + +export const DeleteRemoteWebhookDocument = gql` + mutation DeleteRemoteWebhook($id: Int!) { + deleteWebhook(id: $id) + } +`; + +export const TestRemoteWebhookDocument = gql` + mutation TestRemoteWebhook($id: Int!) { + testWebhook(id: $id) { + ...RemoteWebhookResponseFragment + } + } + ${RemoteWebhookResponseFragment} +`; + +export const TestEditingRemoteWebhookDocument = gql` + mutation TestEditingRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) { + testEditingWebhook(competitionId: $competitionId, webhook: $webhook) { + ...RemoteWebhookResponseFragment + } + } + ${RemoteWebhookResponseFragment} +`; + export const ImportRemoteCompetitionDocument = gql` mutation ImportRemoteCompetition($competitionId: String!) { importCompetition(competitionId: $competitionId) { diff --git a/src/pages/Competition/Admin/index.stories.tsx b/src/pages/Competition/Admin/index.stories.tsx new file mode 100644 index 0000000..7d0b93c --- /dev/null +++ b/src/pages/Competition/Admin/index.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import CompetitionAdmin from './index'; + +const adminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const nonAdminUser: User = { + id: 1005, + name: 'Eva Park', + email: '', + wca_id: '2026PARK05', + avatar: { + url: '', + thumb_url: '', + }, + delegate_status: '', +}; + +const meta = { + title: 'Pages/Competition/Admin', + component: CompetitionAdmin, + decorators: [makeCompetitionContainerDecorator()], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AvailableTools: Story = { + parameters: { + competition: storybookCompetitionFixture, + currentUser: adminUser, + }, +}; + +export const NoAvailableTools: Story = { + parameters: { + competition: storybookCompetitionFixture, + currentUser: nonAdminUser, + }, +}; diff --git a/src/pages/Competition/Admin/index.tsx b/src/pages/Competition/Admin/index.tsx index 32bcb24..4b336ee 100644 --- a/src/pages/Competition/Admin/index.tsx +++ b/src/pages/Competition/Admin/index.tsx @@ -7,6 +7,7 @@ import { useWCIF } from '@/providers/WCIFProvider'; const adminDescriptions: Record = { scramblers: 'View scrambler assignments by event and round.', remote: 'Control Live Activities updates for the competition.', + webhooks: 'Send Live Activities updates to external services.', 'sum-of-ranks': 'Review competitor rankings across completed rounds.', stats: 'View competition registration and event counts.', }; diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx new file mode 100644 index 0000000..820d140 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { RemoteAutoAdvanceToggle } from './RemoteAutoAdvanceToggle'; + +interface AutoAdvanceStoryArgs { + checked: boolean; + disabled: boolean; +} + +function AutoAdvanceStory({ checked, disabled }: AutoAdvanceStoryArgs) { + const [isChecked, setIsChecked] = useState(checked); + + return ( +
+
+
Auto-advance
+
+ Automatically moves Live Activities to the next scheduled group when the current group + ends. +
+
+
+ { + setIsChecked((currentValue) => !currentValue); + }} + /> +
+
+ ); +} + +const meta = { + title: 'Pages/Competition/Remote/Auto Advance Toggle', + component: AutoAdvanceStory, + render: (args) => , + args: { + checked: false, + disabled: false, + }, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Off: Story = {}; + +export const On: Story = { + args: { + checked: true, + }, +}; + +export const Disabled: Story = { + args: { + checked: true, + disabled: true, + }, +}; diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx new file mode 100644 index 0000000..317c63e --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx @@ -0,0 +1,157 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { RemoteWebhookSetupController, RemoteWebhookSetupPanel } from './RemoteWebhookSetup'; + +type StoryWebhook = RemoteWebhookSetupController['webhooks'][number]; +type StoryTestResult = RemoteWebhookSetupController['testResult']; + +interface WebhookSetupStoryArgs { + disabled: boolean; + error: string | null; + initialTestResult: StoryTestResult; + initialWebhooks: StoryWebhook[]; + isLoading: boolean; +} + +const savedWebhooks: StoryWebhook[] = [ + { + id: 1, + method: 'POST', + url: 'https://hooks.example.com/live-activities', + }, + { + id: 2, + method: 'GET', + url: 'https://status.example.com/ping', + }, +]; + +function WebhookSetupStory({ + disabled, + error, + initialTestResult, + initialWebhooks, + isLoading, +}: WebhookSetupStoryArgs) { + const [webhooks, setWebhooks] = useState(initialWebhooks); + const [testResult, setTestResult] = useState(initialTestResult); + + const controller: RemoteWebhookSetupController = { + error, + isLoading, + isSaving: false, + isTesting: false, + removeWebhook: async (id) => { + setWebhooks((currentWebhooks) => currentWebhooks.filter((webhook) => webhook.id !== id)); + }, + saveWebhook: async (webhook, id) => { + setWebhooks((currentWebhooks) => { + if (id) { + return currentWebhooks.map((currentWebhook) => + currentWebhook.id === id ? { id, ...webhook } : currentWebhook, + ); + } + + return [ + ...currentWebhooks, + { + id: Math.max(0, ...currentWebhooks.map((currentWebhook) => currentWebhook.id)) + 1, + ...webhook, + }, + ]; + }); + }, + testResult, + testSavedWebhook: async (id) => { + const webhook = webhooks.find((currentWebhook) => currentWebhook.id === id); + + setTestResult({ + body: '{"ok":true,"message":"Ping accepted"}', + status: 202, + statusText: 'Accepted', + url: webhook?.url || 'https://hooks.example.com/live-activities', + }); + }, + testWebhookSettings: async (webhook) => { + setTestResult({ + body: 'Draft webhook settings responded successfully.', + status: 200, + statusText: 'OK', + url: webhook.url, + }); + }, + webhooks, + }; + + return ; +} + +const meta = { + title: 'Pages/Competition/Remote/Webhook Setup', + component: WebhookSetupStory, + render: (args) => , + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + args: { + disabled: false, + error: null, + initialTestResult: null, + initialWebhooks: savedWebhooks, + isLoading: false, + }, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SavedWebhooks: Story = {}; + +export const Empty: Story = { + args: { + initialWebhooks: [], + }, +}; + +export const TestResult: Story = { + args: { + initialTestResult: { + body: '{"ok":true,"received":1}', + status: 200, + statusText: 'OK', + url: 'https://hooks.example.com/live-activities', + }, + }, +}; + +export const Loading: Story = { + args: { + initialWebhooks: [], + isLoading: true, + }, +}; + +export const Error: Story = { + args: { + error: 'Webhook URL must use HTTPS', + initialWebhooks: [], + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx new file mode 100644 index 0000000..d39f2c2 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx @@ -0,0 +1,198 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { RemoteWebhookSetup } from './RemoteWebhookSetup'; + +jest.mock('@/hooks/useNotifyCompRemoteWebhooks', () => ({ + useNotifyCompRemoteWebhooks: jest.fn(), +})); + +const competitionId = 'ExampleComp2026'; + +const defaultHookValue = { + error: null, + isLoading: false, + isSaving: false, + isTesting: false, + removeWebhook: jest.fn().mockResolvedValue(undefined), + saveWebhook: jest.fn().mockResolvedValue(undefined), + testResult: null, + testSavedWebhook: jest.fn().mockResolvedValue(undefined), + testWebhookSettings: jest.fn().mockResolvedValue(undefined), + webhooks: [], +}; + +const renderSetup = (hookValue = {}) => { + const value = { + ...defaultHookValue, + ...hookValue, + }; + jest + .mocked(useNotifyCompRemoteWebhooks) + .mockReturnValue(value as unknown as ReturnType); + + render( + + + , + ); + + return value; +}; + +describe('RemoteWebhookSetup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists saved webhooks with method and URL', () => { + renderSetup({ + webhooks: [ + { + id: 1, + method: 'POST', + url: 'https://example.com/notify', + }, + ], + }); + + expect(screen.getByRole('heading', { name: 'Webhook setup' })).toBeInTheDocument(); + expect(screen.getByText('POST')).toBeInTheDocument(); + expect(screen.getByText('https://example.com/notify')).toBeInTheDocument(); + }); + + it('creates a webhook from the form', async () => { + const user = userEvent.setup(); + const hook = renderSetup(); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Add webhook' })); + }); + await act(async () => { + await user.selectOptions(screen.getByLabelText('HTTP method'), 'GET'); + await user.type(screen.getByLabelText('URL'), 'https://example.com/new'); + await user.click(screen.getByRole('button', { name: 'Create webhook' })); + }); + + await waitFor(() => { + expect(hook.saveWebhook).toHaveBeenCalledWith( + { + method: 'GET', + url: 'https://example.com/new', + }, + undefined, + ); + }); + }); + + it('updates an existing webhook from the edit form', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + webhooks: [ + { + id: 2, + method: 'POST', + url: 'https://example.com/old', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Edit' })); + }); + await act(async () => { + await user.clear(screen.getByLabelText('URL')); + await user.type(screen.getByLabelText('URL'), 'https://example.com/updated'); + await user.click(screen.getByRole('button', { name: 'Save webhook' })); + }); + + await waitFor(() => { + expect(hook.saveWebhook).toHaveBeenCalledWith( + { + method: 'POST', + url: 'https://example.com/updated', + }, + 2, + ); + }); + }); + + it('deletes a webhook after confirmation', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + webhooks: [ + { + id: 3, + method: 'DELETE', + url: 'https://example.com/remove', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Delete' })); + }); + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Delete webhook' })); + }); + + await waitFor(() => { + expect(hook.removeWebhook).toHaveBeenCalledWith(3); + }); + }); + + it('tests saved and unsaved webhook settings', async () => { + const user = userEvent.setup(); + const hook = renderSetup({ + testResult: { + body: 'Request accepted', + status: 202, + statusText: 'Accepted', + url: 'https://example.com/test', + }, + webhooks: [ + { + id: 4, + method: 'POST', + url: 'https://example.com/test', + }, + ], + }); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Test' })); + }); + + expect(hook.testSavedWebhook).toHaveBeenCalledWith(4); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'Add webhook' })); + }); + await act(async () => { + await user.type(screen.getByLabelText('URL'), 'https://example.com/draft'); + await user.click(screen.getAllByRole('button', { name: 'Test' })[0]); + }); + + expect(hook.testWebhookSettings).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://example.com/draft', + }); + expect(screen.getByText('202 Accepted')).toBeInTheDocument(); + expect(screen.getByText('Request accepted')).toBeInTheDocument(); + }); + + it('renders failed test results', () => { + renderSetup({ + testResult: { + body: '', + status: 0, + statusText: '', + url: 'https://example.com/fail', + }, + }); + + expect(screen.getByText('0 Request failed')).toBeInTheDocument(); + expect(screen.getByText('Empty response body')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx new file mode 100644 index 0000000..0b95d05 --- /dev/null +++ b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx @@ -0,0 +1,347 @@ +import { FormEvent, useEffect, useState } from 'react'; +import { BarLoader } from 'react-spinners'; +import { Button } from '@/components/Button'; +import { NoteBox } from '@/components/Notebox'; +import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks'; +import { + HttpMethod, + NotifyCompWebhook, + NotifyCompWebhookInput, + NotifyCompWebhookResponse, +} from '@/lib/notifyCompRemoteGraphql'; +import { useConfirm } from '@/providers/ConfirmProvider'; + +const HTTP_METHODS: HttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']; +const BODY_SUMMARY_LENGTH = 300; + +interface WebhookFormState { + id?: number; + method: HttpMethod; + url: string; +} + +export interface RemoteWebhookSetupController { + error: string | null; + isLoading: boolean; + isSaving: boolean; + isTesting: boolean; + removeWebhook: (id: number) => Promise; + saveWebhook: (webhook: NotifyCompWebhookInput, id?: number) => Promise; + testResult: NotifyCompWebhookResponse | null; + testSavedWebhook: (id: number) => Promise; + testWebhookSettings: (webhook: NotifyCompWebhookInput) => Promise; + webhooks: NotifyCompWebhook[]; +} + +interface RemoteWebhookSetupPanelProps { + disabled?: boolean; + webhooks: RemoteWebhookSetupController; +} + +interface RemoteWebhookSetupProps { + competitionId: string; + disabled?: boolean; +} + +const emptyForm: WebhookFormState = { + method: 'POST', + url: '', +}; + +const webhookToForm = (webhook: NotifyCompWebhook): WebhookFormState => ({ + id: webhook.id, + method: webhook.method, + url: webhook.url, +}); + +const toWebhookInput = (form: WebhookFormState): NotifyCompWebhookInput => ({ + method: form.method, + url: form.url.trim(), +}); + +const bodySummary = (body?: string | null) => { + const trimmedBody = body?.trim(); + + if (!trimmedBody) { + return 'Empty response body'; + } + + return trimmedBody.length > BODY_SUMMARY_LENGTH + ? `${trimmedBody.slice(0, BODY_SUMMARY_LENGTH)}...` + : trimmedBody; +}; + +function WebhookTestResult({ result }: { result: NotifyCompWebhookResponse }) { + const statusText = result.statusText || (result.status === 0 ? 'Request failed' : 'No text'); + + return ( +
+
+ Test result + + {result.status} {statusText} + +
+

{result.url}

+
+        {bodySummary(result.body)}
+      
+
+ ); +} + +interface WebhookFormProps { + disabled: boolean; + form: WebhookFormState; + isSaving: boolean; + isTesting: boolean; + onCancel: () => void; + onChange: (form: WebhookFormState) => void; + onSave: (form: WebhookFormState) => void; + onTest: (form: WebhookFormState) => void; +} + +function WebhookForm({ + disabled, + form, + isSaving, + isTesting, + onCancel, + onChange, + onSave, + onTest, +}: WebhookFormProps) { + const isBusy = disabled || isSaving || isTesting; + const canSubmit = Boolean(form.url.trim()) && !isBusy; + + const submit = (event: FormEvent) => { + event.preventDefault(); + + if (canSubmit) { + onSave(form); + } + }; + + return ( +
+
+ + + +
+ +
+ + + +
+
+ ); +} + +export function RemoteWebhookSetupPanel({ + disabled = false, + webhooks, +}: RemoteWebhookSetupPanelProps) { + const confirm = useConfirm(); + const [form, setForm] = useState(null); + + useEffect(() => { + if (form?.id) { + const latestWebhook = webhooks.webhooks.find((webhook) => webhook.id === form.id); + + if (latestWebhook) { + setForm(webhookToForm(latestWebhook)); + } + } + }, [form?.id, webhooks.webhooks]); + + const controlsDisabled = disabled || webhooks.isSaving || webhooks.isTesting; + + const saveWebhook = async (nextForm: WebhookFormState) => { + try { + await webhooks.saveWebhook(toWebhookInput(nextForm), nextForm.id); + setForm(null); + } catch { + // The hook exposes the mutation error inline. + } + }; + + const removeWebhook = async (webhook: NotifyCompWebhook) => { + const confirmed = await confirm({ + confirmLabel: 'Delete webhook', + confirmVariant: 'gray', + message: `Delete the ${webhook.method} webhook for ${webhook.url}?`, + }); + + if (confirmed) { + try { + await webhooks.removeWebhook(webhook.id); + } catch { + // The hook exposes the mutation error inline. + } + } + }; + + const testWebhookSettings = async (nextForm: WebhookFormState) => { + try { + await webhooks.testWebhookSettings(toWebhookInput(nextForm)); + } catch { + // The hook exposes the mutation error inline. + } + }; + + const testSavedWebhook = async (webhook: NotifyCompWebhook) => { + try { + await webhooks.testSavedWebhook(webhook.id); + } catch { + // The hook exposes the mutation error inline. + } + }; + + return ( +
+
+
+
+

Webhook setup

+

+ Notify external services when Live Activities are started or stopped. +

+
+ +
+ + {webhooks.isLoading && } + {webhooks.error && } + + {form && ( + { + setForm(null); + }} + onChange={setForm} + onSave={(nextForm) => { + void saveWebhook(nextForm); + }} + onTest={(nextForm) => { + void testWebhookSettings(nextForm); + }} + /> + )} + + {webhooks.testResult && } + + {!webhooks.isLoading && webhooks.webhooks.length === 0 ? ( + + ) : ( +
+ {webhooks.webhooks.map((webhook) => ( +
+
+
+ + {webhook.method} + + {webhook.url} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} + +export function RemoteWebhookSetup({ competitionId, disabled = false }: RemoteWebhookSetupProps) { + const webhooks = useNotifyCompRemoteWebhooks({ competitionId }); + + return ; +} diff --git a/src/pages/Competition/Remote/Webhooks.stories.tsx b/src/pages/Competition/Remote/Webhooks.stories.tsx new file mode 100644 index 0000000..a30ce91 --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.stories.tsx @@ -0,0 +1,127 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ReactNode } from 'react'; +import { RemoteCompetitionDocument, RemoteWebhooksDocument } from '@/lib/notifyCompRemoteGraphql'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { + NotifyCompRemoteAuthContext, + NotifyCompRemoteAuthContextValue, +} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext'; +import { storybookCompetitionFixture } from '@/storybook/competitionFixtures'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import CompetitionRemoteWebhooks from './Webhooks'; + +const competitionId = storybookCompetitionFixture.id; + +const remoteAdminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const remoteAuthContext: NotifyCompRemoteAuthContextValue = { + authenticating: false, + error: null, + isAuthenticated: true, + isAuthenticatedForCompetition: () => true, + signIn: async () => {}, + signOut: () => {}, + userName: remoteAdminUser.name, +}; + +const webhookMocks: MockedResponse[] = [ + { + request: { + query: RemoteCompetitionDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + autoAdvance: true, + autoAdvanceDelay: null, + }, + }, + }, + }, + { + request: { + query: RemoteWebhooksDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + webhooks: [ + { + id: 1, + method: 'POST', + url: 'https://hooks.example.com/live-activities', + }, + { + id: 2, + method: 'GET', + url: 'https://status.example.com/ping', + }, + ], + }, + }, + }, + }, +]; + +function WebhookPageStoryProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} + +const meta = { + title: 'Pages/Competition/Remote/Webhooks', + component: CompetitionRemoteWebhooks, + decorators: [ + (Story) => ( + + + + ), + makeRouteDecorator({ + initialPath: `/competitions/${competitionId}/admin/webhooks`, + routePath: '/competitions/:competitionId/admin/webhooks', + }), + makeCompetitionContainerDecorator({ + currentUser: remoteAdminUser, + }), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WebhookEditor: Story = {}; diff --git a/src/pages/Competition/Remote/Webhooks.test.tsx b/src/pages/Competition/Remote/Webhooks.test.tsx new file mode 100644 index 0000000..f6dccad --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.test.tsx @@ -0,0 +1,124 @@ +import { useQuery } from '@apollo/client'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import CompetitionRemoteWebhooks from './Webhooks'; + +jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), + useQuery: jest.fn(), +})); +jest.mock('@/lib/competitionAuthorization', () => ({ + isCompetitionDelegateOrOrganizer: jest.fn(), +})); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); +jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ + useNotifyCompRemoteAuth: jest.fn(), +})); +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: jest.fn(), +})); +jest.mock('./RemoteWebhookSetup', () => ({ + RemoteWebhookSetup: ({ competitionId }: { competitionId: string }) => ( +
Webhook editor for {competitionId}
+ ), +})); + +const wcif = { + persons: [], + schedule: { + numberOfDays: 1, + startDate: '2026-06-01', + venues: [], + }, +}; + +const renderWebhooksPage = () => { + render( + + + } + /> + + , + ); +}; + +describe('CompetitionRemoteWebhooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useWCIF).mockReturnValue({ + competitionId: 'ExampleComp2026', + setTitle: jest.fn(), + wcif, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(isCompetitionDelegateOrOrganizer).mockReturnValue(true); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + isAuthenticatedForCompetition: () => true, + signIn: jest.fn(), + } as unknown as ReturnType); + jest.mocked(useQuery).mockReturnValue({ + data: { + competition: { + id: 'ExampleComp2026', + }, + }, + error: null, + loading: false, + } as unknown as ReturnType); + }); + + it('asks users to authenticate before managing webhooks', () => { + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + isAuthenticatedForCompetition: () => false, + signIn: jest.fn(), + } as unknown as ReturnType); + + renderWebhooksPage(); + + expect( + screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument(); + }); + + it('asks users to import the competition before managing webhooks', () => { + jest.mocked(useQuery).mockReturnValue({ + data: { + competition: null, + }, + error: null, + loading: false, + } as unknown as ReturnType); + + renderWebhooksPage(); + + expect(screen.getByText(/Not imported/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to Remote' })).toHaveAttribute( + 'href', + '/competitions/ExampleComp2026/admin/remote', + ); + expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument(); + }); + + it('renders the webhook editor for imported competitions', () => { + renderWebhooksPage(); + + expect(screen.getByRole('heading', { name: 'Webhooks' })).toBeInTheDocument(); + expect(screen.getByText('Webhook editor for ExampleComp2026')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/Webhooks.tsx b/src/pages/Competition/Remote/Webhooks.tsx new file mode 100644 index 0000000..8c95c03 --- /dev/null +++ b/src/pages/Competition/Remote/Webhooks.tsx @@ -0,0 +1,107 @@ +import { useQuery } from '@apollo/client'; +import { useEffect } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { BarLoader } from 'react-spinners'; +import { Button } from '@/components/Button'; +import { Container } from '@/components/Container'; +import { NoteBox } from '@/components/Notebox'; +import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization'; +import { NotifyCompCompetition, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import { RemoteWebhookSetup } from './RemoteWebhookSetup'; + +export default function CompetitionRemoteWebhooks() { + const { competitionId } = useParams<{ competitionId: string }>(); + const { wcif, setTitle } = useWCIF(); + const { user } = useAuth(); + const remoteAuth = useNotifyCompRemoteAuth(); + const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, user); + const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId || ''); + const canLoadRemoteCompetition = Boolean(competitionId && isAuthenticated && canManageRemote); + + const remoteCompetitionQuery = useQuery<{ competition: NotifyCompCompetition | null }>( + RemoteCompetitionDocument, + { + variables: { competitionId }, + skip: !canLoadRemoteCompetition, + }, + ); + + useEffect(() => { + setTitle('Webhooks'); + }, [setTitle]); + + if (!competitionId || !wcif) { + return null; + } + + const accessDenied = (Boolean(user) || isAuthenticated) && !canManageRemote; + + return ( + +
+
+

Webhooks

+

+ Send Live Activities updates to another service when activities start or stop. +

+ + Back to Live Activities Remote + +
+ + {remoteAuth.error && } + + {accessDenied ? ( + + ) : !isAuthenticated ? ( +
+

+ Sign in to Live Activities Remote with your WCA account before managing webhooks. +

+ +
+ ) : ( + <> + {remoteCompetitionQuery.loading && } + {remoteCompetitionQuery.error && ( + + )} + + {!remoteCompetitionQuery.loading && !remoteCompetitionQuery.data?.competition ? ( +
+ + + Go to Remote + +
+ ) : ( + remoteCompetitionQuery.data?.competition && ( + + ) + )} + + )} +
+
+ ); +} diff --git a/src/pages/Competition/Remote/index.stories.tsx b/src/pages/Competition/Remote/index.stories.tsx new file mode 100644 index 0000000..b168e99 --- /dev/null +++ b/src/pages/Competition/Remote/index.stories.tsx @@ -0,0 +1,126 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ReactNode, useEffect } from 'react'; +import { RemoteActivitiesDocument, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql'; +import { setNotifyCompWebSocketStatus } from '@/lib/notifyCompWebSocketStatus'; +import { ConfirmProvider } from '@/providers/ConfirmProvider'; +import { + NotifyCompRemoteAuthContext, + NotifyCompRemoteAuthContextValue, +} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext'; +import { + storybookCompetitionFixture, + storybookOngoingActivitiesFixture, +} from '@/storybook/competitionFixtures'; +import { + makeCompetitionContainerDecorator, + makeRouteDecorator, +} from '@/storybook/competitionStorybook'; +import CompetitionRemote from './index'; + +const competitionId = storybookCompetitionFixture.id; + +const remoteAdminUser: User = { + id: 9001, + name: 'Nick Silvestri', + email: '', + wca_id: '2016SILV08', + avatar: { + url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4', + thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf', + }, + delegate_status: 'delegate', +}; + +const remoteAuthContext: NotifyCompRemoteAuthContextValue = { + authenticating: false, + error: null, + isAuthenticated: true, + isAuthenticatedForCompetition: () => true, + signIn: async () => {}, + signOut: () => {}, + userName: remoteAdminUser.name, +}; + +const remoteMocks: MockedResponse[] = [ + { + request: { + query: RemoteCompetitionDocument, + variables: { + competitionId, + }, + }, + result: { + data: { + competition: { + id: competitionId, + autoAdvance: true, + autoAdvanceDelay: null, + }, + }, + }, + }, + { + request: { + query: RemoteActivitiesDocument, + variables: { + competitionId, + roomId: undefined, + }, + }, + result: { + data: { + activities: storybookOngoingActivitiesFixture, + }, + }, + }, +]; + +function RemoteStoryProviders({ children }: { children: ReactNode }) { + useEffect(() => { + setNotifyCompWebSocketStatus({ status: 'connected' }); + + return () => { + setNotifyCompWebSocketStatus({ status: 'idle' }); + }; + }, []); + + return ( + + + + {children} + + + + ); +} + +const meta = { + title: 'Pages/Competition/Remote', + component: CompetitionRemote, + decorators: [ + (Story) => ( + + + + ), + makeRouteDecorator({ + initialPath: `/competitions/${competitionId}/admin/remote`, + routePath: '/competitions/:competitionId/admin/remote', + }), + makeCompetitionContainerDecorator({ + currentUser: remoteAdminUser, + }), + ], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const LiveActivitiesRemote: Story = {}; diff --git a/src/pages/Competition/Remote/index.test.tsx b/src/pages/Competition/Remote/index.test.tsx new file mode 100644 index 0000000..1a56d68 --- /dev/null +++ b/src/pages/Competition/Remote/index.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; +import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; +import { useAuth } from '@/providers/AuthProvider'; +import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider'; +import { useWCIF } from '@/providers/WCIFProvider'; +import CompetitionRemote from './index'; + +jest.mock('@/hooks/useCompetitionRemoteControl', () => ({ + useCompetitionRemoteControl: jest.fn(), +})); +jest.mock('@/hooks/useNotifyCompWebSocketStatus', () => ({ + useNotifyCompWebSocketStatus: jest.fn(), +})); +jest.mock('./RemoteActivityList', () => ({ + RemoteGroupList: () =>
, +})); +jest.mock('@/providers/AuthProvider', () => ({ + useAuth: jest.fn(), +})); +jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({ + useNotifyCompRemoteAuth: jest.fn(), +})); +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: jest.fn(), +})); + +const wcif = { + persons: [], + schedule: { + numberOfDays: 1, + startDate: '2026-06-01', + venues: [], + }, +}; + +const remoteDefaults = { + activities: [], + activityGroups: [], + autoAdvance: false, + canManageRemote: true, + error: null, + importCompetition: jest.fn(), + isAuthenticated: true, + isLoading: false, + isSaving: false, + nextGroup: null, + scheduledActivities: [], +}; + +const renderRemote = () => { + render( + + + } /> + + , + ); +}; + +describe('CompetitionRemote webhook management link', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useWCIF).mockReturnValue({ + setTitle: jest.fn(), + wcif, + } as unknown as ReturnType); + jest.mocked(useAuth).mockReturnValue({ + user: { id: 1 }, + } as unknown as ReturnType); + jest.mocked(useNotifyCompWebSocketStatus).mockReturnValue({ + status: 'connected', + } as ReturnType); + jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({ + authenticating: false, + error: null, + signIn: jest.fn(), + } as unknown as ReturnType); + }); + + it('does not show webhook management before Remote authentication', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + isAuthenticated: false, + } as unknown as ReturnType); + + renderRemote(); + + expect( + screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument(); + }); + + it('does not show webhook management before the competition is imported', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + competition: null, + } as unknown as ReturnType); + + renderRemote(); + + expect(screen.getByText('Import schedule')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument(); + }); + + it('links to webhook management after the competition is imported', () => { + jest.mocked(useCompetitionRemoteControl).mockReturnValue({ + ...remoteDefaults, + competition: { + id: 'ExampleComp2026', + }, + } as unknown as ReturnType); + + renderRemote(); + + expect(screen.getByRole('link', { name: 'Manage webhooks' })).toHaveAttribute( + 'href', + '/competitions/ExampleComp2026/admin/webhooks', + ); + expect(screen.queryByRole('heading', { name: 'Webhook setup' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx index 10c27cb..007f250 100644 --- a/src/pages/Competition/Remote/index.tsx +++ b/src/pages/Competition/Remote/index.tsx @@ -2,13 +2,11 @@ import classNames from 'classnames'; import { useEffect } from 'react'; import { Link, useParams } from 'react-router-dom'; import { BarLoader } from 'react-spinners'; -import { - Button, - Container, - NoteBox, - NotifyCompConnectionStatus, - RemoteActivitySummaryList, -} from '@/components'; +import { Button } from '@/components/Button'; +import { Container } from '@/components/Container'; +import { NoteBox } from '@/components/Notebox'; +import { NotifyCompConnectionStatus } from '@/components/NotifyCompConnectionStatus'; +import { RemoteActivitySummaryList } from '@/components/RemoteActivitySummaryList'; import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl'; import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus'; import { isCompetitionDayOrAfter } from '@/lib/competitionDates'; @@ -166,7 +164,24 @@ export default function CompetitionRemote() { <> {remote.isLoading && } {remote.error && } - {remote.competition && } + {remote.competition && ( +
+ +
+
+

Webhooks

+

+ Configure external Live Activities update webhooks on a separate page. +

+
+ + Manage webhooks + +
+
+ )} {!remote.isLoading && !remote.competition ? (
From 2830e9606f55274dbf01f03245d1dbc5e2200de8 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 29 May 2026 17:13:25 -0700 Subject: [PATCH 3/3] Update round results link placement Move the results link below group links, style it by result availability, and use the shared LinkButton for the back link so button padding is consistent. --- .../CompetitionRound.test.tsx | 40 +++++++++++++++++-- .../CompetitionRound/CompetitionRound.tsx | 27 +++++++------ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index 7e627dc..b949b53 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -57,6 +57,10 @@ jest.mock('react-i18next', () => ({ return `Round ${options?.roundNumber}`; } + if (key === 'common.activityCodeToName.group') { + return `Group ${options?.groupNumber}`; + } + return key; }, }), @@ -89,7 +93,15 @@ const linkedRoundsCompetition = { cutoff: null, timeLimit: null, advancementCondition: null, - results: [], + results: [ + { + personId: 1, + ranking: 1, + attempts: [], + best: 1000, + average: 1200, + }, + ], }, { id: '333-r3', @@ -195,9 +207,29 @@ describe('CompetitionRoundContainer', () => { it('links to results for the selected round', () => { renderRound('333-r2'); - expect(screen.getByText('See Results')).toHaveAttribute( - 'href', - '/competitions/TestComp2026/results/333-r2', + const resultsLink = screen.getByText('See Results'); + + expect(resultsLink).toHaveAttribute('href', '/competitions/TestComp2026/results/333-r2'); + expect(screen.getByText('Group 1').compareDocumentPosition(resultsLink)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, ); }); + + it('uses shared button spacing for the back link', () => { + renderRound('333-r2'); + + expect(screen.getByText('Back To Events')).toHaveClass('btn', 'btn-block'); + }); + + it('shows when the selected round has results', () => { + renderRound('333-r2'); + + expect(screen.getByText('See Results')).toHaveClass('btn-green'); + }); + + it('shows a neutral results link when the selected round has no results yet', () => { + renderRound('333-r3'); + + expect(screen.getByText('See Results')).toHaveClass('btn-light'); + }); }); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index 16e5201..eb3c87e 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -60,6 +60,7 @@ export function CompetitionRoundContainer({ const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); const groups = rounds.flatMap((r) => r.childActivities); const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))]; + const hasResults = (round?.results.length ?? 0) > 0; return ( @@ -79,14 +80,6 @@ export function CompetitionRoundContainer({

)} {round && } - {round && ( - - )}
    @@ -111,12 +104,22 @@ export function CompetitionRoundContainer({ ); })}
+ {round && ( +
+ +
+ )}
- - {t('competition.groups.backToEvents')} - + title={t('competition.groups.backToEvents')} + LinkComponent={LinkComponent} + />
);