diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index 732469e3d1cb..197ffb2d6929 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -2,7 +2,7 @@ import {useTelemetry} from '@sanity/telemetry/react' import {useCallback, useMemo, useState} from 'react' import {useObservable} from 'react-rx' -import {catchError, map, type Observable, startWith} from 'rxjs' +import {catchError, combineLatest, map, type Observable, startWith} from 'rxjs' import {StudioAnnouncementContext} from 'sanity/_singletons' import {useClient} from '../../hooks/useClient' @@ -36,12 +36,15 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const telemetry = useTelemetry() const [dialogMode, setDialogMode] = useState(null) const [isCardDismissed, setIsCardDismissed] = useState(false) - const [seenAnnouncements, setSeenAnnouncements] = useSeenAnnouncements() + const [seenAnnouncements$, setSeenAnnouncements] = useSeenAnnouncements() const {currentUser} = useWorkspace() const client = useClient(CLIENT_OPTIONS) - const fetchAnnouncements: Observable = useMemo(() => { - return client.observable + const getAnnouncements$: Observable<{ + unseen: StudioAnnouncementDocument[] + all: StudioAnnouncementDocument[] + }> = useMemo(() => { + const allAnnouncements$ = client.observable .request({url: '/journey/announcements'}) .pipe( map((docs) => { @@ -58,20 +61,20 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi catchError(() => []), startWith([]), ) - }, [client, currentUser?.roles]) - const studioAnnouncements = useObservable(fetchAnnouncements, []) - const unseenAnnouncements: StudioAnnouncementDocument[] = useMemo(() => { - // If it's loading or it has errored return an empty array to avoid showing the card - if (seenAnnouncements.loading || seenAnnouncements.error) return [] - // If none is seen, return all the announcements - if (!seenAnnouncements.value) return studioAnnouncements - - // Filter out the seen announcements - const unseen = studioAnnouncements.filter((doc) => !seenAnnouncements.value?.includes(doc._id)) - - return unseen - }, [seenAnnouncements, studioAnnouncements]) + return combineLatest([allAnnouncements$, seenAnnouncements$]).pipe( + map(([all, seen]) => { + if (seen.loading || seen.error) return {unseen: [], all: all} + if (!seen.value) return {unseen: all, all: all} + const unseen = all.filter((doc) => !seen.value?.includes(doc._id)) + return {unseen: unseen, all: all} + }), + ) + }, [client.observable, currentUser?.roles, seenAnnouncements$]) + + const announcements = useObservable(getAnnouncements$, {unseen: [], all: []}) + const unseenAnnouncements = announcements.unseen + const studioAnnouncements = announcements.all const saveSeenAnnouncements = useCallback(() => { // Mark all the announcements as seen diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx index f3944fe1321f..a5e9fcf1d491 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -94,7 +94,10 @@ describe('StudioAnnouncementsProvider', () => { describe('if seen announcements is loading', () => { beforeEach(() => { jest.clearAllMocks() - seenAnnouncementsMock.mockReturnValue([{loading: true, value: null, error: null}, jest.fn()]) + seenAnnouncementsMock.mockReturnValue([ + of({loading: true, value: null, error: null}), + jest.fn(), + ]) mockClient(mockAnnouncements) }) test('returns empty unseen announcements ', () => { @@ -114,7 +117,7 @@ describe('StudioAnnouncementsProvider', () => { beforeEach(() => { jest.clearAllMocks() seenAnnouncementsMock.mockReturnValue([ - {loading: false, value: null, error: new Error('something went wrong')}, + of({loading: false, value: null, error: new Error('something went wrong')}), jest.fn(), ]) mockClient(mockAnnouncements) @@ -135,7 +138,10 @@ describe('StudioAnnouncementsProvider', () => { describe('if seen announcements is not loading and has no values', () => { beforeEach(() => { jest.clearAllMocks() - seenAnnouncementsMock.mockReturnValue([{value: [], error: null, loading: false}, jest.fn()]) + seenAnnouncementsMock.mockReturnValue([ + of({value: [], error: null, loading: false}), + jest.fn(), + ]) mockClient(mockAnnouncements) }) test('returns unseen announcements', () => { @@ -157,7 +163,7 @@ describe('StudioAnnouncementsProvider', () => { jest.clearAllMocks() // It doesn't show the first element seenAnnouncementsMock.mockReturnValue([ - {value: ['studioAnnouncement-1'], error: null, loading: false}, + of({value: ['studioAnnouncement-1'], error: null, loading: false}), jest.fn(), ]) @@ -182,7 +188,7 @@ describe('StudioAnnouncementsProvider', () => { jest.clearAllMocks() // It doesn't show the first element seenAnnouncementsMock.mockReturnValue([ - {value: ['studioAnnouncement-1'], error: null, loading: false}, + of({value: ['studioAnnouncement-1'], error: null, loading: false}), jest.fn(), ]) @@ -428,7 +434,10 @@ describe('StudioAnnouncementsProvider', () => { beforeEach(() => { jest.clearAllMocks() // It doesn't show the first element - seenAnnouncementsMock.mockReturnValue([{value: [], error: null, loading: false}, jest.fn()]) + seenAnnouncementsMock.mockReturnValue([ + of({value: [], error: null, loading: false}), + jest.fn(), + ]) }) test('if the audience is everyone, it shows the announcement regardless the version', () => { const announcements: StudioAnnouncementDocument[] = [ @@ -664,7 +673,7 @@ describe('StudioAnnouncementsProvider', () => { test('when the card is dismissed, and only 1 announcement received', () => { const saveSeenAnnouncementsMock = jest.fn() seenAnnouncementsMock.mockReturnValue([ - {value: [], error: null, loading: false}, + of({value: [], error: null, loading: false}), saveSeenAnnouncementsMock, ]) mockClient([mockAnnouncements[0]]) @@ -678,7 +687,7 @@ describe('StudioAnnouncementsProvider', () => { test('when the card is dismissed, and 2 announcements are received', () => { const saveSeenAnnouncementsMock = jest.fn() seenAnnouncementsMock.mockReturnValue([ - {value: [], error: null, loading: false}, + of({value: [], error: null, loading: false}), saveSeenAnnouncementsMock, ]) mockClient(mockAnnouncements) @@ -693,7 +702,7 @@ describe('StudioAnnouncementsProvider', () => { const saveSeenAnnouncementsMock = jest.fn() // The id received here is not present anymore in the mock announcements, this id won't be stored in next save. seenAnnouncementsMock.mockReturnValue([ - {value: ['not-to-be-persisted'], error: null, loading: false}, + of({value: ['not-to-be-persisted'], error: null, loading: false}), saveSeenAnnouncementsMock, ]) mockClient(mockAnnouncements) @@ -707,7 +716,7 @@ describe('StudioAnnouncementsProvider', () => { const saveSeenAnnouncementsMock = jest.fn() // The id received here is present in the mock announcements, this id will be persisted in next save. seenAnnouncementsMock.mockReturnValue([ - {value: [mockAnnouncements[0]._id], error: null, loading: false}, + of({value: [mockAnnouncements[0]._id], error: null, loading: false}), saveSeenAnnouncementsMock, ]) mockClient(mockAnnouncements) @@ -721,7 +730,7 @@ describe('StudioAnnouncementsProvider', () => { test('when the dialog is closed', () => { const saveSeenAnnouncementsMock = jest.fn() seenAnnouncementsMock.mockReturnValue([ - {value: [], error: null, loading: false}, + of({value: [], error: null, loading: false}), saveSeenAnnouncementsMock, ]) mockClient(mockAnnouncements) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx index 94eb5c15a66e..ba610997ad2f 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx @@ -4,7 +4,7 @@ import {of, Subject} from 'rxjs' import {useRouter} from 'sanity/router' import {useKeyValueStore} from '../../../store/_legacy/datastores' -import {useSeenAnnouncements} from '../useSeenAnnouncements' +import {type SeenAnnouncementsState, useSeenAnnouncements} from '../useSeenAnnouncements' jest.mock('../../../store/_legacy/datastores', () => ({ useKeyValueStore: jest.fn(), @@ -24,20 +24,26 @@ describe('useSeenAnnouncements', () => { const observable = new Subject() const getKeyMock = jest.fn().mockReturnValue(observable) const setKeyMock = jest.fn() - useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) const {result} = renderHook(() => useSeenAnnouncements()) - expect(result.current[0]).toEqual({value: null, error: null, loading: true}) - + const seenAnnouncements$ = result.current[0] const seenAnnouncements = ['announcement1', 'announcement2'] - act(() => { - observable.next(seenAnnouncements) + + const expectedStates: SeenAnnouncementsState[] = [ + {value: null, error: null, loading: true}, + {value: seenAnnouncements, error: null, loading: false}, + ] + const emissions: SeenAnnouncementsState[] = [] + + seenAnnouncements$.subscribe((state) => { + emissions.push(state) }) - await waitFor(() => { - expect(result.current[0]).toEqual({value: seenAnnouncements, error: null, loading: false}) + act(() => { + observable.next(seenAnnouncements) }) + expect(emissions).toEqual(expectedStates) }) test('should handle errors on the keyValueStore', async () => { const observable = new Subject() @@ -47,20 +53,23 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) const {result} = renderHook(() => useSeenAnnouncements()) - expect(result.current[0]).toEqual({value: null, error: null, loading: true}) + const seenAnnouncements$ = result.current[0] + + const emissions: SeenAnnouncementsState[] = [] + + seenAnnouncements$.subscribe((state) => { + emissions.push(state) + }) const error = new Error('An error occurred') act(() => { observable.error(error) }) - - await waitFor(() => { - expect(result.current[0]).toEqual({ - value: null, - error: error, - loading: false, - }) - }) + const expectedStates: SeenAnnouncementsState[] = [ + {value: null, error: null, loading: true}, + {value: null, error: error, loading: false}, + ] + expect(emissions).toEqual(expectedStates) }) test('should call the getKey function with the correct key when the hook is called', () => { diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts index 7bbcbc77d7e3..5cb23844f867 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts @@ -1,6 +1,5 @@ import {useCallback, useEffect, useMemo} from 'react' -import {useObservable} from 'react-rx' -import {catchError, map, of} from 'rxjs' +import {catchError, map, type Observable, of, startWith} from 'rxjs' import {useRouter} from 'sanity/router' import {useKeyValueStore} from '../../store/_legacy/datastores' @@ -8,7 +7,7 @@ import {useKeyValueStore} from '../../store/_legacy/datastores' const KEY = 'studio.announcement.seen' const RESET_PARAM = 'reset-announcements' -interface SeenAnnouncementsState { +export interface SeenAnnouncementsState { value: string[] | null error: Error | null loading: boolean @@ -19,20 +18,22 @@ const INITIAL_STATE: SeenAnnouncementsState = { loading: true, } -export function useSeenAnnouncements(): [SeenAnnouncementsState, (seen: string[]) => void] { +export function useSeenAnnouncements(): [ + Observable, + (seen: string[]) => void, +] { const router = useRouter() const keyValueStore = useKeyValueStore() - const seenAnnouncements$ = useMemo( + const seenAnnouncements$: Observable = useMemo( () => keyValueStore.getKey(KEY).pipe( map((value) => ({value: value as string[] | null, error: null, loading: false})), + startWith(INITIAL_STATE), catchError((error) => of({value: null, error: error, loading: false})), ), [keyValueStore], ) - const seenAnnouncements = useObservable(seenAnnouncements$, INITIAL_STATE) - const setSeenAnnouncements = useCallback( (seen: string[]) => { keyValueStore.setKey(KEY, seen) @@ -52,5 +53,5 @@ export function useSeenAnnouncements(): [SeenAnnouncementsState, (seen: string[] } }, [resetAnnouncementsParams, setSeenAnnouncements]) - return [seenAnnouncements, setSeenAnnouncements] + return [seenAnnouncements$, setSeenAnnouncements] }