From 8f10beba19f71816fed25cf8e3f2ba63ebb1d0e3 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 12 Sep 2024 16:57:40 +0200 Subject: [PATCH 01/15] feat(core): update UpsellDescriptionSerializer to support h3, images & lists --- .../UpsellDescriptionSerializer.tsx | 97 ++++++++++++++++--- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx index d4a3757a703..f346ea8a20e 100644 --- a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx +++ b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx @@ -1,7 +1,13 @@ -import {PortableText, type PortableTextComponents} from '@portabletext/react' +import { + PortableText, + type PortableTextComponents, + type PortableTextTypeComponentProps, +} from '@portabletext/react' import {Icon, LinkIcon} from '@sanity/icons' import {type PortableTextBlock} from '@sanity/types' import {Box, Card, Flex, Heading, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' import {type ReactNode, useEffect, useMemo, useState} from 'react' import {css, styled} from 'styled-components' @@ -67,11 +73,17 @@ const Link = styled.a<{useTextColor: boolean}>` color: ${(props) => (props.useTextColor ? 'var(--card-muted-fg-color) !important' : '')}; ` -const DynamicIconContainer = styled.span` +const DynamicIconContainer = styled.span<{$inline: boolean}>` + display: ${({$inline}) => ($inline ? 'inline-block' : 'inline')}; + font-size: calc(21 / 16 * 1rem) !important; + min-width: calc(21 / 16 * 1rem - 0.375rem); + line-height: 0; > svg { + height: 1em; + width: 1em; display: inline; - font-size: calc(21 / 16 * 1rem) !important; - margin: -0.375rem 0 !important; + font-size: 1em !important; + margin: -0.375rem !important; *[stroke] { stroke: currentColor; } @@ -80,7 +92,7 @@ const DynamicIconContainer = styled.span` const accentSpanWrapper = (children: ReactNode) => {children} -const DynamicIcon = (props: {icon: {url: string}}) => { +const DynamicIcon = (props: {icon: {url: string}; inline?: boolean}) => { const [__html, setHtml] = useState('') useEffect(() => { const controller = new AbortController() @@ -105,7 +117,7 @@ const DynamicIcon = (props: {icon: {url: string}}) => { } }, [props.icon.url]) - return + return } function NormalBlock(props: {children: ReactNode}) { @@ -120,7 +132,7 @@ function NormalBlock(props: {children: ReactNode}) { ) } -function HeadingBlock(props: {children: ReactNode}) { +function H2Block(props: {children: ReactNode}) { const {children} = props return ( @@ -131,20 +143,74 @@ function HeadingBlock(props: {children: ReactNode}) { ) } +function H3Block(props: {children: ReactNode}) { + const {children} = props + return ( + + + {children} + + + ) +} + +const Image = styled.img((props) => { + const theme = getTheme_v2(props.theme) + + return css` + object-fit: cover; + width: 100%; + border-radius: ${theme.radius[3]}px; + ` +}) + +function ImageBlock( + props: PortableTextTypeComponentProps<{ + image?: {url: string} + }>, +) { + return ( + + + + ) +} + const components: PortableTextComponents = { block: { normal: ({children}) => {children}, - h2: ({children}) => {children}, + h2: ({children}) => {children}, + h3: ({children}) => {children}, }, list: { - bullet: ({children}) => children, - number: ({children}) => <>{children}, + bullet: ({children}) =>
    {children}
, + number: ({children}) =>
    {children}
, checkmarks: ({children}) => <>{children}, }, listItem: { - bullet: ({children}) => {children}, - number: ({children}) => {children}, - checkmarks: ({children}) => {children}, + bullet: ({children}) => ( + + {children} + + ), + number: ({children}) => ( + + {children} + + ), + checkmarks: ({children}) => {children}, }, marks: { @@ -174,7 +240,7 @@ const components: PortableTextComponents = { $hasTextRight={props.value.hasTextRight} /> ) : ( - + <>{props.value.icon?.url && } )} ) @@ -193,7 +259,7 @@ const components: PortableTextComponents = { {props.value.sanityIcon ? ( ) : ( - + <>{props.value.icon?.url && } )} @@ -206,6 +272,7 @@ const components: PortableTextComponents = { ), + imageBlock: (props) => , }, } From 73627acdd282f7f48e7a34c4e07dc5d4f4dba7a0 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 12 Sep 2024 17:31:10 +0200 Subject: [PATCH 02/15] feat(core): studio announcements card and dialog gro-2493, gro-2498 --- .../sanity/src/core/studio/StudioLayout.tsx | 2 + packages/sanity/src/core/studio/index.ts | 1 + .../studio/studioAnnouncements/Divider.tsx | 49 ++++++ .../StudioAnnouncementCard.tsx | 140 ++++++++++++++++++ .../StudioAnnouncementDialog.tsx | 123 +++++++++++++++ .../StudioAnnouncements.tsx | 79 ++++++++++ .../core/studio/studioAnnouncements/index.ts | 5 + .../core/studio/studioAnnouncements/query.ts | 30 ++++ .../core/studio/studioAnnouncements/types.ts | 16 ++ .../core/studio/studioAnnouncements/utils.ts | 27 ++++ 10 files changed, 472 insertions(+) create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/Divider.tsx create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementCard.tsx create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementDialog.tsx create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncements.tsx create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/index.ts create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/query.ts create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/types.ts create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/utils.ts diff --git a/packages/sanity/src/core/studio/StudioLayout.tsx b/packages/sanity/src/core/studio/StudioLayout.tsx index 5641104e904..b503821d670 100644 --- a/packages/sanity/src/core/studio/StudioLayout.tsx +++ b/packages/sanity/src/core/studio/StudioLayout.tsx @@ -15,6 +15,7 @@ import { useLayoutComponent, useNavbarComponent, } from './studio-components-hooks' +import {StudioAnnouncements} from './studioAnnouncements/StudioAnnouncements' import {StudioErrorBoundary} from './StudioErrorBoundary' import {useWorkspace} from './workspace' @@ -188,6 +189,7 @@ export function StudioLayoutComponent() { )} + ) } diff --git a/packages/sanity/src/core/studio/index.ts b/packages/sanity/src/core/studio/index.ts index 3dc1d4051c0..f9b87401ec7 100644 --- a/packages/sanity/src/core/studio/index.ts +++ b/packages/sanity/src/core/studio/index.ts @@ -6,6 +6,7 @@ export * from './copyPaste' export * from './renderStudio' export * from './source' export * from './Studio' +export * from './studioAnnouncements' export * from './StudioLayout' export * from './StudioProvider' export * from './upsell' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/Divider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/Divider.tsx new file mode 100644 index 00000000000..3824f49d3a6 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/Divider.tsx @@ -0,0 +1,49 @@ +import {Box} from '@sanity/ui' +import {useCallback, useEffect, useRef, useState} from 'react' +import {styled} from 'styled-components' + +const Hr = styled.hr<{$show: boolean}>` + height: 1px; + background: var(--card-border-color); + width: 100%; + opacity: ${({$show}) => ($show ? 1 : 0)}; + transition: opacity 0.3s ease; + margin: 0; + border: none; +` +/** + * A divider that fades when reaching the top of the parent. + */ +export function Divider(props: {parentRef: React.RefObject}): JSX.Element { + const {parentRef} = props + const itemRef = useRef(null) + const [show, setShow] = useState(true) + + const handleScrollChange = useCallback(() => { + const itemTop = itemRef.current?.getBoundingClientRect().top + const parentTop = parentRef.current?.getBoundingClientRect().top + // If the item is between -6px and 80px from the top of the parent, show it + if (typeof itemTop !== 'number' || typeof parentTop !== 'number') return + setShow(itemTop >= parentTop + 60) + }, [parentRef]) + + useEffect(() => { + const parent = parentRef.current + if (parent) { + parent.addEventListener('scroll', handleScrollChange) + } + return () => { + if (parent) { + parent.removeEventListener('scroll', handleScrollChange) + } + } + }, [itemRef, handleScrollChange, parentRef]) + + return ( + + +
+
+
+ ) +} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementCard.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementCard.tsx new file mode 100644 index 00000000000..3c56132ab6f --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementCard.tsx @@ -0,0 +1,140 @@ +import {RemoveIcon} from '@sanity/icons' +import {Box, Card, Stack, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {css, keyframes, styled} from 'styled-components' + +import {Button, Popover} from '../../../ui-components' +import {type StudioAnnouncementDocument} from './types' + +const TYPE_DICTIONARY: { + [key in StudioAnnouncementDocument['announcementType']]: string +} = { + 'whats-new': "What's new", +} + +const keyframe = keyframes` + 0% { + background-position: 100%; + } + 100% { + background-position: -100%; + } +` + +const Root = styled.div((props) => { + const theme = getTheme_v2(props.theme) + const cardHoverBg = theme.color.selectable.default.hovered.bg + const cardNormalBg = theme.color.selectable.default.enabled.bg + + return css` + position: relative; + cursor: pointer; + // hide the close button + #close-floating-button { + opacity: 0; + transition: opacity 0.2s; + } + + &:hover { + > [data-ui='whats-new-card'] { + --card-bg-color: ${cardHoverBg}; + box-shadow: inset 0 0 2px 1px var(--card-skeleton-color-to); + background-image: linear-gradient( + to right, + var(--card-bg-color), + var(--card-bg-color), + ${cardNormalBg}, + var(--card-bg-color), + var(--card-bg-color), + var(--card-bg-color) + ); + background-position: 100%; + background-size: 200% 100%; + background-attachment: fixed; + animation-name: ${keyframe}; + animation-timing-function: ease-in; + animation-iteration-count: infinite; + animation-duration: 2000ms; + + /* --card-bg-color: var(--card-badge-default-bg-color); */ + } + #close-floating-button { + opacity: 1; + background: transparent; + + &:hover { + transition: all 0.2s; + box-shadow: 0 0 0 1px ${theme.color.selectable.default.hovered.border}; + } + } + } + ` +}) + +const FloatingCard = styled(Card)` + max-width: 320px; +` +const ButtonRoot = styled.div` + z-index: 1; + position: absolute; + top: 4px; + right: 6px; +` + +/** + * @internal + * @hidden + */ +export function StudioAnnouncementCard(props: { + title: string + isOpen: boolean + announcementType: StudioAnnouncementDocument['announcementType'] + onCardClick: () => void + onCardClose: () => void +}) { + const {title, announcementType, onCardClick, isOpen, onCardClose} = props + + return ( + + + + + + {TYPE_DICTIONARY[announcementType]} + + + + {title} + + + + + + ) + } + + const {queryByText, getByRole} = render(, {wrapper}) + + expect(queryByText("What's new")).toBeInTheDocument() + expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() + + const openDialogButton = getByRole('button', {name: 'Open dialog'}) + fireEvent.click(openDialogButton) + + // The card closes even if we open it from somewhere else + expect(queryByText("What's new")).toBeNull() + // The first announcement is seen, it's rendered because it's showing all + expect(queryByText(mockAnnouncements[0].title)).toBeInTheDocument() + // The second announcement is unseen, so it's rendered + expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() + }) + }) + describe('tests audiences - studio version is 3.57.0', () => { + beforeEach(() => { + jest.clearAllMocks() + // It doesn't show the first element + seenAnnouncementsMock.mockReturnValue([[], jest.fn()]) + }) + test('if the audience is everyone, it shows the announcement regardless the version', () => { + const {createClient} = require('@sanity/client') + const announcements = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'everyone', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is above-version and studio version is not above', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'above-version', + studioVersion: '3.57.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is above-version and studio version is above', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'above-version', + studioVersion: '3.56.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is specific-version and studio matches ', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'specific-version', + studioVersion: '3.57.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is specific-version and studio doesnt match ', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'specific-version', + studioVersion: '3.56.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is below-version and studio is above', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'below-version', + studioVersion: '3.57.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is below-version and studio is below', () => { + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audience: 'below-version', + studioVersion: '3.58.0', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + }) +}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx new file mode 100644 index 00000000000..690b3e1e9af --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx @@ -0,0 +1,67 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' +import {act, renderHook, waitFor} from '@testing-library/react' +import {of, Subject} from 'rxjs' + +import {useKeyValueStore} from '../../../store/_legacy/datastores' +import {useSeenAnnouncements} from '../useSeenAnnouncements' + +jest.mock('../../../store/_legacy/datastores', () => ({ + useKeyValueStore: jest.fn(), +})) + +const useKeyValueStoreMock = useKeyValueStore as jest.Mock + +describe('useSeenAnnouncements', () => { + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks() + }) + test('should return "loading" initially and update when observable emits', async () => { + 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]).toBe('loading') + + const seenAnnouncements = ['announcement1', 'announcement2'] + act(() => { + observable.next(seenAnnouncements) + }) + + await waitFor(() => { + expect(result.current[0]).toEqual(seenAnnouncements) + }) + }) + + test('should call the getKey function with the correct key when the hook is called', () => { + const observable = new Subject() + const getKeyMock = jest.fn().mockImplementation(() => observable) + + const setKeyMock = jest.fn().mockReturnValue(of([])) + + useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) + + renderHook(() => useSeenAnnouncements()) + + expect(getKeyMock).toHaveBeenCalledWith('studio.announcement.seen') + }) + + test('should call setKey with the correct arguments when setSeenAnnouncements is called', () => { + const newSeenAnnouncements = ['announcement1', 'announcement2'] + const getKeyMock = jest.fn().mockImplementation(() => of([])) + const setKeyMock = jest.fn().mockReturnValue(of([])) + + useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) + const {result} = renderHook(() => useSeenAnnouncements()) + const [_, setSeenAnnouncements] = result.current + // Call the setSeenAnnouncements function + act(() => { + setSeenAnnouncements(newSeenAnnouncements) + }) + + expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', newSeenAnnouncements) + }) +}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts index cddc6730296..2cdaee63630 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts @@ -1,7 +1,8 @@ import {useCallback, useMemo} from 'react' import {useObservable} from 'react-rx' import {type Observable} from 'rxjs' -import {useKeyValueStore} from 'sanity' + +import {useKeyValueStore} from '../../store/_legacy/datastores' const KEY = 'studio.announcement.seen' @@ -17,11 +18,13 @@ export function useSeenAnnouncements(): [string[] | null | 'loading', (seen: str ) const seenAnnouncements = useObservable(seenAnnouncements$, 'loading') - const setSeenAnnouncements = useCallback((seen: string[]) => { - // eslint-disable-next-line no-console - console.log('Seen announcements', seen) - // keyValueStore.setKey(KEY, seen) - }, []) + const setSeenAnnouncements = useCallback( + (seen: string[]) => { + // eslint-disable-next-line no-console + keyValueStore.setKey(KEY, seen) + }, + [keyValueStore], + ) return [seenAnnouncements, setSeenAnnouncements] } From 78a1a6390ffea394aae59cdee923690fe62eaed3 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 16 Sep 2024 16:32:53 +0200 Subject: [PATCH 08/15] chore(core): add tests to save seen announcements actions --- .../StudioAnnouncementsProvider.tsx | 14 ++- .../StudioAnnouncementsProvider.test.tsx | 102 ++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index c612ff64aa9..b850bebd87b 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -66,17 +66,21 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi return () => subscription.unsubscribe() }, []) + const saveSeenAnnouncements = useCallback(() => { + // Mark all the announcements as seen + setSeenAnnouncements(studioAnnouncements.map((doc) => doc._id)) + }, [setSeenAnnouncements, studioAnnouncements]) + const handleOpenDialog = useCallback((mode: DialogMode) => { setDialogMode(mode) setIsCardDismissed(true) }, []) const handleCardDismiss = useCallback(() => { - // Mark all the announcements as seen - setSeenAnnouncements(studioAnnouncements.map((doc) => doc._id)) + saveSeenAnnouncements() setIsCardDismissed(true) telemetry.log(StudioAnnouncementCardDismissed) - }, [setSeenAnnouncements, studioAnnouncements, telemetry]) + }, [saveSeenAnnouncements, telemetry]) const handleCardClick = useCallback(() => { handleOpenDialog('unseen') @@ -85,9 +89,9 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const handleDialogClose = useCallback(() => { setDialogMode(null) - setSeenAnnouncements(studioAnnouncements.map((doc) => doc._id)) + saveSeenAnnouncements() telemetry.log(StudioAnnouncementModalDismissed) - }, [telemetry, setSeenAnnouncements, studioAnnouncements]) + }, [telemetry, saveSeenAnnouncements]) const contextValue: StudioAnnouncementsContextValue = useMemo( () => ({ 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 b9fe8034cf1..6861b75d343 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -71,6 +71,7 @@ const mockAnnouncements = [ audience: 'everyone', }, ] + describe('StudioAnnouncementsProvider', () => { let wrapper = ({children}: {children: ReactNode}) => children beforeAll(async () => { @@ -543,4 +544,105 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual(announcements) }) }) + describe('storing seen announcements', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + test('when the card is dismissed, and only 1 announcement received', () => { + const {createClient} = require('@sanity/client') + const saveSeenAnnouncementsMock = jest.fn() + seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next([mockAnnouncements[0]]) + return {unsubscribe: jest.fn()} + }, + }) + const {getByLabelText} = render(null, {wrapper}) + + const closeButton = getByLabelText('Dismiss announcements') + fireEvent.click(closeButton) + expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith([mockAnnouncements[0]._id]) + }) + test('when the card is dismissed, and 2 announcements are received', () => { + const {createClient} = require('@sanity/client') + const saveSeenAnnouncementsMock = jest.fn() + seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(mockAnnouncements) + return {unsubscribe: jest.fn()} + }, + }) + const {getByLabelText} = render(null, {wrapper}) + + const closeButton = getByLabelText('Dismiss announcements') + fireEvent.click(closeButton) + expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) + }) + test("when the card is dismissed, doesn't persist previous stored values", () => { + const {createClient} = require('@sanity/client') + 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([['not-to-be-persisted'], saveSeenAnnouncementsMock]) + + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(mockAnnouncements) + return {unsubscribe: jest.fn()} + }, + }) + const {getByLabelText} = render(null, {wrapper}) + + const closeButton = getByLabelText('Dismiss announcements') + fireEvent.click(closeButton) + expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) + }) + test('when the card is dismissed, persist previous stored values', () => { + const {createClient} = require('@sanity/client') + const saveSeenAnnouncementsMock = jest.fn() + // The id received here is present in the mock announcements, this id will be persisted in next save. + seenAnnouncementsMock.mockReturnValue([[mockAnnouncements[0]._id], saveSeenAnnouncementsMock]) + + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(mockAnnouncements) + return {unsubscribe: jest.fn()} + }, + }) + const {getByLabelText} = render(null, {wrapper}) + + const closeButton = getByLabelText('Dismiss announcements') + fireEvent.click(closeButton) + expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) + }) + test('when the dialog is closed', () => { + const {createClient} = require('@sanity/client') + const saveSeenAnnouncementsMock = jest.fn() + // The id received here is present in the mock announcements, this id will be persisted in next save. + seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(mockAnnouncements) + return {unsubscribe: jest.fn()} + }, + }) + const {getByLabelText} = render(null, {wrapper}) + + const openButton = getByLabelText('Open announcements') + fireEvent.click(openButton) + // Dialog renders and we close it + const closeButton = getByLabelText('Close dialog') + fireEvent.click(closeButton) + expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) + }) + }) }) From fc01d0ebf4fdc506a2ad6c68cf621ae0773b7c50 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Mon, 16 Sep 2024 17:38:36 +0200 Subject: [PATCH 09/15] feat(core): update telemetry events for studioAnnouncements --- .../StudioAnnouncementsDialog.tsx | 42 +++-- .../StudioAnnouncementsMenuItem.tsx | 2 +- .../StudioAnnouncementsProvider.tsx | 54 ++++-- .../studioAnnouncements.telemetry.ts | 66 ++++++-- .../StudioAnnouncementsDialog.test.tsx | 48 ++++-- .../StudioAnnouncementsMenuItem.test.tsx | 2 +- .../StudioAnnouncementsProvider.test.tsx | 158 ++++++++++++------ .../core/studio/studioAnnouncements/types.ts | 2 +- .../UpsellDescriptionSerializer.tsx | 19 ++- 9 files changed, 282 insertions(+), 111 deletions(-) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx index ca80446a553..5b22c1f2e22 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx @@ -1,6 +1,6 @@ +/* eslint-disable camelcase */ import {CloseIcon} from '@sanity/icons' import {useTelemetry} from '@sanity/telemetry/react' -import {type PortableTextBlock} from '@sanity/types' import {Box, Flex, Grid, Text} from '@sanity/ui' import {Fragment, useCallback, useMemo, useRef} from 'react' import {useTranslation} from 'react-i18next' @@ -8,10 +8,11 @@ import {styled} from 'styled-components' import {Button, Dialog} from '../../../ui-components' import {useDateTimeFormat, type UseDateTimeFormatOptions} from '../../hooks' +import {SANITY_VERSION} from '../../version' import {UpsellDescriptionSerializer} from '../upsell' -import {StudioAnnouncementModalLinkClicked} from './__telemetry__/studioAnnouncements.telemetry' +import {ProductAnnouncementLinkClicked} from './__telemetry__/studioAnnouncements.telemetry' import {Divider} from './Divider' -import {type StudioAnnouncementDocument} from './types' +import {type DialogMode, type StudioAnnouncementDocument} from './types' const DATE_FORMAT_OPTIONS: UseDateTimeFormatOptions = { month: 'short', @@ -40,27 +41,38 @@ const FloatingButton = styled(Button)` ` interface UnseenDocumentProps { - body: PortableTextBlock[] - header: string - publishedDate?: string + announcement: StudioAnnouncementDocument + mode: DialogMode } /** * Renders the unseen document in the dialog. * Has a sticky header with the date and title, and a body with the content. */ -function UnseenDocument({body = [], header, publishedDate}: UnseenDocumentProps) { +function UnseenDocument({announcement, mode}: UnseenDocumentProps) { const telemetry = useTelemetry() const dateFormatter = useDateTimeFormat(DATE_FORMAT_OPTIONS) + const {publishedDate, title, body} = announcement const formattedDate = useMemo(() => { if (!publishedDate) return '' return dateFormatter.format(new Date(publishedDate)) }, [publishedDate, dateFormatter]) - const handleLinkClick = useCallback(() => { - telemetry.log(StudioAnnouncementModalLinkClicked) - }, [telemetry]) + const handleLinkClick = useCallback( + ({url, linkTitle}: {url: string; linkTitle: string}) => { + telemetry.log(ProductAnnouncementLinkClicked, { + announcement_id: announcement._id, + announcement_title: announcement.title, + source: 'studio', + studio_version: SANITY_VERSION, + origin: mode, + link_url: url, + link_title: linkTitle, + }) + }, + [telemetry, announcement, mode], + ) return ( @@ -74,7 +86,7 @@ function UnseenDocument({body = [], header, publishedDate}: UnseenDocumentProps) - {header} + {title} @@ -88,6 +100,7 @@ function UnseenDocument({body = [], header, publishedDate}: UnseenDocumentProps) interface StudioAnnouncementDialogProps { unseenDocuments: StudioAnnouncementDocument[] onClose: () => void + mode: DialogMode } /** @@ -98,6 +111,7 @@ interface StudioAnnouncementDialogProps { export function StudioAnnouncementsDialog({ unseenDocuments = [], onClose, + mode, }: StudioAnnouncementDialogProps) { const dialogRef = useRef(null) const {t} = useTranslation() @@ -115,11 +129,7 @@ export function StudioAnnouncementsDialog({ {unseenDocuments.map((unseenDocument, index) => ( - + {/* Add a divider between each dialog if it's not the last one */} {index < unseenDocuments.length - 1 && } diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsMenuItem.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsMenuItem.tsx index a9bd9357aa6..f90c46b07e3 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsMenuItem.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsMenuItem.tsx @@ -7,7 +7,7 @@ export function StudioAnnouncementsMenuItem({text}: {text: string}) { const {onDialogOpen, studioAnnouncements} = useStudioAnnouncements() const handleOpenDialog = useCallback(() => { - onDialogOpen('all') + onDialogOpen('help_menu') }, [onDialogOpen]) if (studioAnnouncements.length === 0) return null diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index b850bebd87b..167700c74af 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {createClient} from '@sanity/client' import {useTelemetry} from '@sanity/telemetry/react' import {useCallback, useEffect, useMemo, useState} from 'react' @@ -5,10 +6,10 @@ import {StudioAnnouncementContext} from 'sanity/_singletons' import {SANITY_VERSION} from '../../version' import { - StudioAnnouncementCardClicked, - StudioAnnouncementCardDismissed, - StudioAnnouncementCardSeen, - StudioAnnouncementModalDismissed, + ProductAnnouncementCardClicked, + ProductAnnouncementCardDismissed, + ProductAnnouncementCardSeen, + ProductAnnouncementModalDismissed, } from './__telemetry__/studioAnnouncements.telemetry' import {studioAnnouncementQuery} from './query' import {StudioAnnouncementsCard} from './StudioAnnouncementsCard' @@ -30,7 +31,7 @@ interface StudioAnnouncementsProviderProps { */ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProviderProps) { const telemetry = useTelemetry() - const [dialogMode, setDialogMode] = useState() + const [dialogMode, setDialogMode] = useState(null) const [isCardDismissed, setIsCardDismissed] = useState(false) const [studioAnnouncements, setStudioAnnouncements] = useState([]) const [seenAnnouncements, setSeenAnnouncements] = useSeenAnnouncements() @@ -44,7 +45,12 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi // Filter out the seen announcements const unseen = studioAnnouncements.filter((doc) => !seenAnnouncements.includes(doc._id)) if (unseen.length > 0) { - telemetry.log(StudioAnnouncementCardSeen) + telemetry.log(ProductAnnouncementCardSeen, { + announcement_id: unseen[0]._id, + announcement_title: unseen[0].title, + source: 'studio', + studio_version: SANITY_VERSION, + }) } return unseen }, [seenAnnouncements, studioAnnouncements, telemetry]) @@ -79,19 +85,36 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const handleCardDismiss = useCallback(() => { saveSeenAnnouncements() setIsCardDismissed(true) - telemetry.log(StudioAnnouncementCardDismissed) - }, [saveSeenAnnouncements, telemetry]) + telemetry.log(ProductAnnouncementCardDismissed, { + announcement_id: unseenAnnouncements[0]._id, + announcement_title: unseenAnnouncements[0].title, + source: 'studio', + studio_version: SANITY_VERSION, + }) + }, [saveSeenAnnouncements, telemetry, unseenAnnouncements]) const handleCardClick = useCallback(() => { - handleOpenDialog('unseen') - telemetry.log(StudioAnnouncementCardClicked) - }, [handleOpenDialog, telemetry]) + handleOpenDialog('card') + telemetry.log(ProductAnnouncementCardClicked, { + announcement_id: unseenAnnouncements[0]._id, + announcement_title: unseenAnnouncements[0].title, + source: 'studio', + studio_version: SANITY_VERSION, + }) + }, [handleOpenDialog, telemetry, unseenAnnouncements]) const handleDialogClose = useCallback(() => { + telemetry.log(ProductAnnouncementModalDismissed, { + announcement_id: unseenAnnouncements[0]._id, + announcement_title: unseenAnnouncements[0].title, + source: 'studio', + studio_version: SANITY_VERSION, + origin: dialogMode ?? 'card', + }) + setDialogMode(null) saveSeenAnnouncements() - telemetry.log(StudioAnnouncementModalDismissed) - }, [telemetry, saveSeenAnnouncements]) + }, [saveSeenAnnouncements, telemetry, unseenAnnouncements, dialogMode]) const contextValue: StudioAnnouncementsContextValue = useMemo( () => ({ @@ -118,7 +141,10 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi )} {dialogMode && ( )} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts b/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts index d9aa9ff27fe..8e22f39e235 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts @@ -1,31 +1,67 @@ import {defineEvent} from '@sanity/telemetry' -export const StudioAnnouncementCardSeen = defineEvent({ - name: 'Studio Announcement Card Seen', +interface ProductAnnouncementSharedProperties { + announcement_id: string + announcement_title: string + source: 'studio' + studio_version?: string + // TODO: Aren't this added automatically? + project_id?: string + organization_id?: string +} + +type origin = 'card' | 'help_menu' + +export const ProductAnnouncementCardSeen = defineEvent({ + name: 'Product Announcement Card Seen', + version: 1, + description: 'User viewed the product announcement card', +}) + +export const ProductAnnouncementCardClicked = defineEvent({ + name: 'Product Announcement Card Clicked', + version: 1, + description: 'User clicked the product announcement card', +}) + +export const ProductAnnouncementCardDismissed = defineEvent({ + name: 'Product Announcement Card Dismissed', version: 1, - description: 'User viewed the studio announcement card', + description: 'User dismissed the product announcement card', }) -export const StudioAnnouncementCardClicked = defineEvent({ - name: 'Studio Announcement Card Clicked', +export const ProductAnnouncementViewed = defineEvent< + ProductAnnouncementSharedProperties & {scrolled_into_view: boolean; origin: origin} +>({ + name: 'Product Announcement Viewed', version: 1, - description: 'User clicked the studio announcement card', + description: 'User viewed the product announcement', }) -export const StudioAnnouncementCardDismissed = defineEvent({ - name: 'Studio Announcement Card Dismissed', +export const ProductAnnouncementLinkClicked = defineEvent< + ProductAnnouncementSharedProperties & { + link_url: string + link_title: string + origin: origin + } +>({ + name: 'Product Announcement Link Clicked', version: 1, - description: 'User dismissed the studio announcement card', + description: 'User clicked the link in the product announcement ', }) -export const StudioAnnouncementModalLinkClicked = defineEvent({ - name: 'Studio Announcement Modal Link Clicked', +export const ProductAnnouncementModalDismissed = defineEvent< + ProductAnnouncementSharedProperties & { + origin: origin + } +>({ + name: 'Product Announcement Dismissed', version: 1, - description: 'User clicked the link in the studio announcement modal', + description: 'User dismissed the product announcement modal ', }) -export const StudioAnnouncementModalDismissed = defineEvent({ - name: 'Studio Announcement Modal Dismissed', +export const WhatsNewHelpMenuItemClicked = defineEvent({ + name: 'Whats New Help Menu Item Clicked', version: 1, - description: 'User dismissed the studio announcement modal', + description: 'User clicked the "Whats new" help menu item', }) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx index 0c4b90b139a..54f5ecb7999 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {afterEach, describe, expect, jest, test} from '@jest/globals' import {fireEvent, render, screen} from '@testing-library/react' import {type ReactNode} from 'react' @@ -12,6 +13,10 @@ jest.mock('@sanity/telemetry/react', () => ({ useTelemetry: jest.fn(), })) +jest.mock('../../../version', () => ({ + SANITY_VERSION: '3.57.0', +})) + const MOCKED_ANNOUNCEMENTS: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -96,7 +101,11 @@ describe('StudioAnnouncementsCard', () => { const wrapper = await createAnnouncementWrapper() await render( - , + , {wrapper}, ) @@ -125,7 +134,11 @@ describe('StudioAnnouncementsCard', () => { const wrapper = await createAnnouncementWrapper() await render( - , + , {wrapper}, ) // Check that the close button is rendered @@ -145,7 +158,11 @@ describe('StudioAnnouncementsCard', () => { }) const wrapper = await createAnnouncementWrapper() await render( - , + , {wrapper}, ) @@ -153,12 +170,23 @@ describe('StudioAnnouncementsCard', () => { const link = screen.getByText('Content with a link') fireEvent.click(link) - expect(mockLog).toHaveBeenCalledWith({ - description: 'User clicked the link in the studio announcement modal', - name: 'Studio Announcement Modal Link Clicked', - schema: undefined, - type: 'log', - version: 1, - }) + expect(mockLog).toHaveBeenCalledWith( + { + description: 'User clicked the link in the product announcement ', + name: 'Product Announcement Link Clicked', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-1', + announcement_title: 'Announcement 1', + link_title: 'Content with a link', + link_url: 'https://github.com/sanity-io/sanity/releases/tag/v3.56.0', + origin: 'card', + source: 'studio', + studio_version: '3.57.0', + }, + ) }) }) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx index cabb1618764..5de5b9e0697 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx @@ -94,6 +94,6 @@ describe('StudioAnnouncementsMenuItem', () => { fireEvent.click(screen.getByText('Announcements')) - expect(onDialogOpenMock).toHaveBeenCalledWith('all') + expect(onDialogOpenMock).toHaveBeenCalledWith('help_menu') }) }) 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 6861b75d343..54d702f0b54 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {beforeAll, beforeEach, describe, expect, jest, test} from '@jest/globals' import {fireEvent, render, renderHook} from '@testing-library/react' import {type ReactNode} from 'react' @@ -197,20 +198,36 @@ describe('StudioAnnouncementsProvider', () => { // Opening the dialog calls the telemetry only once, with the seen card expect(mockLog).toBeCalledTimes(2) - expect(mockLog).toBeCalledWith({ - description: 'User viewed the studio announcement card', - name: 'Studio Announcement Card Seen', - schema: undefined, - type: 'log', - version: 1, - }) - expect(mockLog).toBeCalledWith({ - description: 'User clicked the studio announcement card', - name: 'Studio Announcement Card Clicked', - schema: undefined, - type: 'log', - version: 1, - }) + expect(mockLog).toBeCalledWith( + { + description: 'User viewed the product announcement card', + name: 'Product Announcement Card Seen', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) + expect(mockLog).toBeCalledWith( + { + description: 'User clicked the product announcement card', + name: 'Product Announcement Card Clicked', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) }) test("dismisses card, then it's hidden, dialog doesn't render", () => { @@ -229,20 +246,36 @@ describe('StudioAnnouncementsProvider', () => { // Dismissing the card calls telemetry with the seen and dismiss logs expect(mockLog).toBeCalledTimes(2) - expect(mockLog).toBeCalledWith({ - description: 'User viewed the studio announcement card', - name: 'Studio Announcement Card Seen', - schema: undefined, - type: 'log', - version: 1, - }) - expect(mockLog).toBeCalledWith({ - description: 'User dismissed the studio announcement card', - name: 'Studio Announcement Card Dismissed', - schema: undefined, - type: 'log', - version: 1, - }) + expect(mockLog).toBeCalledWith( + { + description: 'User viewed the product announcement card', + name: 'Product Announcement Card Seen', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) + expect(mockLog).toBeCalledWith( + { + description: 'User dismissed the product announcement card', + name: 'Product Announcement Card Dismissed', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) }) test('dismisses dialog, card and dialog are hidden', () => { @@ -265,34 +298,59 @@ describe('StudioAnnouncementsProvider', () => { expect(queryByText(mockAnnouncements[1].title)).toBeNull() expect(mockLog).toBeCalledTimes(3) - expect(mockLog).toBeCalledWith({ - description: 'User viewed the studio announcement card', - name: 'Studio Announcement Card Seen', - schema: undefined, - type: 'log', - version: 1, - }) - expect(mockLog).toBeCalledWith({ - description: 'User clicked the studio announcement card', - name: 'Studio Announcement Card Clicked', - schema: undefined, - type: 'log', - version: 1, - }) - expect(mockLog).toBeCalledWith({ - description: 'User dismissed the studio announcement modal', - name: 'Studio Announcement Modal Dismissed', - schema: undefined, - type: 'log', - version: 1, - }) + expect(mockLog).toBeCalledWith( + { + description: 'User viewed the product announcement card', + name: 'Product Announcement Card Seen', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) + expect(mockLog).toBeCalledWith( + { + description: 'User clicked the product announcement card', + name: 'Product Announcement Card Clicked', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + source: 'studio', + studio_version: '3.57.0', + }, + ) + expect(mockLog).toBeCalledWith( + { + description: 'User dismissed the product announcement modal ', + name: 'Product Announcement Dismissed', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + origin: 'card', + source: 'studio', + studio_version: '3.57.0', + }, + ) }) test('opens the dialog from outside the card, so it shows all unseen', () => { const Component = () => { const {onDialogOpen} = useStudioAnnouncements() return ( // eslint-disable-next-line react/jsx-no-bind - ) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/types.ts b/packages/sanity/src/core/studio/studioAnnouncements/types.ts index 671a2ad547b..46c79102a2a 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/types.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/types.ts @@ -22,4 +22,4 @@ export interface StudioAnnouncementsContextValue { } // Decides weather to show all the announcements or only the unseen ones -export type DialogMode = 'unseen' | 'all' +export type DialogMode = 'card' | 'help_menu' diff --git a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx index 6e94fe40149..dc52c5259f2 100644 --- a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx +++ b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx @@ -176,7 +176,11 @@ function ImageBlock( ) } -const createComponents = ({onLinkClick}: {onLinkClick?: () => void}): PortableTextComponents => ({ +const createComponents = ({ + onLinkClick, +}: { + onLinkClick?: ({url, linkTitle}: {url: string; linkTitle: string}) => void +}): PortableTextComponents => ({ block: { normal: ({children}) => {children}, h2: ({children}) => {children}, @@ -222,7 +226,16 @@ const createComponents = ({onLinkClick}: {onLinkClick?: () => void}): PortableTe rel="noopener noreferrer" target="_blank" useTextColor={props.value.useTextColor} - onClick={onLinkClick} + // eslint-disable-next-line react/jsx-no-bind + onClick={ + onLinkClick + ? () => + onLinkClick({ + url: props.value.href, + linkTitle: props.text, + }) + : undefined + } > {props.children} {props.value.showIcon && } @@ -279,7 +292,7 @@ const createComponents = ({onLinkClick}: {onLinkClick?: () => void}): PortableTe interface DescriptionSerializerProps { blocks: PortableTextBlock[] - onLinkClick?: () => void + onLinkClick?: ({url, linkTitle}: {url: string; linkTitle: string}) => void } /** From e2c186048dde17c71df3a770d7e24a1c038e9cb8 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Tue, 17 Sep 2024 10:20:46 +0200 Subject: [PATCH 10/15] fix(core): update useSeenAnnouncements to handle state reset --- .../StudioAnnouncements.tsx | 73 ------------------- .../StudioAnnouncementsProvider.tsx | 52 +++++++------ .../StudioAnnouncementsProvider.test.tsx | 26 ++++--- ...nts.test.tsx => useSeenDocuments.test.tsx} | 53 ++++++++++++++ .../useSeenAnnouncements.ts | 21 ++++-- 5 files changed, 111 insertions(+), 114 deletions(-) delete mode 100644 packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncements.tsx rename packages/sanity/src/core/studio/studioAnnouncements/__tests__/{useUnseenDocuments.test.tsx => useSeenDocuments.test.tsx} (54%) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncements.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncements.tsx deleted file mode 100644 index d5d5cde3026..00000000000 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncements.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import {useTelemetry} from '@sanity/telemetry/react' -import {useCallback, useState} from 'react' - -import { - StudioAnnouncementCardClicked, - StudioAnnouncementCardDismissed, - StudioAnnouncementModalDismissed, -} from './__telemetry__/studioAnnouncements.telemetry' -import {StudioAnnouncementsCard} from './StudioAnnouncementsCard' -import {StudioAnnouncementsDialog} from './StudioAnnouncementsDialog' -import {type DialogMode} from './types' -import {useStudioAnnouncements} from './useStudioAnnouncements' - -interface StudioAnnouncementsProps { - setSeenAnnouncements: (ids: string[]) => void -} -/** - * @internal - * @hidden - */ -export function StudioAnnouncements({setSeenAnnouncements}: StudioAnnouncementsProps) { - const telemetry = useTelemetry() - const [dialogMode, setDialogMode] = useState() - const [isCardDismissed, setIsCardDismissed] = useState(false) - const {studioAnnouncements, unseenAnnouncements} = useStudioAnnouncements() - - const handleOpenDialog = useCallback((mode: DialogMode) => { - setDialogMode(mode) - setIsCardDismissed(true) - }, []) - - const handleCardDismiss = useCallback(() => { - // Mark all the announcements as seen - setSeenAnnouncements(studioAnnouncements.map((doc) => doc._id)) - setIsCardDismissed(true) - telemetry.log(StudioAnnouncementCardDismissed) - }, [setSeenAnnouncements, studioAnnouncements, telemetry]) - - const handleCardClick = useCallback(() => { - handleOpenDialog('unseen') - telemetry.log(StudioAnnouncementCardClicked) - }, [handleOpenDialog, telemetry]) - - const handleDialogClose = useCallback(() => { - setDialogMode(null) - setSeenAnnouncements(studioAnnouncements.map((doc) => doc._id)) - telemetry.log(StudioAnnouncementModalDismissed) - }, [telemetry, setSeenAnnouncements, studioAnnouncements]) - - if (!unseenAnnouncements.length) { - return null - } - - return ( - <> - {!isCardDismissed && ( - - )} - {dialogMode && ( - - )} - - ) -} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index 167700c74af..8206ebe6892 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -54,6 +54,7 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi } return unseen }, [seenAnnouncements, studioAnnouncements, telemetry]) + useEffect(() => { // TODO: Replace for internal api const client = createClient({projectId: '3do82whm', dataset: 'next'}) @@ -86,8 +87,8 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi saveSeenAnnouncements() setIsCardDismissed(true) telemetry.log(ProductAnnouncementCardDismissed, { - announcement_id: unseenAnnouncements[0]._id, - announcement_title: unseenAnnouncements[0].title, + announcement_id: unseenAnnouncements[0]?._id, + announcement_title: unseenAnnouncements[0]?.title, source: 'studio', studio_version: SANITY_VERSION, }) @@ -96,17 +97,20 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const handleCardClick = useCallback(() => { handleOpenDialog('card') telemetry.log(ProductAnnouncementCardClicked, { - announcement_id: unseenAnnouncements[0]._id, - announcement_title: unseenAnnouncements[0].title, + announcement_id: unseenAnnouncements[0]?._id, + announcement_title: unseenAnnouncements[0]?.title, source: 'studio', studio_version: SANITY_VERSION, }) }, [handleOpenDialog, telemetry, unseenAnnouncements]) const handleDialogClose = useCallback(() => { + const firstAnnouncement = + dialogMode === 'help_menu' ? studioAnnouncements[0] : unseenAnnouncements[0] + telemetry.log(ProductAnnouncementModalDismissed, { - announcement_id: unseenAnnouncements[0]._id, - announcement_title: unseenAnnouncements[0].title, + announcement_id: firstAnnouncement?._id, + announcement_title: firstAnnouncement?.title, source: 'studio', studio_version: SANITY_VERSION, origin: dialogMode ?? 'card', @@ -114,7 +118,7 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi setDialogMode(null) saveSeenAnnouncements() - }, [saveSeenAnnouncements, telemetry, unseenAnnouncements, dialogMode]) + }, [dialogMode, studioAnnouncements, unseenAnnouncements, telemetry, saveSeenAnnouncements]) const contextValue: StudioAnnouncementsContextValue = useMemo( () => ({ @@ -129,26 +133,20 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi {children} {unseenAnnouncements.length > 0 && ( - <> - {!isCardDismissed && ( - - )} - {dialogMode && ( - - )} - + + )} + {dialogMode && ( + )} ) 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 54d702f0b54..1f28b6524c0 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable camelcase */ import {beforeAll, beforeEach, describe, expect, jest, test} from '@jest/globals' -import {fireEvent, render, renderHook} from '@testing-library/react' +import {fireEvent, render, renderHook, waitFor} from '@testing-library/react' import {type ReactNode} from 'react' import {defineConfig} from 'sanity' @@ -178,7 +178,7 @@ describe('StudioAnnouncementsProvider', () => { }, }) }) - test('clicks on card, it opens dialog and card is hidden, shows only the unseen announcements', () => { + test('clicks on card, it opens dialog and card is hidden, shows only the unseen announcements', async () => { const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') useTelemetry.mockReturnValue({log: mockLog}) @@ -190,7 +190,9 @@ describe('StudioAnnouncementsProvider', () => { const cardButton = getByLabelText('Open announcements') fireEvent.click(cardButton) - expect(queryByText("What's new")).toBeNull() + await waitFor(() => { + expect(queryByText("What's new")).toBeNull() + }) // The first announcement is seen, so it's not rendered expect(queryByText(mockAnnouncements[0].title)).toBeNull() // The second announcement is unseen, so it's rendered @@ -230,7 +232,7 @@ describe('StudioAnnouncementsProvider', () => { ) }) - test("dismisses card, then it's hidden, dialog doesn't render", () => { + test("dismisses card, then it's hidden, dialog doesn't render", async () => { const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') useTelemetry.mockReturnValue({log: mockLog}) @@ -241,7 +243,9 @@ describe('StudioAnnouncementsProvider', () => { expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() const closeButton = getByLabelText('Dismiss announcements') fireEvent.click(closeButton) - expect(queryByText("What's new")).toBeNull() + await waitFor(() => { + expect(queryByText("What's new")).toBeNull() + }) expect(queryByText(mockAnnouncements[1].title)).toBeNull() // Dismissing the card calls telemetry with the seen and dismiss logs @@ -278,7 +282,7 @@ describe('StudioAnnouncementsProvider', () => { ) }) - test('dismisses dialog, card and dialog are hidden', () => { + test('dismisses dialog, card and dialog are hidden', async () => { const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') useTelemetry.mockReturnValue({log: mockLog}) @@ -289,7 +293,9 @@ describe('StudioAnnouncementsProvider', () => { expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() const cardButton = getByLabelText('Open announcements') fireEvent.click(cardButton) - expect(queryByText("What's new")).toBeNull() + await waitFor(() => { + expect(queryByText("What's new")).toBeNull() + }) expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() const closeButton = getByLabelText('Close dialog') @@ -345,7 +351,7 @@ describe('StudioAnnouncementsProvider', () => { }, ) }) - test('opens the dialog from outside the card, so it shows all unseen', () => { + test('opens the dialog from outside the card, so it shows all unseen', async () => { const Component = () => { const {onDialogOpen} = useStudioAnnouncements() return ( @@ -365,7 +371,9 @@ describe('StudioAnnouncementsProvider', () => { fireEvent.click(openDialogButton) // The card closes even if we open it from somewhere else - expect(queryByText("What's new")).toBeNull() + await waitFor(() => { + expect(queryByText("What's new")).toBeNull() + }) // The first announcement is seen, it's rendered because it's showing all expect(queryByText(mockAnnouncements[0].title)).toBeInTheDocument() // The second announcement is unseen, so it's rendered diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx similarity index 54% rename from packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx rename to packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx index 690b3e1e9af..0495123532e 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useUnseenDocuments.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx @@ -1,6 +1,7 @@ import {beforeEach, describe, expect, jest, test} from '@jest/globals' import {act, renderHook, waitFor} from '@testing-library/react' import {of, Subject} from 'rxjs' +import {useRouter} from 'sanity/router' import {useKeyValueStore} from '../../../store/_legacy/datastores' import {useSeenAnnouncements} from '../useSeenAnnouncements' @@ -10,6 +11,10 @@ jest.mock('../../../store/_legacy/datastores', () => ({ })) const useKeyValueStoreMock = useKeyValueStore as jest.Mock +jest.mock('sanity/router', () => ({ + useRouter: jest.fn().mockReturnValue({state: {}}), +})) +const useRouterMock = useRouter as jest.Mock describe('useSeenAnnouncements', () => { beforeEach(() => { @@ -64,4 +69,52 @@ describe('useSeenAnnouncements', () => { expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', newSeenAnnouncements) }) + describe('should reset states when the param is provided', () => { + test('when a reset value is provided', async () => { + useRouterMock.mockReturnValue({ + state: {_searchParams: [['reset-announcements', 'foo,bar']]}, + }) + const getKeyMock = jest.fn().mockImplementation(() => of([])) + const setKeyMock = jest.fn().mockReturnValue(of([])) + + useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) + renderHook(() => useSeenAnnouncements()) + + // Call the setSeenAnnouncements function + await waitFor(() => { + expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', ['foo', 'bar']) + }) + }) + test('when no reset value is provided', async () => { + useRouterMock.mockReturnValue({ + state: {_searchParams: [['reset-announcements', '']]}, + }) + const getKeyMock = jest.fn().mockImplementation(() => of([])) + const setKeyMock = jest.fn().mockReturnValue(of([])) + + useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) + renderHook(() => useSeenAnnouncements()) + + // Call the setSeenAnnouncements function + await waitFor(() => { + expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', []) + }) + }) + + test('when the key is not provided', async () => { + useRouterMock.mockReturnValue({ + state: {_searchParams: []}, + }) + const getKeyMock = jest.fn().mockImplementation(() => of([])) + const setKeyMock = jest.fn().mockReturnValue(of([])) + + useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) + renderHook(() => useSeenAnnouncements()) + + // Call the setSeenAnnouncements function + await waitFor(() => { + expect(setKeyMock).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts index 2cdaee63630..b0a97e98b93 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts @@ -1,15 +1,15 @@ -import {useCallback, useMemo} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import {useObservable} from 'react-rx' import {type Observable} from 'rxjs' +import {useRouter} from 'sanity/router' import {useKeyValueStore} from '../../store/_legacy/datastores' const KEY = 'studio.announcement.seen' +const RESET_PARAM = 'reset-announcements' -/** - * TODO: This is not functional yet, the API is not accepting the new key - */ export function useSeenAnnouncements(): [string[] | null | 'loading', (seen: string[]) => void] { + const router = useRouter() // Handles the communication with the key value store const keyValueStore = useKeyValueStore() const seenAnnouncements$ = useMemo( @@ -20,11 +20,22 @@ export function useSeenAnnouncements(): [string[] | null | 'loading', (seen: str const setSeenAnnouncements = useCallback( (seen: string[]) => { - // eslint-disable-next-line no-console keyValueStore.setKey(KEY, seen) }, [keyValueStore], ) + const params = new URLSearchParams(router.state._searchParams) + const resetAnnouncementsParams = params?.get(RESET_PARAM) + + useEffect(() => { + // For testing purposes, reset the seen params. + // e.g. /structure?reset-announcements=foo,bar + // Will reset the values of the seen announcement to: ['foo', 'bar'] + if (resetAnnouncementsParams !== null) { + const resetValue = resetAnnouncementsParams ? resetAnnouncementsParams.split(',') : [] + setSeenAnnouncements(resetValue) + } + }, [resetAnnouncementsParams, setSeenAnnouncements]) return [seenAnnouncements, setSeenAnnouncements] } From b0d1102082ee462ddeb76ec4712737f9cf54fd73 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Tue, 17 Sep 2024 11:38:10 +0200 Subject: [PATCH 11/15] feat(core): add telemetry logs to announcement viewed and resources menu clicked --- .../StudioAnnouncementsDialog.tsx | 87 +++++++++++++++---- .../StudioAnnouncementsMenuItem.tsx | 13 ++- .../StudioAnnouncementsProvider.tsx | 2 +- .../studioAnnouncements.telemetry.ts | 3 - .../StudioAnnouncementsDialog.test.tsx | 35 ++++++-- .../StudioAnnouncementsMenuItem.test.tsx | 31 +++++++ .../StudioAnnouncementsProvider.test.tsx | 38 +++++++- 7 files changed, 176 insertions(+), 33 deletions(-) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx index 5b22c1f2e22..cd212a3479b 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsDialog.tsx @@ -2,7 +2,7 @@ import {CloseIcon} from '@sanity/icons' import {useTelemetry} from '@sanity/telemetry/react' import {Box, Flex, Grid, Text} from '@sanity/ui' -import {Fragment, useCallback, useMemo, useRef} from 'react' +import {Fragment, useCallback, useEffect, useMemo, useRef} from 'react' import {useTranslation} from 'react-i18next' import {styled} from 'styled-components' @@ -10,7 +10,10 @@ import {Button, Dialog} from '../../../ui-components' import {useDateTimeFormat, type UseDateTimeFormatOptions} from '../../hooks' import {SANITY_VERSION} from '../../version' import {UpsellDescriptionSerializer} from '../upsell' -import {ProductAnnouncementLinkClicked} from './__telemetry__/studioAnnouncements.telemetry' +import { + ProductAnnouncementLinkClicked, + ProductAnnouncementViewed, +} from './__telemetry__/studioAnnouncements.telemetry' import {Divider} from './Divider' import {type DialogMode, type StudioAnnouncementDocument} from './types' @@ -40,24 +43,26 @@ const FloatingButton = styled(Button)` z-index: 2; ` -interface UnseenDocumentProps { +interface AnnouncementProps { announcement: StudioAnnouncementDocument mode: DialogMode + isFirst: boolean + parentRef: React.RefObject } /** * Renders the unseen document in the dialog. * Has a sticky header with the date and title, and a body with the content. */ -function UnseenDocument({announcement, mode}: UnseenDocumentProps) { +function Announcement({announcement, mode, isFirst, parentRef}: AnnouncementProps) { const telemetry = useTelemetry() const dateFormatter = useDateTimeFormat(DATE_FORMAT_OPTIONS) - const {publishedDate, title, body} = announcement + const logViewedItemRef = useRef(null) const formattedDate = useMemo(() => { - if (!publishedDate) return '' - return dateFormatter.format(new Date(publishedDate)) - }, [publishedDate, dateFormatter]) + if (!announcement.publishedDate) return '' + return dateFormatter.format(new Date(announcement.publishedDate)) + }, [announcement.publishedDate, dateFormatter]) const handleLinkClick = useCallback( ({url, linkTitle}: {url: string; linkTitle: string}) => { @@ -73,6 +78,49 @@ function UnseenDocument({announcement, mode}: UnseenDocumentProps) { }, [telemetry, announcement, mode], ) + const logViewed = useCallback( + (scrolledIntoView: boolean) => { + telemetry.log(ProductAnnouncementViewed, { + announcement_id: announcement._id, + announcement_title: announcement.title, + source: 'studio', + studio_version: SANITY_VERSION, + scrolled_into_view: scrolledIntoView, + origin: mode, + }) + }, + [announcement._id, announcement.title, mode, telemetry], + ) + + useEffect(() => { + if (isFirst) { + // If it's the first announcement we want to log that the user has seen it. + // The rest will be logged when they scroll into view. + logViewed(false) + return + } + const item = logViewedItemRef.current + const parent = parentRef.current + + if (!item || !parent) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + logViewed(true) + // Disconnect the observer after it's been viewed + observer.disconnect() + } + }, + {root: parent, threshold: 1, rootMargin: '0px 0px -100px 0px'}, + ) + + observer.observe(item) + + // eslint-disable-next-line consistent-return + return () => { + observer.disconnect() + } + }, [logViewed, parentRef, isFirst]) return ( @@ -84,21 +132,21 @@ function UnseenDocument({announcement, mode}: UnseenDocumentProps) {
- + - {title} + {announcement.title} - + ) } interface StudioAnnouncementDialogProps { - unseenDocuments: StudioAnnouncementDocument[] + announcements: StudioAnnouncementDocument[] onClose: () => void mode: DialogMode } @@ -109,7 +157,7 @@ interface StudioAnnouncementDialogProps { * @hidden */ export function StudioAnnouncementsDialog({ - unseenDocuments = [], + announcements = [], onClose, mode, }: StudioAnnouncementDialogProps) { @@ -127,11 +175,16 @@ export function StudioAnnouncementsDialog({ __unstable_autoFocus={false} > - {unseenDocuments.map((unseenDocument, index) => ( - - + {announcements.map((announcement, index) => ( + + {/* Add a divider between each dialog if it's not the last one */} - {index < unseenDocuments.length - 1 && } + {index < announcements.length - 1 && } ))} { onDialogOpen('help_menu') - }, [onDialogOpen]) + telemetry.log(WhatsNewHelpMenuItemClicked, { + source: 'studio', + announcement_id: studioAnnouncements[0]?._id, + announcement_title: studioAnnouncements[0]?.title, + studio_version: SANITY_VERSION, + }) + }, [onDialogOpen, studioAnnouncements, telemetry]) if (studioAnnouncements.length === 0) return null return diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index 8206ebe6892..10f6727f57b 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -144,7 +144,7 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi {dialogMode && ( )} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts b/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts index 8e22f39e235..36c998960f6 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/__telemetry__/studioAnnouncements.telemetry.ts @@ -5,9 +5,6 @@ interface ProductAnnouncementSharedProperties { announcement_title: string source: 'studio' studio_version?: string - // TODO: Aren't this added automatically? - project_id?: string - organization_id?: string } type origin = 'card' | 'help_menu' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx index 54f5ecb7999..aa446f51163 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx @@ -10,7 +10,9 @@ import {StudioAnnouncementsDialog} from '../StudioAnnouncementsDialog' import {type StudioAnnouncementDocument} from '../types' jest.mock('@sanity/telemetry/react', () => ({ - useTelemetry: jest.fn(), + useTelemetry: jest.fn().mockReturnValue({ + log: jest.fn(), + }), })) jest.mock('../../../version', () => ({ @@ -102,7 +104,7 @@ describe('StudioAnnouncementsCard', () => { const wrapper = await createAnnouncementWrapper() await render( , @@ -135,7 +137,7 @@ describe('StudioAnnouncementsCard', () => { const wrapper = await createAnnouncementWrapper() await render( , @@ -148,18 +150,16 @@ describe('StudioAnnouncementsCard', () => { expect(onCloseMock).toHaveBeenCalled() }) - test('logs telemetry when link is clicked', async () => { + test('logs telemetry when link is clicked and announcement viewed', async () => { const onCloseMock = jest.fn() const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') // Set up the mock return value - useTelemetry.mockReturnValue({ - log: mockLog, - }) + useTelemetry.mockReturnValue({log: mockLog}) const wrapper = await createAnnouncementWrapper() await render( , @@ -169,7 +169,24 @@ describe('StudioAnnouncementsCard', () => { // Simulate clicking on a link const link = screen.getByText('Content with a link') fireEvent.click(link) - + expect(mockLog).toHaveBeenCalledTimes(2) + expect(mockLog).toHaveBeenCalledWith( + { + description: 'User viewed the product announcement', + name: 'Product Announcement Viewed', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-1', + announcement_title: 'Announcement 1', + origin: 'card', + scrolled_into_view: false, + source: 'studio', + studio_version: '3.57.0', + }, + ) expect(mockLog).toHaveBeenCalledWith( { description: 'User clicked the link in the product announcement ', diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx index 5de5b9e0697..1a4b63a3e93 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import {afterEach, describe, expect, jest, test} from '@jest/globals' import {Menu} from '@sanity/ui' import {fireEvent, render, screen} from '@testing-library/react' @@ -11,6 +12,16 @@ import {type StudioAnnouncementDocument} from '../types' import {useStudioAnnouncements} from '../useStudioAnnouncements' jest.mock('../useStudioAnnouncements') +jest.mock('@sanity/telemetry/react', () => ({ + useTelemetry: jest.fn().mockReturnValue({ + log: jest.fn(), + }), +})) + +jest.mock('../../../version', () => ({ + SANITY_VERSION: '3.57.0', +})) + const MOCKED_ANNOUNCEMENT: StudioAnnouncementDocument = { _id: 'studioAnnouncement-1', _type: 'productAnnouncement', @@ -79,6 +90,10 @@ describe('StudioAnnouncementsMenuItem', () => { test('clicking on MenuItem calls onDialogOpen with "all"', async () => { const onDialogOpenMock = jest.fn() + const mockLog = jest.fn() + const {useTelemetry} = require('@sanity/telemetry/react') + // Set up the mock return value + useTelemetry.mockReturnValue({log: mockLog}) useStudioAnnouncementsMock.mockReturnValue({ studioAnnouncements: [MOCKED_ANNOUNCEMENT], @@ -95,5 +110,21 @@ describe('StudioAnnouncementsMenuItem', () => { fireEvent.click(screen.getByText('Announcements')) expect(onDialogOpenMock).toHaveBeenCalledWith('help_menu') + expect(mockLog).toHaveBeenCalledTimes(1) + expect(mockLog).toHaveBeenCalledWith( + { + description: 'User clicked the "Whats new" help menu item', + name: 'Whats New Help Menu Item Clicked', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-1', + announcement_title: 'Announcement 1', + source: 'studio', + studio_version: '3.57.0', + }, + ) }) }) 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 1f28b6524c0..e97a5aac2bd 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -199,7 +199,7 @@ describe('StudioAnnouncementsProvider', () => { expect(queryByText(mockAnnouncements[1].title)).toBeInTheDocument() // Opening the dialog calls the telemetry only once, with the seen card - expect(mockLog).toBeCalledTimes(2) + expect(mockLog).toBeCalledTimes(3) expect(mockLog).toBeCalledWith( { description: 'User viewed the product announcement card', @@ -230,6 +230,23 @@ describe('StudioAnnouncementsProvider', () => { studio_version: '3.57.0', }, ) + expect(mockLog).toBeCalledWith( + { + description: 'User viewed the product announcement', + name: 'Product Announcement Viewed', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + origin: 'card', + scrolled_into_view: false, + source: 'studio', + studio_version: '3.57.0', + }, + ) }) test("dismisses card, then it's hidden, dialog doesn't render", async () => { @@ -303,7 +320,7 @@ describe('StudioAnnouncementsProvider', () => { expect(queryByText("What's new")).toBeNull() expect(queryByText(mockAnnouncements[1].title)).toBeNull() - expect(mockLog).toBeCalledTimes(3) + expect(mockLog).toBeCalledTimes(4) expect(mockLog).toBeCalledWith( { description: 'User viewed the product announcement card', @@ -350,6 +367,23 @@ describe('StudioAnnouncementsProvider', () => { studio_version: '3.57.0', }, ) + expect(mockLog).toBeCalledWith( + { + description: 'User viewed the product announcement', + name: 'Product Announcement Viewed', + schema: undefined, + type: 'log', + version: 1, + }, + { + announcement_id: 'studioAnnouncement-2', + announcement_title: 'Announcement 2', + origin: 'card', + scrolled_into_view: false, + source: 'studio', + studio_version: '3.57.0', + }, + ) }) test('opens the dialog from outside the card, so it shows all unseen', async () => { const Component = () => { From 532e7d9589d4757d541779b34bc7770d977d9de7 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Tue, 17 Sep 2024 12:04:50 +0200 Subject: [PATCH 12/15] chore(core): remove translations resources in tests --- .../__tests__/StudioAnnouncementsCard.test.tsx | 3 +-- .../__tests__/StudioAnnouncementsDialog.test.tsx | 3 +-- .../__tests__/StudioAnnouncementsMenuItem.test.tsx | 3 +-- .../__tests__/StudioAnnouncementsProvider.test.tsx | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsCard.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsCard.test.tsx index 46b7e18b34b..c3ebe164f92 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsCard.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsCard.test.tsx @@ -5,7 +5,6 @@ import {type ReactNode} from 'react' import {defineConfig} from 'sanity' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' -import {structureUsEnglishLocaleBundle} from '../../../../structure/i18n' import {StudioAnnouncementsCard} from '../StudioAnnouncementsCard' const config = defineConfig({ @@ -16,7 +15,7 @@ const config = defineConfig({ async function createAnnouncementWrapper() { const wrapper = await createTestProvider({ config, - resources: [structureUsEnglishLocaleBundle], + resources: [], }) return ({children}: {children: ReactNode}) => wrapper({children: {children}}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx index aa446f51163..b50d2a9eb1c 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx @@ -5,7 +5,6 @@ import {type ReactNode} from 'react' import {defineConfig} from 'sanity' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' -import {structureUsEnglishLocaleBundle} from '../../../../structure/i18n' import {StudioAnnouncementsDialog} from '../StudioAnnouncementsDialog' import {type StudioAnnouncementDocument} from '../types' @@ -88,7 +87,7 @@ const config = defineConfig({ async function createAnnouncementWrapper() { const wrapper = await createTestProvider({ config, - resources: [structureUsEnglishLocaleBundle], + resources: [], }) return ({children}: {children: ReactNode}) => wrapper({children: <>{children}}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx index 1a4b63a3e93..7bcfa2cb10d 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx @@ -6,7 +6,6 @@ import {type ReactNode} from 'react' import {defineConfig} from 'sanity' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' -import {structureUsEnglishLocaleBundle} from '../../../../structure/i18n' import {StudioAnnouncementsMenuItem} from '../StudioAnnouncementsMenuItem' import {type StudioAnnouncementDocument} from '../types' import {useStudioAnnouncements} from '../useStudioAnnouncements' @@ -45,7 +44,7 @@ const config = defineConfig({ async function createAnnouncementWrapper() { const wrapper = await createTestProvider({ config, - resources: [structureUsEnglishLocaleBundle], + resources: [], }) return ({children}: {children: ReactNode}) => wrapper({children: {children}}) 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 e97a5aac2bd..abe5a767455 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -5,7 +5,6 @@ import {type ReactNode} from 'react' import {defineConfig} from 'sanity' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' -import {structureUsEnglishLocaleBundle} from '../../../../structure/i18n' import {StudioAnnouncementsProvider} from '../StudioAnnouncementsProvider' import {type StudioAnnouncementDocument} from '../types' import {useSeenAnnouncements} from '../useSeenAnnouncements' @@ -39,7 +38,7 @@ const config = defineConfig({ async function createAnnouncementWrapper() { const wrapper = await createTestProvider({ config, - resources: [structureUsEnglishLocaleBundle], + resources: [], }) return ({children}: {children: ReactNode}) => From e31b560698f024a9819f28fa355ca4c1e17156c7 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Tue, 17 Sep 2024 14:38:26 +0200 Subject: [PATCH 13/15] fix(core): update query to check expiry date --- .../sanity/src/core/studio/studioAnnouncements/query.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/query.ts b/packages/sanity/src/core/studio/studioAnnouncements/query.ts index 48c6131f5ce..786fc84b281 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/query.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/query.ts @@ -1,7 +1,12 @@ /** * TODO: Remove once the API call is implemented */ -export const studioAnnouncementQuery = `*[_type == "productAnnouncement"] | order(publishedDate desc) { +export const studioAnnouncementQuery = `*[_type == "productAnnouncement" && + ( + !defined(expiryDate) || + defined(expiryDate) && dateTime(expiryDate) > dateTime(now()) + )] + | order(publishedDate desc) { ..., body[]{ ..., From 55944a6b869185ec1f9fb65e0ed0be4683abc77d Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Wed, 18 Sep 2024 10:59:59 +0200 Subject: [PATCH 14/15] feat(core): add studioAnnouncements audienceRole check --- .../StudioAnnouncementsProvider.tsx | 22 ++++- .../StudioAnnouncementsProvider.test.tsx | 70 +++++++++++++- .../__tests__/utils.test.ts | 96 +++++++++++++++++++ .../core/studio/studioAnnouncements/types.ts | 12 +++ .../core/studio/studioAnnouncements/utils.ts | 38 ++++++-- .../UpsellDescriptionSerializer.tsx | 6 +- 6 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 packages/sanity/src/core/studio/studioAnnouncements/__tests__/utils.test.ts diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index 10f6727f57b..57e2b73540f 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -2,6 +2,7 @@ import {createClient} from '@sanity/client' import {useTelemetry} from '@sanity/telemetry/react' import {useCallback, useEffect, useMemo, useState} from 'react' +import {useWorkspace} from 'sanity' import {StudioAnnouncementContext} from 'sanity/_singletons' import {SANITY_VERSION} from '../../version' @@ -20,7 +21,7 @@ import { type StudioAnnouncementsContextValue, } from './types' import {useSeenAnnouncements} from './useSeenAnnouncements' -import {isValidAudience} from './utils' +import {isValidAnnouncementAudience, isValidAnnouncementRole} from './utils' interface StudioAnnouncementsProviderProps { children: React.ReactNode @@ -35,6 +36,7 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const [isCardDismissed, setIsCardDismissed] = useState(false) const [studioAnnouncements, setStudioAnnouncements] = useState([]) const [seenAnnouncements, setSeenAnnouncements] = useSeenAnnouncements() + const {currentUser} = useWorkspace() const unseenAnnouncements: StudioAnnouncementDocument[] = useMemo(() => { // If it's loading return an empty array to avoid showing the card @@ -57,21 +59,33 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi useEffect(() => { // TODO: Replace for internal api - const client = createClient({projectId: '3do82whm', dataset: 'next'}) + const client = createClient({ + projectId: 'm5jza465', + dataset: 'dev', + useCdn: false, + apiVersion: 'vX', + }) const subscription = client.observable .fetch(studioAnnouncementQuery) .subscribe({ next: (docs) => { - const validDocs = docs.filter((doc) => isValidAudience(doc, SANITY_VERSION)) + const validDocs = docs.filter( + (doc) => + isValidAnnouncementAudience( + {audience: doc.audience, studioVersion: doc.studioVersion}, + SANITY_VERSION, + ) && isValidAnnouncementRole(doc.audienceRole, currentUser?.roles), + ) setStudioAnnouncements(validDocs) }, error: (error) => { console.error('Error fetching studio announcements:', error) }, }) + // eslint-disable-next-line consistent-return return () => subscription.unsubscribe() - }, []) + }, [currentUser?.roles]) 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 abe5a767455..c76e46ceb78 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -514,7 +514,7 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.unseenAnnouncements).toEqual(announcements) expect(result.current.studioAnnouncements).toEqual(announcements) }) - test('if the audience is specific-version and studio matches ', () => { + test('if the audience is specific-version and studio matches', () => { const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { @@ -546,7 +546,7 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.unseenAnnouncements).toEqual(announcements) expect(result.current.studioAnnouncements).toEqual(announcements) }) - test('if the audience is specific-version and studio doesnt match ', () => { + test("if the audience is specific-version and studio doesn't match ", () => { const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { @@ -639,6 +639,72 @@ describe('StudioAnnouncementsProvider', () => { wrapper, }) + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test("if the audienceRole is fixed and user doesn't have the role", () => { + // mocked workspace roles is [ { name: 'administrator', title: 'Administrator' } ] + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audienceRole: ['developer'], + audience: 'everyone', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audienceRole is fixed and user has the role', () => { + // mocked workspace roles is [ { name: 'administrator', title: 'Administrator' } ] + const {createClient} = require('@sanity/client') + const announcements: StudioAnnouncementDocument[] = [ + { + _id: 'studioAnnouncement-1', + _type: 'productAnnouncement', + _rev: '1', + _createdAt: '2024-09-10T14:44:00.000Z', + _updatedAt: "2024-09-10T14:44:00.000Z'", + title: 'Announcement 1', + body: [], + announcementType: 'whats-new', + publishedDate: '2024-09-10T14:44:00.000Z', + audienceRole: ['administrator'], + audience: 'everyone', + }, + ] + const mockFetch = createClient().observable.fetch as jest.Mock + mockFetch.mockReturnValue({ + subscribe: ({next}: any) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + expect(result.current.unseenAnnouncements).toEqual(announcements) expect(result.current.studioAnnouncements).toEqual(announcements) }) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/utils.test.ts b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/utils.test.ts new file mode 100644 index 00000000000..e8636a8c577 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/utils.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, test} from '@jest/globals' + +import {isValidAnnouncementAudience, isValidAnnouncementRole} from '../utils' + +describe('isValidAnnouncementRole', () => { + const userRoles = [ + {name: 'developer', title: 'Developer'}, + {name: 'administrator', title: 'Administrator'}, + ] + + test('returns true when audienceRole is undefined', () => { + expect(isValidAnnouncementRole(undefined, userRoles)).toBe(true) + }) + test('returns true when user is undefined', () => { + expect(isValidAnnouncementRole(['administrator'], undefined)).toBe(false) + expect(isValidAnnouncementRole(['administrator'], [])).toBe(false) + }) + test("returns true if the user's role is in the audienceRole", () => { + expect(isValidAnnouncementRole(['administrator'], userRoles)).toBe(true) + }) + test("returns true if the user's role is in the audienceRole", () => { + expect(isValidAnnouncementRole(['developer', 'custom'], userRoles)).toBe(true) + }) + test("returns false if the user's role is not in the audienceRole", () => { + expect(isValidAnnouncementRole(['editor'], userRoles)).toBe(false) + }) + test("returns false if the user's role is not in the audienceRole", () => { + expect(isValidAnnouncementRole(['editor'], [{name: 'foo', title: 'Custom foo role'}])).toBe( + false, + ) + }) + test('returns false if the user has a custom role and we aim custom roles', () => { + expect( + isValidAnnouncementRole( + ['custom'], + [...userRoles, {name: 'foo', title: 'A custom foo role'}], + ), + ).toBe(true) + }) +}) + +describe('isValidAnnouncementAudience', () => { + test('should return true when audience is "everyone"', () => { + const announcement = {audience: 'everyone', studioVersion: undefined} as const + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(announcement, sanityVersion)).toBe(true) + }) + + describe('when audience is "specific-version"', () => { + const document = {audience: 'specific-version', studioVersion: '3.55.0'} as const + test('should return true when versions match', () => { + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(true) + }) + + test('should return false when versions do not match', () => { + const sanityVersion = '3.56.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(false) + }) + }) + + describe('when audience is "above-version"', () => { + const document = {audience: 'above-version', studioVersion: '3.55.0'} as const + test('should return true when sanityVersion is above document.studioVersion', () => { + const sanityVersion = '3.56.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(true) + }) + + test('should return false when sanityVersion is equal to document.studioVersion', () => { + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(false) + }) + + test('should return false when sanityVersion is below document.studioVersion', () => { + const sanityVersion = '3.54.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(false) + }) + }) + describe('when audience is "below-version"', () => { + const document = {audience: 'below-version', studioVersion: '3.55.0'} as const + test('should return false when sanityVersion is above document.studioVersion', () => { + const sanityVersion = '3.56.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(false) + }) + + test('should return false when sanityVersion is equal to document.studioVersion', () => { + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(false) + }) + + test('should return true when sanityVersion is below document.studioVersion', () => { + const sanityVersion = '3.54.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(true) + }) + }) +}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/types.ts b/packages/sanity/src/core/studio/studioAnnouncements/types.ts index 46c79102a2a..5eb436a1cac 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/types.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/types.ts @@ -1,5 +1,16 @@ import {type PortableTextBlock} from 'sanity' +export const audienceRoles = [ + 'administrator', + 'editor', + 'viewer', + 'contributor', + 'developer', + 'custom', +] as const + +export type AudienceRole = (typeof audienceRoles)[number] + export interface StudioAnnouncementDocument { _id: string _type: 'productAnnouncement' @@ -12,6 +23,7 @@ export interface StudioAnnouncementDocument { publishedDate: string expiryDate?: string audience: 'everyone' | 'specific-version' | 'above-version' | 'below-version' + audienceRole?: AudienceRole[] | undefined studioVersion?: string } diff --git a/packages/sanity/src/core/studio/studioAnnouncements/utils.ts b/packages/sanity/src/core/studio/studioAnnouncements/utils.ts index 6dc332200b1..090021cd128 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/utils.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/utils.ts @@ -1,31 +1,55 @@ +import {type Role} from 'sanity' import {satisfies} from 'semver' -import {type StudioAnnouncementDocument} from './types' +import {type AudienceRole, audienceRoles, type StudioAnnouncementDocument} from './types' /** * @internal * @hidden */ -export function isValidAudience( - document: StudioAnnouncementDocument, - studioVersion: string, +export function isValidAnnouncementAudience( + document: { + audience: StudioAnnouncementDocument['audience'] + studioVersion: StudioAnnouncementDocument['studioVersion'] + }, + sanityVersion: string, ): boolean { switch (document.audience) { case 'everyone': return true case 'specific-version': - return satisfies(studioVersion, `= ${document.studioVersion}`, { + return satisfies(sanityVersion, `= ${document.studioVersion}`, { includePrerelease: true, }) case 'above-version': - return satisfies(studioVersion, `> ${document.studioVersion}`, { + return satisfies(sanityVersion, `> ${document.studioVersion}`, { includePrerelease: true, }) case 'below-version': - return satisfies(studioVersion, `< ${document.studioVersion}`, { + return satisfies(sanityVersion, `< ${document.studioVersion}`, { includePrerelease: true, }) default: return true } } + +/** + * @internal + * @hidden + */ +export function isValidAnnouncementRole( + audience: StudioAnnouncementDocument['audienceRole'] | undefined, + userRoles: Role[] | undefined, +): boolean { + if (!audience || !audience.length) return true + if (!userRoles || !userRoles.length) return false + + if (userRoles.some((role) => audience.includes(role.name as AudienceRole))) return true + + // Check if the user has a custom role + if (userRoles.some((role) => !audienceRoles.includes(role.name as AudienceRole))) { + return audience.includes('custom') + } + return false +} diff --git a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx index dc52c5259f2..b0269be8e9e 100644 --- a/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx +++ b/packages/sanity/src/core/studio/upsell/upsellDescriptionSerializer/UpsellDescriptionSerializer.tsx @@ -68,9 +68,9 @@ const InlineIcon = styled(Icon)` } ` -const Link = styled.a<{useTextColor: boolean}>` +const Link = styled.a<{$useTextColor: boolean}>` font-weight: 600; - color: ${(props) => (props.useTextColor ? 'var(--card-muted-fg-color) !important' : '')}; + color: ${(props) => (props.$useTextColor ? 'var(--card-muted-fg-color) !important' : '')}; ` const DynamicIconContainer = styled.span<{$inline: boolean}>` @@ -225,7 +225,7 @@ const createComponents = ({ href={props.value.href} rel="noopener noreferrer" target="_blank" - useTextColor={props.value.useTextColor} + $useTextColor={props.value.useTextColor} // eslint-disable-next-line react/jsx-no-bind onClick={ onLinkClick From fa41f14113bf28ee0d9a62ed471924ab96067e15 Mon Sep 17 00:00:00 2001 From: pedrobonamin Date: Thu, 19 Sep 2024 10:40:26 +0200 Subject: [PATCH 15/15] feat(core): replace client.fetch for internal api --- .../sanity/src/core/studio/StudioProvider.tsx | 2 +- .../StudioAnnouncementsProvider.tsx | 37 ++- .../StudioAnnouncementsDialog.test.tsx | 1 - .../StudioAnnouncementsMenuItem.test.tsx | 1 - .../StudioAnnouncementsProvider.test.tsx | 254 +++++++----------- ...test.tsx => useSeenAnnouncements.test.tsx} | 35 ++- .../core/studio/studioAnnouncements/index.ts | 3 +- .../core/studio/studioAnnouncements/query.ts | 35 --- .../useSeenAnnouncements.ts | 25 +- 9 files changed, 157 insertions(+), 236 deletions(-) rename packages/sanity/src/core/studio/studioAnnouncements/__tests__/{useSeenDocuments.test.tsx => useSeenAnnouncements.test.tsx} (81%) delete mode 100644 packages/sanity/src/core/studio/studioAnnouncements/query.ts diff --git a/packages/sanity/src/core/studio/StudioProvider.tsx b/packages/sanity/src/core/studio/StudioProvider.tsx index aa26318e143..ff67112bb73 100644 --- a/packages/sanity/src/core/studio/StudioProvider.tsx +++ b/packages/sanity/src/core/studio/StudioProvider.tsx @@ -26,7 +26,7 @@ import { NotFoundScreen, } from './screens' import {type StudioProps} from './Studio' -import {StudioAnnouncementsProvider} from './studioAnnouncements' +import {StudioAnnouncementsProvider} from './studioAnnouncements/StudioAnnouncementsProvider' import {StudioErrorBoundary} from './StudioErrorBoundary' import {StudioTelemetryProvider} from './StudioTelemetryProvider' import {StudioThemeProvider} from './StudioThemeProvider' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx index 57e2b73540f..2185fdd4d6f 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsProvider.tsx @@ -1,10 +1,10 @@ /* eslint-disable camelcase */ -import {createClient} from '@sanity/client' import {useTelemetry} from '@sanity/telemetry/react' import {useCallback, useEffect, useMemo, useState} from 'react' -import {useWorkspace} from 'sanity' import {StudioAnnouncementContext} from 'sanity/_singletons' +import {useClient} from '../../hooks/useClient' +import {useWorkspace} from '../../studio/workspace' import {SANITY_VERSION} from '../../version' import { ProductAnnouncementCardClicked, @@ -12,7 +12,6 @@ import { ProductAnnouncementCardSeen, ProductAnnouncementModalDismissed, } from './__telemetry__/studioAnnouncements.telemetry' -import {studioAnnouncementQuery} from './query' import {StudioAnnouncementsCard} from './StudioAnnouncementsCard' import {StudioAnnouncementsDialog} from './StudioAnnouncementsDialog' import { @@ -26,6 +25,8 @@ import {isValidAnnouncementAudience, isValidAnnouncementRole} from './utils' interface StudioAnnouncementsProviderProps { children: React.ReactNode } +const CLIENT_OPTIONS = {apiVersion: 'v2024-09-19'} + /** * @internal * @hidden @@ -37,15 +38,16 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi const [studioAnnouncements, setStudioAnnouncements] = useState([]) const [seenAnnouncements, setSeenAnnouncements] = useSeenAnnouncements() const {currentUser} = useWorkspace() + const client = useClient(CLIENT_OPTIONS) const unseenAnnouncements: StudioAnnouncementDocument[] = useMemo(() => { - // If it's loading return an empty array to avoid showing the card - if (seenAnnouncements === 'loading') return [] + // 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) return studioAnnouncements + if (!seenAnnouncements.value) return studioAnnouncements // Filter out the seen announcements - const unseen = studioAnnouncements.filter((doc) => !seenAnnouncements.includes(doc._id)) + const unseen = studioAnnouncements.filter((doc) => !seenAnnouncements.value?.includes(doc._id)) if (unseen.length > 0) { telemetry.log(ProductAnnouncementCardSeen, { announcement_id: unseen[0]._id, @@ -58,18 +60,11 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi }, [seenAnnouncements, studioAnnouncements, telemetry]) useEffect(() => { - // TODO: Replace for internal api - const client = createClient({ - projectId: 'm5jza465', - dataset: 'dev', - useCdn: false, - apiVersion: 'vX', - }) - - const subscription = client.observable - .fetch(studioAnnouncementQuery) + const request = client.observable + .request({url: '/journey/announcements'}) .subscribe({ next: (docs) => { + if (!docs) return const validDocs = docs.filter( (doc) => isValidAnnouncementAudience( @@ -79,13 +74,13 @@ export function StudioAnnouncementsProvider({children}: StudioAnnouncementsProvi ) setStudioAnnouncements(validDocs) }, - error: (error) => { - console.error('Error fetching studio announcements:', error) + error: () => { + /* silently ignore any error */ }, }) // eslint-disable-next-line consistent-return - return () => subscription.unsubscribe() - }, [currentUser?.roles]) + return () => request.unsubscribe() + }, [currentUser?.roles, client]) const saveSeenAnnouncements = useCallback(() => { // Mark all the announcements as seen diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx index b50d2a9eb1c..70ade9d9e4f 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsDialog.test.tsx @@ -153,7 +153,6 @@ describe('StudioAnnouncementsCard', () => { const onCloseMock = jest.fn() const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') - // Set up the mock return value useTelemetry.mockReturnValue({log: mockLog}) const wrapper = await createAnnouncementWrapper() await render( diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx index 7bcfa2cb10d..c497a3ee288 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsMenuItem.test.tsx @@ -91,7 +91,6 @@ describe('StudioAnnouncementsMenuItem', () => { const onDialogOpenMock = jest.fn() const mockLog = jest.fn() const {useTelemetry} = require('@sanity/telemetry/react') - // Set up the mock return value useTelemetry.mockReturnValue({log: mockLog}) useStudioAnnouncementsMock.mockReturnValue({ 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 c76e46ceb78..7539158bf56 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/StudioAnnouncementsProvider.test.tsx @@ -5,6 +5,7 @@ import {type ReactNode} from 'react' import {defineConfig} from 'sanity' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' +import {useClient} from '../../../hooks/useClient' import {StudioAnnouncementsProvider} from '../StudioAnnouncementsProvider' import {type StudioAnnouncementDocument} from '../types' import {useSeenAnnouncements} from '../useSeenAnnouncements' @@ -24,13 +25,30 @@ jest.mock('@sanity/client', () => ({ }), })) +jest.mock('../../../hooks/useClient') +const useClientMock = useClient as jest.Mock + +const mockClient = (announcements: StudioAnnouncementDocument[]) => { + useClientMock.mockReturnValue({ + observable: { + request: () => ({ + // @ts-expect-error this intents to mock only the observable, not all the client. + subscribe: ({next}) => { + next(announcements) + return {unsubscribe: jest.fn()} + }, + }), + }, + }) +} + jest.mock('../useSeenAnnouncements') +const seenAnnouncementsMock = useSeenAnnouncements as jest.Mock + jest.mock('../../../version', () => ({ SANITY_VERSION: '3.57.0', })) -const seenAnnouncementsMock = useSeenAnnouncements as jest.Mock - const config = defineConfig({ projectId: 'test', dataset: 'test', @@ -45,7 +63,7 @@ async function createAnnouncementWrapper() { wrapper({children: {children}}) } -const mockAnnouncements = [ +const mockAnnouncements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', _type: 'productAnnouncement', @@ -80,16 +98,8 @@ describe('StudioAnnouncementsProvider', () => { describe('if seen announcements is loading', () => { beforeEach(() => { jest.clearAllMocks() - seenAnnouncementsMock.mockReturnValue(['loading', jest.fn()]) - const {createClient} = require('@sanity/client') - - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) + seenAnnouncementsMock.mockReturnValue([{loading: true, value: null, error: null}, jest.fn()]) + mockClient(mockAnnouncements) }) test('returns empty unseen announcements ', () => { const {result} = renderHook(() => useStudioAnnouncements(), { @@ -101,23 +111,36 @@ describe('StudioAnnouncementsProvider', () => { }) test("if unseen is empty, card doesn't show ", () => { const {queryByText} = render(null, {wrapper}) + expect(queryByText("What's new")).toBeNull() + }) + }) + describe('if seen announcements failed', () => { + beforeEach(() => { + jest.clearAllMocks() + seenAnnouncementsMock.mockReturnValue([ + {loading: false, value: null, error: new Error('something went wrong')}, + jest.fn(), + ]) + mockClient(mockAnnouncements) + }) + test('returns empty unseen announcements ', () => { + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual(mockAnnouncements) + }) + test("if unseen is empty, card doesn't show ", () => { + const {queryByText} = render(null, {wrapper}) expect(queryByText("What's new")).toBeNull() }) }) describe('if seen announcements is not loading and has no values', () => { beforeEach(() => { jest.clearAllMocks() - seenAnnouncementsMock.mockReturnValue([[], jest.fn()]) - - const {createClient} = require('@sanity/client') - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) + seenAnnouncementsMock.mockReturnValue([{value: [], error: null, loading: false}, jest.fn()]) + mockClient(mockAnnouncements) }) test('returns unseen announcements', () => { const {result} = renderHook(() => useStudioAnnouncements(), { @@ -137,16 +160,12 @@ describe('StudioAnnouncementsProvider', () => { beforeEach(() => { jest.clearAllMocks() // It doesn't show the first element - seenAnnouncementsMock.mockReturnValue([['studioAnnouncement-1'], jest.fn()]) + seenAnnouncementsMock.mockReturnValue([ + {value: ['studioAnnouncement-1'], error: null, loading: false}, + jest.fn(), + ]) - const {createClient} = require('@sanity/client') - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(mockAnnouncements) }) test('returns unseen announcements', () => { const {result} = renderHook(() => useStudioAnnouncements(), { @@ -166,16 +185,12 @@ describe('StudioAnnouncementsProvider', () => { beforeEach(() => { jest.clearAllMocks() // It doesn't show the first element - seenAnnouncementsMock.mockReturnValue([['studioAnnouncement-1'], jest.fn()]) + seenAnnouncementsMock.mockReturnValue([ + {value: ['studioAnnouncement-1'], error: null, loading: false}, + jest.fn(), + ]) - const {createClient} = require('@sanity/client') - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(mockAnnouncements) }) test('clicks on card, it opens dialog and card is hidden, shows only the unseen announcements', async () => { const mockLog = jest.fn() @@ -417,11 +432,10 @@ describe('StudioAnnouncementsProvider', () => { beforeEach(() => { jest.clearAllMocks() // It doesn't show the first element - seenAnnouncementsMock.mockReturnValue([[], jest.fn()]) + seenAnnouncementsMock.mockReturnValue([{value: [], error: null, loading: false}, jest.fn()]) }) test('if the audience is everyone, it shows the announcement regardless the version', () => { - const {createClient} = require('@sanity/client') - const announcements = [ + const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', _type: 'productAnnouncement', @@ -435,13 +449,7 @@ describe('StudioAnnouncementsProvider', () => { audience: 'everyone', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -451,7 +459,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual(announcements) }) test('if the audience is above-version and studio version is not above', () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -467,13 +474,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.57.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -483,7 +484,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual([]) }) test('if the audience is above-version and studio version is above', () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -499,13 +499,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.56.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -515,7 +509,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual(announcements) }) test('if the audience is specific-version and studio matches', () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -531,13 +524,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.57.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -547,7 +534,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual(announcements) }) test("if the audience is specific-version and studio doesn't match ", () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -563,13 +549,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.56.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -579,7 +559,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual([]) }) test('if the audience is below-version and studio is above', () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -595,13 +574,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.57.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -611,7 +584,6 @@ describe('StudioAnnouncementsProvider', () => { expect(result.current.studioAnnouncements).toEqual([]) }) test('if the audience is below-version and studio is below', () => { - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -627,13 +599,7 @@ describe('StudioAnnouncementsProvider', () => { studioVersion: '3.58.0', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -644,7 +610,6 @@ describe('StudioAnnouncementsProvider', () => { }) test("if the audienceRole is fixed and user doesn't have the role", () => { // mocked workspace roles is [ { name: 'administrator', title: 'Administrator' } ] - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -660,13 +625,7 @@ describe('StudioAnnouncementsProvider', () => { audience: 'everyone', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -677,7 +636,6 @@ describe('StudioAnnouncementsProvider', () => { }) test('if the audienceRole is fixed and user has the role', () => { // mocked workspace roles is [ { name: 'administrator', title: 'Administrator' } ] - const {createClient} = require('@sanity/client') const announcements: StudioAnnouncementDocument[] = [ { _id: 'studioAnnouncement-1', @@ -693,13 +651,7 @@ describe('StudioAnnouncementsProvider', () => { audience: 'everyone', }, ] - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(announcements) - return {unsubscribe: jest.fn()} - }, - }) + mockClient(announcements) const {result} = renderHook(() => useStudioAnnouncements(), { wrapper, @@ -714,17 +666,13 @@ describe('StudioAnnouncementsProvider', () => { jest.clearAllMocks() }) test('when the card is dismissed, and only 1 announcement received', () => { - const {createClient} = require('@sanity/client') const saveSeenAnnouncementsMock = jest.fn() - seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + seenAnnouncementsMock.mockReturnValue([ + {value: [], error: null, loading: false}, + saveSeenAnnouncementsMock, + ]) + mockClient([mockAnnouncements[0]]) - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next([mockAnnouncements[0]]) - return {unsubscribe: jest.fn()} - }, - }) const {getByLabelText} = render(null, {wrapper}) const closeButton = getByLabelText('Dismiss announcements') @@ -732,17 +680,13 @@ describe('StudioAnnouncementsProvider', () => { expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith([mockAnnouncements[0]._id]) }) test('when the card is dismissed, and 2 announcements are received', () => { - const {createClient} = require('@sanity/client') const saveSeenAnnouncementsMock = jest.fn() - seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + seenAnnouncementsMock.mockReturnValue([ + {value: [], error: null, loading: false}, + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) const {getByLabelText} = render(null, {wrapper}) const closeButton = getByLabelText('Dismiss announcements') @@ -750,18 +694,13 @@ describe('StudioAnnouncementsProvider', () => { expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) }) test("when the card is dismissed, doesn't persist previous stored values", () => { - const {createClient} = require('@sanity/client') 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([['not-to-be-persisted'], saveSeenAnnouncementsMock]) - - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) + seenAnnouncementsMock.mockReturnValue([ + {value: ['not-to-be-persisted'], error: null, loading: false}, + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) const {getByLabelText} = render(null, {wrapper}) const closeButton = getByLabelText('Dismiss announcements') @@ -769,18 +708,14 @@ describe('StudioAnnouncementsProvider', () => { expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) }) test('when the card is dismissed, persist previous stored values', () => { - const {createClient} = require('@sanity/client') const saveSeenAnnouncementsMock = jest.fn() // The id received here is present in the mock announcements, this id will be persisted in next save. - seenAnnouncementsMock.mockReturnValue([[mockAnnouncements[0]._id], saveSeenAnnouncementsMock]) + seenAnnouncementsMock.mockReturnValue([ + {value: [mockAnnouncements[0]._id], error: null, loading: false}, + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) const {getByLabelText} = render(null, {wrapper}) const closeButton = getByLabelText('Dismiss announcements') @@ -788,18 +723,13 @@ describe('StudioAnnouncementsProvider', () => { expect(saveSeenAnnouncementsMock).toHaveBeenCalledWith(mockAnnouncements.map((d) => d._id)) }) test('when the dialog is closed', () => { - const {createClient} = require('@sanity/client') const saveSeenAnnouncementsMock = jest.fn() - // The id received here is present in the mock announcements, this id will be persisted in next save. - seenAnnouncementsMock.mockReturnValue([[], saveSeenAnnouncementsMock]) + seenAnnouncementsMock.mockReturnValue([ + {value: [], error: null, loading: false}, + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) - const mockFetch = createClient().observable.fetch as jest.Mock - mockFetch.mockReturnValue({ - subscribe: ({next}: any) => { - next(mockAnnouncements) - return {unsubscribe: jest.fn()} - }, - }) const {getByLabelText} = render(null, {wrapper}) const openButton = getByLabelText('Open announcements') diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx similarity index 81% rename from packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx rename to packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx index 0495123532e..94eb5c15a66 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenDocuments.test.tsx +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx @@ -18,7 +18,6 @@ const useRouterMock = useRouter as jest.Mock describe('useSeenAnnouncements', () => { beforeEach(() => { - // Reset mocks before each test jest.clearAllMocks() }) test('should return "loading" initially and update when observable emits', async () => { @@ -29,7 +28,7 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) const {result} = renderHook(() => useSeenAnnouncements()) - expect(result.current[0]).toBe('loading') + expect(result.current[0]).toEqual({value: null, error: null, loading: true}) const seenAnnouncements = ['announcement1', 'announcement2'] act(() => { @@ -37,7 +36,30 @@ describe('useSeenAnnouncements', () => { }) await waitFor(() => { - expect(result.current[0]).toEqual(seenAnnouncements) + expect(result.current[0]).toEqual({value: seenAnnouncements, error: null, loading: false}) + }) + }) + test('should handle errors on the keyValueStore', async () => { + 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 error = new Error('An error occurred') + act(() => { + observable.error(error) + }) + + await waitFor(() => { + expect(result.current[0]).toEqual({ + value: null, + error: error, + loading: false, + }) }) }) @@ -62,7 +84,7 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) const {result} = renderHook(() => useSeenAnnouncements()) const [_, setSeenAnnouncements] = result.current - // Call the setSeenAnnouncements function + act(() => { setSeenAnnouncements(newSeenAnnouncements) }) @@ -80,7 +102,6 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) renderHook(() => useSeenAnnouncements()) - // Call the setSeenAnnouncements function await waitFor(() => { expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', ['foo', 'bar']) }) @@ -95,13 +116,12 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) renderHook(() => useSeenAnnouncements()) - // Call the setSeenAnnouncements function await waitFor(() => { expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', []) }) }) - test('when the key is not provided', async () => { + test('when the reset key is not provided', async () => { useRouterMock.mockReturnValue({ state: {_searchParams: []}, }) @@ -111,7 +131,6 @@ describe('useSeenAnnouncements', () => { useKeyValueStoreMock.mockReturnValue({getKey: getKeyMock, setKey: setKeyMock}) renderHook(() => useSeenAnnouncements()) - // Call the setSeenAnnouncements function await waitFor(() => { expect(setKeyMock).not.toHaveBeenCalled() }) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/index.ts b/packages/sanity/src/core/studio/studioAnnouncements/index.ts index cfc5140d3ec..4b6679f6f82 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/index.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/index.ts @@ -1,5 +1,4 @@ -// This exports are internal but consumed in the admin studio to generate the in studio previews. +// This exports are internal but consumed in the product-announcements studio to generate the in-studio previews. export * from './StudioAnnouncementsCard' export * from './StudioAnnouncementsDialog' -export * from './StudioAnnouncementsProvider' export * from './utils' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/query.ts b/packages/sanity/src/core/studio/studioAnnouncements/query.ts deleted file mode 100644 index 786fc84b281..00000000000 --- a/packages/sanity/src/core/studio/studioAnnouncements/query.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * TODO: Remove once the API call is implemented - */ -export const studioAnnouncementQuery = `*[_type == "productAnnouncement" && - ( - !defined(expiryDate) || - defined(expiryDate) && dateTime(expiryDate) > dateTime(now()) - )] - | order(publishedDate desc) { - ..., - body[]{ - ..., - _type == "imageBlock" => { - ..., - "image": { - "url": image.asset->.url - } - }, - _type == "iconAndText" => { - ..., - icon { - "url": asset->.url - } - }, - _type == "block" => { - ..., - children[] { - ..., - _type == "inlineIcon" => { - icon {"url": asset->.url} - } - } - } - } - }` diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts index b0a97e98b93..7bbcbc77d7e 100644 --- a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts +++ b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts @@ -1,6 +1,6 @@ import {useCallback, useEffect, useMemo} from 'react' import {useObservable} from 'react-rx' -import {type Observable} from 'rxjs' +import {catchError, map, of} from 'rxjs' import {useRouter} from 'sanity/router' import {useKeyValueStore} from '../../store/_legacy/datastores' @@ -8,15 +8,30 @@ import {useKeyValueStore} from '../../store/_legacy/datastores' const KEY = 'studio.announcement.seen' const RESET_PARAM = 'reset-announcements' -export function useSeenAnnouncements(): [string[] | null | 'loading', (seen: string[]) => void] { +interface SeenAnnouncementsState { + value: string[] | null + error: Error | null + loading: boolean +} +const INITIAL_STATE: SeenAnnouncementsState = { + value: null, + error: null, + loading: true, +} + +export function useSeenAnnouncements(): [SeenAnnouncementsState, (seen: string[]) => void] { const router = useRouter() - // Handles the communication with the key value store const keyValueStore = useKeyValueStore() const seenAnnouncements$ = useMemo( - () => keyValueStore.getKey(KEY) as Observable, + () => + keyValueStore.getKey(KEY).pipe( + map((value) => ({value: value as string[] | null, error: null, loading: false})), + catchError((error) => of({value: null, error: error, loading: false})), + ), [keyValueStore], ) - const seenAnnouncements = useObservable(seenAnnouncements$, 'loading') + + const seenAnnouncements = useObservable(seenAnnouncements$, INITIAL_STATE) const setSeenAnnouncements = useCallback( (seen: string[]) => {