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
12 changes: 12 additions & 0 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
67 changes: 67 additions & 0 deletions src/apps/opportunities/src/components/ChallengeForum.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(<ChallengeForum challenge={{ id: 'challenge-id', name: 'Challenge' }} memberId='10' />)
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(<ChallengeForum challenge={{ id: 'challenge-id', name: 'Challenge' }} memberId='10' />)
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(<ChallengeForum challenge={{ id: 'challenge-id', name: 'Challenge' }} memberId='10' />)
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(<ChallengeForum challenge={{ id: 'challenge-id', name: 'Challenge' }} memberId='10' />)
expect(screen.getByRole('button', { name: 'Watch forum' }))
.toBeDisabled()
watchError = new Error('Unavailable')
view.rerender(<ChallengeForum challenge={{ id: 'challenge-id', name: 'Challenge' }} memberId='10' />)
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],
Expand Down
55 changes: 52 additions & 3 deletions src/apps/opportunities/src/components/ChallengeForum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { uploadForumAttachment } from '../services/forum-attachments.service'
import { ForumIcon } from './ForumIcon'

import {
ChallengeForumWatchState,
ChallengeOpportunity,
ForumPost,
ForumPostReaction,
Expand All @@ -21,9 +22,11 @@ import {
deleteForumPost,
deleteForumTopic,
getChallengeForumTopics,
getChallengeForumWatch,
getForumTopicDetail,
getMemberProfilesByUserIds,
markForumTopicRead,
setChallengeForumWatching,
setForumPostReaction,
setForumTopicWatching,
updateForumPost,
Expand Down Expand Up @@ -368,14 +371,17 @@ const ForumFallback: FC<ForumFallbackProps> = 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.
*/
const ForumOverview: FC<{
canCreateAnnouncements: boolean
onCreate: () => void
onCreateAnnouncement: () => void
onWatch: () => void
watchDisabled: boolean
watching?: boolean
topics: ForumTopicSummary[]
total: number
}> = props => {
Expand Down Expand Up @@ -404,6 +410,15 @@ const ForumOverview: FC<{
</span>
</div>
<div className={styles.overviewActions}>
<button
aria-pressed={props.watching ?? false}
disabled={props.watchDisabled}
onClick={props.onWatch}
type='button'
>
<IconOutline.EyeIcon aria-hidden='true' />
{props.watching ? 'Unwatch forum' : 'Watch forum'}
</button>
<button onClick={props.onCreate} type='button'>
<IconOutline.PlusCircleIcon aria-hidden='true' />
Create new topic
Expand Down Expand Up @@ -1724,8 +1739,8 @@ function resetForumScroll(): void {
*
* The component keeps every communication workflow inside Opportunities:
* topic create/edit/delete, nested replies, post edit/delete, reaction toggles,
* watch state, and read state. A legacy link is retained only as recovery when
* the v6 API cannot be reached or the member is signed out.
* topic and challenge-wide watch preferences, and read state. A legacy link is
* retained only as recovery when the v6 API cannot be reached or the member is signed out.
*
* @param props challenge context, optional member ID, and administrator announcement/delete access.
* @returns embedded topic list, creation form, detail discussion, or recovery state.
Expand All @@ -1750,6 +1765,11 @@ export const ChallengeForum: FC<ChallengeForumProps> = props => {
() => getChallengeForumTopics(props.challenge.id),
{ revalidateOnFocus: false },
)
const watchResponse: SWRResponse<ChallengeForumWatchState, Error> = useSWR(
props.memberId ? ['opportunities:forum-watch', props.challenge.id, props.memberId] : undefined,
() => getChallengeForumWatch(props.challenge.id),
{ revalidateOnFocus: false },
)
const detailResponse: SWRResponse<ForumTopicDetail, Error> = useSWR(
selectedTopicId ? ['opportunities:forum-topic', selectedTopicId] : undefined,
() => getForumTopicDetail(selectedTopicId as string),
Expand Down Expand Up @@ -1955,6 +1975,25 @@ export const ChallengeForum: FC<ChallengeForumProps> = props => {
}
}

/**
* Persists the overview's forum-wide preference and refreshes its displayed state.
* @returns Nothing after the save and cache update finish.
* @throws Does not throw; failures preserve the previous state and show an error.
*/
const toggleForumWatch = async (): Promise<void> => {
if (!watchResponse.data || pendingAction === 'forum-watch') return
setPendingAction('forum-watch')
setMutationError(undefined)
try {
const saved = await setChallengeForumWatching(props.challenge.id, !watchResponse.data.watching)
await watchResponse.mutate(saved, false)
} catch (error) {
setMutationError(forumErrorMessage(error))
} finally {
setPendingAction(undefined)
}
}

if (!props.memberId) {
return (
<ForumFallback
Expand Down Expand Up @@ -2039,6 +2078,9 @@ export const ChallengeForum: FC<ChallengeForumProps> = props => {
<div className={styles.forumLayout}>
<aside className={styles.leftPanel}>
<ForumOverview
onWatch={toggleForumWatch}
watchDisabled={!watchResponse.data || !!watchResponse.error || pendingAction === 'forum-watch'}
watching={watchResponse.data?.watching}
canCreateAnnouncements={!!props.canCreateAnnouncements}
onCreate={() => {
setMutationError(undefined)
Expand Down Expand Up @@ -2075,6 +2117,13 @@ export const ChallengeForum: FC<ChallengeForumProps> = props => {
<DiscussionInfo profilesByMemberId={profilesByMemberId} topics={topics} />
</aside>
<section aria-label='Forum topics' className={styles.topicList}>
{watchResponse.error && (
<p className={styles.actionError} role='alert'>
Forum notification preferences could not be loaded.
{' '}
<button onClick={() => watchResponse.mutate()} type='button'>Retry</button>
</p>
)}
{mutationError && (
<p className={styles.actionError} role='alert'>
{mutationError}
Expand Down
7 changes: 7 additions & 0 deletions src/apps/opportunities/src/models/forum.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 15 additions & 0 deletions src/apps/opportunities/src/services/forum.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
deleteForumPost,
deleteForumTopic,
getChallengeForumTopics,
getChallengeForumWatch,
getForumTopicDetail,
markForumTopicRead,
setChallengeForumWatching,
setForumPostReaction,
setForumTopicWatching,
updateForumPost,
Expand Down Expand Up @@ -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')
Expand Down
30 changes: 30 additions & 0 deletions src/apps/opportunities/src/services/forum.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '~/libs/core'

import {
ChallengeForumWatchState,
CreateForumPostRequest,
CreateForumTopicRequest,
CreateForumTopicResponse,
Expand Down Expand Up @@ -275,3 +276,32 @@ export async function markForumTopicRead(topicId: string): Promise<void> {
{},
)
}

/**
* 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<ChallengeForumWatchState> {
return xhrGetAsync<ChallengeForumWatchState>(
`${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<ChallengeForumWatchState> {
const url = `${FORUMS_URL}/challenges/${encodeURIComponent(challengeId)}/watch`
return watching
? xhrPutAsync<Record<string, never>, ChallengeForumWatchState>(url, {})
: xhrDeleteAsync<ChallengeForumWatchState>(url)
}
Loading