diff --git a/packages/sanity/src/_singletons/context/StudioAnnouncementsContext.ts b/packages/sanity/src/_singletons/context/StudioAnnouncementsContext.ts new file mode 100644 index 00000000000..702cb3c9f7a --- /dev/null +++ b/packages/sanity/src/_singletons/context/StudioAnnouncementsContext.ts @@ -0,0 +1,11 @@ +import {createContext} from 'sanity/_createContext' + +import type {StudioAnnouncementsContextValue} from '../../core/studio/studioAnnouncements/types' + +/** + * @internal + */ +export const StudioAnnouncementContext = createContext( + 'sanity/_singletons/context/studioAnnouncements', + undefined, +) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index ca77d8f8c1d..05761b9609f 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -55,6 +55,7 @@ export * from './context/SearchContext' export * from './context/SortableItemIdContext' export * from './context/SourceContext' export * from './context/StructureToolContext' +export * from './context/StudioAnnouncementsContext' export * from './context/TasksContext' export * from './context/TasksEnabledContext' export * from './context/TasksNavigationContext' diff --git a/packages/sanity/src/core/i18n/bundles/studio.ts b/packages/sanity/src/core/i18n/bundles/studio.ts index cebc6c89600..e46949db912 100644 --- a/packages/sanity/src/core/i18n/bundles/studio.ts +++ b/packages/sanity/src/core/i18n/bundles/studio.ts @@ -10,6 +10,16 @@ import {type LocaleResourceBundle} from '../types' * @hidden */ export const studioLocaleStrings = defineLocalesResources('studio', { + /** The text used in the tooltip shown in the dialog close button */ + 'announcement.dialog.close': 'Close', + /** Aria label to be used in the dialog close button */ + 'announcement.dialog.close-label': 'Close dialog', + /**Text to be used in the tooltip in the button in the studio announcement card */ + 'announcement.floating-button.dismiss': 'Close', + /**Aria label to be used in the floating button in the studio announcement card, to dismiss the card */ + 'announcement.floating-button.dismiss-label': 'Dismiss announcements', + /**Aria label to be used in the floating button in the studio announcement card */ + 'announcement.floating-button.open-label': 'Open announcements', /** Menu item for deleting the asset */ 'asset-source.asset-list.menu.delete': 'Delete', /** Menu item for showing where a particular asset is used */ diff --git a/packages/sanity/src/core/studio/StudioProvider.tsx b/packages/sanity/src/core/studio/StudioProvider.tsx index 73420f54c48..ff67112bb73 100644 --- a/packages/sanity/src/core/studio/StudioProvider.tsx +++ b/packages/sanity/src/core/studio/StudioProvider.tsx @@ -26,6 +26,7 @@ import { NotFoundScreen, } from './screens' import {type StudioProps} from './Studio' +import {StudioAnnouncementsProvider} from './studioAnnouncements/StudioAnnouncementsProvider' import {StudioErrorBoundary} from './StudioErrorBoundary' import {StudioTelemetryProvider} from './StudioTelemetryProvider' import {StudioThemeProvider} from './StudioThemeProvider' @@ -69,7 +70,9 @@ export function StudioProvider({ - {children} + + {children} + diff --git a/packages/sanity/src/core/studio/components/navbar/resources/ResourcesMenuItems.tsx b/packages/sanity/src/core/studio/components/navbar/resources/ResourcesMenuItems.tsx index 854058d7d0f..6b038a1d6f4 100644 --- a/packages/sanity/src/core/studio/components/navbar/resources/ResourcesMenuItems.tsx +++ b/packages/sanity/src/core/studio/components/navbar/resources/ResourcesMenuItems.tsx @@ -5,6 +5,7 @@ import {LoadingBlock} from '../../../../components/loadingBlock' import {hasSanityPackageInImportMap} from '../../../../environment/hasSanityPackageInImportMap' import {useTranslation} from '../../../../i18n' import {SANITY_VERSION} from '../../../../version' +import {StudioAnnouncementsMenuItem} from '../../../studioAnnouncements/StudioAnnouncementsMenuItem' import {type ResourcesResponse, type Section} from './helper-functions/types' interface ResourcesMenuItemProps { @@ -97,6 +98,8 @@ function SubSection({subSection}: {subSection: Section}) { ) case 'internalAction': // TODO: Add support for internal actions (MVI-2) if (!item.type) return null + if (item.type === 'studio-announcements-modal') + return return ( item.type === 'show-welcome-modal' && ) diff --git a/packages/sanity/src/core/studio/components/navbar/resources/helper-functions/types.ts b/packages/sanity/src/core/studio/components/navbar/resources/helper-functions/types.ts index c600b199c3f..480d562dab7 100644 --- a/packages/sanity/src/core/studio/components/navbar/resources/helper-functions/types.ts +++ b/packages/sanity/src/core/studio/components/navbar/resources/helper-functions/types.ts @@ -51,7 +51,7 @@ interface InternalAction extends Item { type?: InternalActionType } -type InternalActionType = 'show-welcome-modal' +type InternalActionType = 'show-welcome-modal' | 'studio-announcements-modal' /** * @hidden 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..bf94a2fbd4c --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/Divider.tsx @@ -0,0 +1,62 @@ +import {Box} from '@sanity/ui' +import {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; +` + +interface DividerProps { + parentRef: React.RefObject +} + +/** + * This is the threshold for the divider to start fading + * uses a negative value to start fading before reaching the top + * of the parent. + * We want to fade out the divider so it doesn't overlap with the close icon when reaching the top. + * It's the sum of the title height (48px) and the divider padding top (12px) + */ +const DIVIDER_FADE_THRESHOLD = '-60px 0px 0px 0px' + +/** + * A divider that fades when reaching the top of the parent. + */ +export function Divider({parentRef}: DividerProps): JSX.Element { + const itemRef = useRef(null) + const [show, setShow] = useState(true) + + useEffect(() => { + const item = itemRef.current + const parent = parentRef.current + + if (!item || !parent) return + const observer = new IntersectionObserver( + ([entry]) => { + setShow(entry.isIntersecting) + }, + {root: parent, threshold: 0, rootMargin: DIVIDER_FADE_THRESHOLD}, + ) + + observer.observe(item) + + // eslint-disable-next-line consistent-return + return () => { + observer.disconnect() + } + }, [parentRef]) + + return ( + + +
+
+
+ ) +} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsCard.tsx b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsCard.tsx new file mode 100644 index 00000000000..01a34276bdb --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/StudioAnnouncementsCard.tsx @@ -0,0 +1,167 @@ +/* eslint-disable camelcase */ +import {RemoveIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Card, Stack, Text} from '@sanity/ui' +// eslint-disable-next-line camelcase +import {getTheme_v2} from '@sanity/ui/theme' +import {useEffect} from 'react' +import {useTranslation} from 'sanity' +import {css, keyframes, styled} from 'styled-components' + +import {Button, Popover} from '../../../ui-components' +import {SANITY_VERSION} from '../../version' +import {ProductAnnouncementCardSeen} from './__telemetry__/studioAnnouncements.telemetry' + +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; + } + #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 ButtonRoot = styled.div` + z-index: 1; + position: absolute; + top: 4px; + right: 6px; +` + +interface StudioAnnouncementCardProps { + title: string + id: string + name: string + isOpen: boolean + preHeader: string + onCardClick: () => void + onCardDismiss: () => void +} + +/** + * @internal + * @hidden + */ +export function StudioAnnouncementsCard({ + title, + id, + isOpen, + name, + preHeader, + onCardClick, + onCardDismiss, +}: StudioAnnouncementCardProps) { + const {t} = useTranslation() + const telemetry = useTelemetry() + + useEffect(() => { + if (isOpen) { + telemetry.log(ProductAnnouncementCardSeen, { + announcement_id: id, + announcement_title: title, + announcement_internal_name: name, + source: 'studio', + studio_version: SANITY_VERSION, + }) + } + }, [telemetry, id, title, isOpen, name]) + + return ( + + + + + + {preHeader} + + + + {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 + 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 + 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([ + of({value: [], error: null, loading: false}), + jest.fn(), + ]) + }) + + test('if the audience is everyone, it shows the announcement regardless the version', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'everyone', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is greater-than-or-equal-version and studio version is not above', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'greater-than-or-equal-version', + studioVersion: '3.58.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is greater-than-or-equal-version and studio version is above', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'greater-than-or-equal-version', + studioVersion: '3.56.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is above-equal.version and studio version is equal', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'greater-than-or-equal-version', + studioVersion: '3.57.0', + }, + ] + mockClient(announcements) + + 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 announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'specific-version', + studioVersion: '3.57.0', + }, + ] + mockClient(announcements) + + 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 doesn't match ", () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'specific-version', + studioVersion: '3.56.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is less-than-or-equal-version and studio is above', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'less-than-or-equal-version', + studioVersion: '3.56.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual([]) + expect(result.current.studioAnnouncements).toEqual([]) + }) + test('if the audience is less-than-or-equal-version and studio is below', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'less-than-or-equal-version', + studioVersion: '3.58.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + expect(result.current.studioAnnouncements).toEqual(announcements) + }) + test('if the audience is less-than-or-equal-version and studio is equal', () => { + const announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audience: 'less-than-or-equal-version', + studioVersion: '3.57.0', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + 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 announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audienceRole: ['developer'], + audience: 'everyone', + }, + ] + mockClient(announcements) + + 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 announcements: StudioAnnouncementDocument[] = [ + { + ...mockAnnouncements[1], + audienceRole: ['administrator'], + audience: 'everyone', + }, + ] + mockClient(announcements) + + const {result} = renderHook(() => useStudioAnnouncements(), { + wrapper, + }) + + expect(result.current.unseenAnnouncements).toEqual(announcements) + 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 saveSeenAnnouncementsMock = jest.fn() + seenAnnouncementsMock.mockReturnValue([ + of({value: [], error: null, loading: false}), + saveSeenAnnouncementsMock, + ]) + mockClient([mockAnnouncements[0]]) + + 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 saveSeenAnnouncementsMock = jest.fn() + seenAnnouncementsMock.mockReturnValue([ + of({value: [], error: null, loading: false}), + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) + + 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 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([ + of({value: ['not-to-be-persisted'], error: null, loading: false}), + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) + 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 saveSeenAnnouncementsMock = jest.fn() + // The id received here is present in the mock announcements, this id will be persisted in next save. + seenAnnouncementsMock.mockReturnValue([ + of({value: [mockAnnouncements[0]._id], error: null, loading: false}), + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) + + 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 saveSeenAnnouncementsMock = jest.fn() + seenAnnouncementsMock.mockReturnValue([ + of({value: [], error: null, loading: false}), + saveSeenAnnouncementsMock, + ]) + mockClient(mockAnnouncements) + + 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)) + }) + }) +}) diff --git a/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx new file mode 100644 index 00000000000..ba610997ad2 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/__tests__/useSeenAnnouncements.test.tsx @@ -0,0 +1,148 @@ +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 {type SeenAnnouncementsState, useSeenAnnouncements} from '../useSeenAnnouncements' + +jest.mock('../../../store/_legacy/datastores', () => ({ + useKeyValueStore: jest.fn(), +})) + +const useKeyValueStoreMock = useKeyValueStore as jest.Mock +jest.mock('sanity/router', () => ({ + useRouter: jest.fn().mockReturnValue({state: {}}), +})) +const useRouterMock = useRouter as jest.Mock + +describe('useSeenAnnouncements', () => { + beforeEach(() => { + 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()) + const seenAnnouncements$ = result.current[0] + const seenAnnouncements = ['announcement1', 'announcement2'] + + const expectedStates: SeenAnnouncementsState[] = [ + {value: null, error: null, loading: true}, + {value: seenAnnouncements, error: null, loading: false}, + ] + const emissions: SeenAnnouncementsState[] = [] + + seenAnnouncements$.subscribe((state) => { + emissions.push(state) + }) + + act(() => { + observable.next(seenAnnouncements) + }) + expect(emissions).toEqual(expectedStates) + }) + 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()) + const seenAnnouncements$ = result.current[0] + + const emissions: SeenAnnouncementsState[] = [] + + seenAnnouncements$.subscribe((state) => { + emissions.push(state) + }) + + const error = new Error('An error occurred') + act(() => { + observable.error(error) + }) + const expectedStates: SeenAnnouncementsState[] = [ + {value: null, error: null, loading: true}, + {value: null, error: error, loading: false}, + ] + expect(emissions).toEqual(expectedStates) + }) + + test('should call the getKey function with the correct key when the hook is called', () => { + 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 + + act(() => { + setSeenAnnouncements(newSeenAnnouncements) + }) + + 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()) + + 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()) + + await waitFor(() => { + expect(setKeyMock).toHaveBeenCalledWith('studio.announcement.seen', []) + }) + }) + + test('when the reset 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()) + + await waitFor(() => { + expect(setKeyMock).not.toHaveBeenCalled() + }) + }) + }) +}) 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..823a80a5de6 --- /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: 'greater-than-or-equal-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 true when sanityVersion is equal to document.studioVersion', () => { + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(true) + }) + + 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: 'less-than-or-equal-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 true when sanityVersion is equal to document.studioVersion', () => { + const sanityVersion = '3.55.0' + expect(isValidAnnouncementAudience(document, sanityVersion)).toBe(true) + }) + + 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/index.ts b/packages/sanity/src/core/studio/studioAnnouncements/index.ts new file mode 100644 index 00000000000..4b6679f6f82 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/index.ts @@ -0,0 +1,4 @@ +// 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 './utils' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/types.ts b/packages/sanity/src/core/studio/studioAnnouncements/types.ts new file mode 100644 index 00000000000..0e841435be9 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/types.ts @@ -0,0 +1,43 @@ +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' + _rev: string + _createdAt: string + _updatedAt: string + title: string + name: string + body: PortableTextBlock[] + announcementType: 'whats-new' + publishedDate: string + expiryDate?: string + audience: + | 'everyone' + | 'specific-version' + | 'greater-than-or-equal-version' + | 'less-than-or-equal-version' + audienceRole?: AudienceRole[] | undefined + studioVersion?: string + preHeader: string +} + +export interface StudioAnnouncementsContextValue { + studioAnnouncements: StudioAnnouncementDocument[] + unseenAnnouncements: StudioAnnouncementDocument[] + onDialogOpen: (mode: DialogMode) => void +} + +// Decides weather to show all the announcements or only the unseen ones +export type DialogMode = 'card' | 'help_menu' diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts new file mode 100644 index 00000000000..5cb23844f86 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/useSeenAnnouncements.ts @@ -0,0 +1,57 @@ +import {useCallback, useEffect, useMemo} from 'react' +import {catchError, map, type Observable, of, startWith} from 'rxjs' +import {useRouter} from 'sanity/router' + +import {useKeyValueStore} from '../../store/_legacy/datastores' + +const KEY = 'studio.announcement.seen' +const RESET_PARAM = 'reset-announcements' + +export interface SeenAnnouncementsState { + value: string[] | null + error: Error | null + loading: boolean +} +const INITIAL_STATE: SeenAnnouncementsState = { + value: null, + error: null, + loading: true, +} + +export function useSeenAnnouncements(): [ + Observable, + (seen: string[]) => void, +] { + const router = useRouter() + const keyValueStore = useKeyValueStore() + const seenAnnouncements$: Observable = useMemo( + () => + keyValueStore.getKey(KEY).pipe( + map((value) => ({value: value as string[] | null, error: null, loading: false})), + startWith(INITIAL_STATE), + catchError((error) => of({value: null, error: error, loading: false})), + ), + [keyValueStore], + ) + + const setSeenAnnouncements = useCallback( + (seen: string[]) => { + 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] +} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/useStudioAnnouncements.tsx b/packages/sanity/src/core/studio/studioAnnouncements/useStudioAnnouncements.tsx new file mode 100644 index 00000000000..2ff5fba557f --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/useStudioAnnouncements.tsx @@ -0,0 +1,13 @@ +import {useContext} from 'react' +import {StudioAnnouncementContext} from 'sanity/_singletons' + +import {type StudioAnnouncementsContextValue} from './types' + +export function useStudioAnnouncements(): StudioAnnouncementsContextValue { + const context = useContext(StudioAnnouncementContext) + if (!context) { + throw new Error('useStudioAnnouncements: missing context value') + } + + return context +} diff --git a/packages/sanity/src/core/studio/studioAnnouncements/utils.ts b/packages/sanity/src/core/studio/studioAnnouncements/utils.ts new file mode 100644 index 00000000000..b2142ce83c6 --- /dev/null +++ b/packages/sanity/src/core/studio/studioAnnouncements/utils.ts @@ -0,0 +1,55 @@ +import {type Role} from 'sanity' +import {satisfies} from 'semver' + +import {type AudienceRole, audienceRoles, type StudioAnnouncementDocument} from './types' + +/** + * @internal + * @hidden + */ +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(sanityVersion, `= ${document.studioVersion}`, { + includePrerelease: true, + }) + case 'greater-than-or-equal-version': + return satisfies(sanityVersion, `>= ${document.studioVersion}`, { + includePrerelease: true, + }) + case 'less-than-or-equal-version': + 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 d4a3757a703..b0269be8e9e 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' @@ -62,16 +68,22 @@ 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` +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,78 @@ function HeadingBlock(props: {children: ReactNode}) { ) } -const components: PortableTextComponents = { +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 createComponents = ({ + onLinkClick, +}: { + onLinkClick?: ({url, linkTitle}: {url: string; linkTitle: string}) => void +}): 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: { @@ -155,7 +225,17 @@ const components: PortableTextComponents = { 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 + ? () => + onLinkClick({ + url: props.value.href, + linkTitle: props.text, + }) + : undefined + } > {props.children} {props.value.showIcon && } @@ -174,7 +254,7 @@ const components: PortableTextComponents = { $hasTextRight={props.value.hasTextRight} /> ) : ( - + <>{props.value.icon?.url && } )} ) @@ -193,7 +273,7 @@ const components: PortableTextComponents = { {props.value.sanityIcon ? ( ) : ( - + <>{props.value.icon?.url && } )} @@ -206,11 +286,13 @@ const components: PortableTextComponents = { ), + imageBlock: (props) => , }, -} +}) interface DescriptionSerializerProps { blocks: PortableTextBlock[] + onLinkClick?: ({url, linkTitle}: {url: string; linkTitle: string}) => void } /** @@ -219,7 +301,11 @@ interface DescriptionSerializerProps { * @internal */ export function UpsellDescriptionSerializer(props: DescriptionSerializerProps) { - const value = useMemo(() => transformBlocks(props.blocks), [props.blocks]) + const {blocks, onLinkClick} = props + + const value = useMemo(() => transformBlocks(blocks), [blocks]) + const components = useMemo(() => createComponents({onLinkClick: onLinkClick}), [onLinkClick]) + return (