From d84733fd4f49411302f7d0baaee3fdfa59b15590 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 16 Oct 2026 06:20:25 +1100 Subject: [PATCH] PM-6594: Add challenge forum watch controls What was broken Members could watch individual topics but could not change notification preferences for the entire challenge forum, including future topics. Root cause The forum overview exposed only topic-specific watch actions and had no challenge-wide preference API integration. What was changed Add Watch forum / Unwatch forum to the challenge overview, including empty forums. Load the current member's effective preference and persist choices through the companion Forums API endpoints. Disable unavailable/pending controls, preserve state after failed saves, and show a retry for failed reads. Document the API and migration deployment dependency. Any added/updated tests Add service routing and component regressions for opt-out/re-watch, empty forums, failed saves, and loading/read errors. The 49 forum tests and lint pass. The full UI suite reports 387 passing and 22 failing suites; 21 failures reproduce on unchanged origin/dev, and the remaining suite passes in isolation on both branches. The production build passes with a 12 GB Node heap. --- src/apps/opportunities/README.md | 12 ++++ .../src/components/ChallengeForum.spec.tsx | 67 +++++++++++++++++++ .../src/components/ChallengeForum.tsx | 55 ++++++++++++++- .../opportunities/src/models/forum.models.ts | 7 ++ .../src/services/forum.service.spec.ts | 15 +++++ .../src/services/forum.service.ts | 30 +++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 10d4e2dc9..dbc402196 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -886,3 +886,15 @@ The shared toolbar includes configured Filestack attachments through Figma grouping (dividers between formatting, heading, size, alignment, list, insert and expand controls, plus the size chevron) lives in the shared editor styles, so challenge discussion composers match the public forums. + +### Challenge forum notifications + +The Challenge Forum overview includes a Watch forum / Unwatch forum control, +including for empty forums. It reads the authenticated member's effective +preference from `GET /v6/forums/challenges/:challengeId/watch` and saves choices +with PUT or DELETE on that route. The Forums API defaults registrants and +copilots to watching based on their challenge resource assignments and persists +explicit opt-outs. Topic-specific watches remain independent. Loading and failed +reads disable the control; failed saves preserve the displayed preference and +show the API error. This requires the Forums API challenge-watch migration and +endpoints to be deployed first. diff --git a/src/apps/opportunities/src/components/ChallengeForum.spec.tsx b/src/apps/opportunities/src/components/ChallengeForum.spec.tsx index 7ff8aee76..59667495e 100644 --- a/src/apps/opportunities/src/components/ChallengeForum.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeForum.spec.tsx @@ -38,6 +38,10 @@ const mockGetForumTopicDetail = jest.fn() const mockMarkForumTopicRead = jest.fn() const mockSetForumPostReaction = jest.fn() const mockSetForumTopicWatching = jest.fn() +const mockSetChallengeForumWatching = jest.fn() +const mockMutateForumWatch = jest.fn() +let forumWatching: boolean | undefined +let watchError: Error | undefined const mockUseSWR = jest.fn() const mockUpdateForumPost = jest.fn() const mockUpdateForumTopic = jest.fn() @@ -101,9 +105,11 @@ jest.mock('../services', () => ({ deleteForumPost: (...args: unknown[]) => mockDeleteForumPost(...args), deleteForumTopic: (...args: unknown[]) => mockDeleteForumTopic(...args), getChallengeForumTopics: jest.fn(), + getChallengeForumWatch: jest.fn(), getForumTopicDetail: (...args: unknown[]) => mockGetForumTopicDetail(...args), getMemberProfilesByUserIds: jest.fn(), markForumTopicRead: (...args: unknown[]) => mockMarkForumTopicRead(...args), + setChallengeForumWatching: (...args: unknown[]) => mockSetChallengeForumWatching(...args), setForumPostReaction: (...args: unknown[]) => mockSetForumPostReaction(...args), setForumTopicWatching: (...args: unknown[]) => mockSetForumTopicWatching(...args), updateForumPost: (...args: unknown[]) => mockUpdateForumPost(...args), @@ -231,7 +237,22 @@ describe('ChallengeForum', () => { } topicDetail = { posts: [starterPost], topic: announcement } mockGetForumTopicDetail.mockResolvedValue(topicDetail) + forumWatching = true + watchError = undefined + mockSetChallengeForumWatching.mockReset() + mockMutateForumWatch.mockReset() mockUseSWR.mockImplementation((key: unknown) => { + if (Array.isArray(key) && key[0] === 'opportunities:forum-watch') { + return { + data: forumWatching === undefined ? undefined : { + challengeId: 'challenge-id', memberId: '10', watching: forumWatching, + }, + error: watchError, + isValidating: false, + mutate: mockMutateForumWatch, + } + } + if (Array.isArray(key) && key[0] === 'opportunities:forum-topics') { return { data: topicCollection, @@ -315,6 +336,52 @@ describe('ChallengeForum', () => { .not.toBeInTheDocument() }) + it('unwatches an empty forum and displays the saved preference', async () => { + topicCollection = { data: [], sourceTotalCount: 0, truncated: false } + const saved = { challengeId: 'challenge-id', memberId: '10', watching: false } + mockSetChallengeForumWatching.mockResolvedValue(saved) + const view = render() + const button = screen.getByRole('button', { name: 'Unwatch forum' }) + expect(button) + .toHaveAttribute('aria-pressed', 'true') + fireEvent.click(button) + await waitFor(() => expect(mockMutateForumWatch) + .toHaveBeenCalledWith(saved, false)) + expect(mockSetChallengeForumWatching) + .toHaveBeenCalledWith('challenge-id', false) + forumWatching = false + view.rerender() + expect(screen.getByRole('button', { name: 'Watch forum' })) + .toHaveAttribute('aria-pressed', 'false') + fireEvent.click(screen.getByRole('button', { name: 'Watch forum' })) + await waitFor(() => expect(mockSetChallengeForumWatching) + .toHaveBeenCalledWith('challenge-id', true)) + }) + + it('preserves the watch preference when saving fails', async () => { + mockSetChallengeForumWatching.mockRejectedValue(new Error('Unable to save preference')) + render() + fireEvent.click(screen.getByRole('button', { name: 'Unwatch forum' })) + await screen.findByText('Unable to save preference') + expect(mockMutateForumWatch).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: 'Unwatch forum' })) + .toBeEnabled() + }) + + it('disables the watch control while its preference is loading or unavailable', () => { + forumWatching = undefined + const view = render() + expect(screen.getByRole('button', { name: 'Watch forum' })) + .toBeDisabled() + watchError = new Error('Unavailable') + view.rerender() + expect(screen.getByRole('button', { name: 'Watch forum' })) + .toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mockMutateForumWatch) + .toHaveBeenCalled() + }) + it('uses the API source total for topic counters', () => { topicCollection = { data: [announcement, discussion], diff --git a/src/apps/opportunities/src/components/ChallengeForum.tsx b/src/apps/opportunities/src/components/ChallengeForum.tsx index 9e1e9b687..cd455ffbd 100644 --- a/src/apps/opportunities/src/components/ChallengeForum.tsx +++ b/src/apps/opportunities/src/components/ChallengeForum.tsx @@ -7,6 +7,7 @@ import { uploadForumAttachment } from '../services/forum-attachments.service' import { ForumIcon } from './ForumIcon' import { + ChallengeForumWatchState, ChallengeOpportunity, ForumPost, ForumPostReaction, @@ -21,9 +22,11 @@ import { deleteForumPost, deleteForumTopic, getChallengeForumTopics, + getChallengeForumWatch, getForumTopicDetail, getMemberProfilesByUserIds, markForumTopicRead, + setChallengeForumWatching, setForumPostReaction, setForumTopicWatching, updateForumPost, @@ -368,7 +371,7 @@ const ForumFallback: FC = props => ( /** * Renders challenge forum counters and the available in-page creation actions. * - * @param props visible topics, source total, ordinary creation, and administrator announcement creation. + * @param props visible topics, counts, creation actions, and challenge-wide watch state/control. * @returns authored forum overview rail. * @throws Does not throw. */ @@ -376,6 +379,9 @@ const ForumOverview: FC<{ canCreateAnnouncements: boolean onCreate: () => void onCreateAnnouncement: () => void + onWatch: () => void + watchDisabled: boolean + watching?: boolean topics: ForumTopicSummary[] total: number }> = props => { @@ -404,6 +410,15 @@ const ForumOverview: FC<{
+ +

+ )} {mutationError && (

{mutationError} diff --git a/src/apps/opportunities/src/models/forum.models.ts b/src/apps/opportunities/src/models/forum.models.ts index 99af8743a..960f9fbc5 100644 --- a/src/apps/opportunities/src/models/forum.models.ts +++ b/src/apps/opportunities/src/models/forum.models.ts @@ -138,3 +138,10 @@ export interface ForumWatchMutationResponse { topicId: string watching?: boolean } + +/** Effective current-member watch state for an entire challenge forum. */ +export interface ChallengeForumWatchState { + challengeId: string + memberId: string + watching: boolean +} diff --git a/src/apps/opportunities/src/services/forum.service.spec.ts b/src/apps/opportunities/src/services/forum.service.spec.ts index 6aceb0fac..5f6a94700 100644 --- a/src/apps/opportunities/src/services/forum.service.spec.ts +++ b/src/apps/opportunities/src/services/forum.service.spec.ts @@ -5,8 +5,10 @@ import { deleteForumPost, deleteForumTopic, getChallengeForumTopics, + getChallengeForumWatch, getForumTopicDetail, markForumTopicRead, + setChallengeForumWatching, setForumPostReaction, setForumTopicWatching, updateForumPost, @@ -34,6 +36,19 @@ jest.mock('~/libs/core', () => ({ describe('forum service', () => { beforeEach(() => jest.clearAllMocks()) + it('reads and persists challenge-wide watch choices without impersonation fields', async () => { + await getChallengeForumWatch('challenge/id') + await setChallengeForumWatching('challenge/id', true) + await setChallengeForumWatching('challenge/id', false) + const url = 'https://api.example/v6/forums/challenges/challenge%2Fid/watch' + expect(mockXhrGetAsync) + .toHaveBeenCalledWith(url) + expect(mockXhrPutAsync) + .toHaveBeenCalledWith(url, {}) + expect(mockXhrDeleteAsync) + .toHaveBeenCalledWith(url) + }) + it('builds an encoded, bounded challenge topic page URL', () => { expect(challengeForumTopicsUrl('challenge/id', 0, 0)) .toBe('https://api.example/v6/forums/topics/challenges/challenge%2Fid?page=1&perPage=1') diff --git a/src/apps/opportunities/src/services/forum.service.ts b/src/apps/opportunities/src/services/forum.service.ts index b4a015f77..443d91fdb 100644 --- a/src/apps/opportunities/src/services/forum.service.ts +++ b/src/apps/opportunities/src/services/forum.service.ts @@ -8,6 +8,7 @@ import { } from '~/libs/core' import { + ChallengeForumWatchState, CreateForumPostRequest, CreateForumTopicRequest, CreateForumTopicResponse, @@ -275,3 +276,32 @@ export async function markForumTopicRead(topicId: string): Promise { {}, ) } + +/** + * Loads the current member's effective challenge-wide watch preference. + * @param challengeId challenge whose forum preference is requested. + * @returns persisted preference or default derived from registration/copilot assignment. + * @throws Propagates authentication, authorization, and network errors. + */ +export function getChallengeForumWatch(challengeId: string): Promise { + return xhrGetAsync( + `${FORUMS_URL}/challenges/${encodeURIComponent(challengeId)}/watch`, + ) +} + +/** + * Persists the current member's watch preference for existing and future forum topics. + * @param challengeId challenge whose forum preference is updated. + * @param watching true to watch, false to persist an opt-out from default notifications. + * @returns saved challenge-wide watch state. + * @throws Propagates authentication, authorization, and network errors. + */ +export function setChallengeForumWatching( + challengeId: string, + watching: boolean, +): Promise { + const url = `${FORUMS_URL}/challenges/${encodeURIComponent(challengeId)}/watch` + return watching + ? xhrPutAsync, ChallengeForumWatchState>(url, {}) + : xhrDeleteAsync(url) +}