Skip to content

Commit 3593bf5

Browse files
authored
feat(core): Studio announcements (#7515)
* feat(core): update UpsellDescriptionSerializer to support h3, images & lists * feat(core): studio announcements card and dialog gro-2493, gro-2498 * feat(core): add studio announcements telemetry events * feat(core): add studioAnnouncement provider with unseen modals check * feat(core): add studioAnnouncement menu item to Resources menu items * chore(core): improvements to studio announcements * chore(core): add tests for studio announcements * chore(core): add tests to save seen announcements actions * feat(core): update telemetry events for studioAnnouncements * fix(core): update useSeenAnnouncements to handle state reset * feat(core): add telemetry logs to announcement viewed and resources menu clicked * chore(core): remove translations resources in tests * fix(core): update query to check expiry date * feat(core): add studioAnnouncements audienceRole check * feat(core): replace client.fetch for internal api * fix(core): move cardSeen telemetry log to card * fix(core): add h2 to announcement dialog * chore(core): add divider fade threshold details * chore(core): refactor announcements provider fetch, use useObservable * chore(core): update useSeenAnnouncements, handle seen and unseen through rxjs * feat(core): update product announcement audience, (greater|less)-than-or-equal-version * feat(core): add support for card preHeader * fix(core): reduce studio announcements dialog height * chore(core): update studio announcements telemetry, add internal_name
1 parent a218c88 commit 3593bf5

25 files changed

+2371
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {createContext} from 'sanity/_createContext'
2+
3+
import type {StudioAnnouncementsContextValue} from '../../core/studio/studioAnnouncements/types'
4+
5+
/**
6+
* @internal
7+
*/
8+
export const StudioAnnouncementContext = createContext<StudioAnnouncementsContextValue | undefined>(
9+
'sanity/_singletons/context/studioAnnouncements',
10+
undefined,
11+
)

packages/sanity/src/_singletons/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export * from './context/SearchContext'
5555
export * from './context/SortableItemIdContext'
5656
export * from './context/SourceContext'
5757
export * from './context/StructureToolContext'
58+
export * from './context/StudioAnnouncementsContext'
5859
export * from './context/TasksContext'
5960
export * from './context/TasksEnabledContext'
6061
export * from './context/TasksNavigationContext'

packages/sanity/src/core/i18n/bundles/studio.ts

+10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import {type LocaleResourceBundle} from '../types'
1010
* @hidden
1111
*/
1212
export const studioLocaleStrings = defineLocalesResources('studio', {
13+
/** The text used in the tooltip shown in the dialog close button */
14+
'announcement.dialog.close': 'Close',
15+
/** Aria label to be used in the dialog close button */
16+
'announcement.dialog.close-label': 'Close dialog',
17+
/**Text to be used in the tooltip in the button in the studio announcement card */
18+
'announcement.floating-button.dismiss': 'Close',
19+
/**Aria label to be used in the floating button in the studio announcement card, to dismiss the card */
20+
'announcement.floating-button.dismiss-label': 'Dismiss announcements',
21+
/**Aria label to be used in the floating button in the studio announcement card */
22+
'announcement.floating-button.open-label': 'Open announcements',
1323
/** Menu item for deleting the asset */
1424
'asset-source.asset-list.menu.delete': 'Delete',
1525
/** Menu item for showing where a particular asset is used */

packages/sanity/src/core/studio/StudioProvider.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
NotFoundScreen,
2727
} from './screens'
2828
import {type StudioProps} from './Studio'
29+
import {StudioAnnouncementsProvider} from './studioAnnouncements/StudioAnnouncementsProvider'
2930
import {StudioErrorBoundary} from './StudioErrorBoundary'
3031
import {StudioTelemetryProvider} from './StudioTelemetryProvider'
3132
import {StudioThemeProvider} from './StudioThemeProvider'
@@ -69,7 +70,9 @@ export function StudioProvider({
6970
<LocaleProvider>
7071
<PackageVersionStatusProvider>
7172
<MaybeEnableErrorReporting errorReporter={errorReporter} />
72-
<ResourceCacheProvider>{children}</ResourceCacheProvider>
73+
<ResourceCacheProvider>
74+
<StudioAnnouncementsProvider>{children}</StudioAnnouncementsProvider>
75+
</ResourceCacheProvider>
7376
</PackageVersionStatusProvider>
7477
</LocaleProvider>
7578
</StudioTelemetryProvider>

packages/sanity/src/core/studio/components/navbar/resources/ResourcesMenuItems.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {LoadingBlock} from '../../../../components/loadingBlock'
55
import {hasSanityPackageInImportMap} from '../../../../environment/hasSanityPackageInImportMap'
66
import {useTranslation} from '../../../../i18n'
77
import {SANITY_VERSION} from '../../../../version'
8+
import {StudioAnnouncementsMenuItem} from '../../../studioAnnouncements/StudioAnnouncementsMenuItem'
89
import {type ResourcesResponse, type Section} from './helper-functions/types'
910

1011
interface ResourcesMenuItemProps {
@@ -97,6 +98,8 @@ function SubSection({subSection}: {subSection: Section}) {
9798
)
9899
case 'internalAction': // TODO: Add support for internal actions (MVI-2)
99100
if (!item.type) return null
101+
if (item.type === 'studio-announcements-modal')
102+
return <StudioAnnouncementsMenuItem text={item.title} />
100103
return (
101104
item.type === 'show-welcome-modal' && <MenuItem key={item._key} text={item.title} />
102105
)

packages/sanity/src/core/studio/components/navbar/resources/helper-functions/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ interface InternalAction extends Item {
5151
type?: InternalActionType
5252
}
5353

54-
type InternalActionType = 'show-welcome-modal'
54+
type InternalActionType = 'show-welcome-modal' | 'studio-announcements-modal'
5555

5656
/**
5757
* @hidden

packages/sanity/src/core/studio/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './copyPaste'
66
export * from './renderStudio'
77
export * from './source'
88
export * from './Studio'
9+
export * from './studioAnnouncements'
910
export * from './StudioLayout'
1011
export * from './StudioProvider'
1112
export * from './upsell'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {Box} from '@sanity/ui'
2+
import {useEffect, useRef, useState} from 'react'
3+
import {styled} from 'styled-components'
4+
5+
const Hr = styled.hr<{$show: boolean}>`
6+
height: 1px;
7+
background: var(--card-border-color);
8+
width: 100%;
9+
opacity: ${({$show}) => ($show ? 1 : 0)};
10+
transition: opacity 0.3s ease;
11+
margin: 0;
12+
border: none;
13+
`
14+
15+
interface DividerProps {
16+
parentRef: React.RefObject<HTMLDivElement>
17+
}
18+
19+
/**
20+
* This is the threshold for the divider to start fading
21+
* uses a negative value to start fading before reaching the top
22+
* of the parent.
23+
* We want to fade out the divider so it doesn't overlap with the close icon when reaching the top.
24+
* It's the sum of the title height (48px) and the divider padding top (12px)
25+
*/
26+
const DIVIDER_FADE_THRESHOLD = '-60px 0px 0px 0px'
27+
28+
/**
29+
* A divider that fades when reaching the top of the parent.
30+
*/
31+
export function Divider({parentRef}: DividerProps): JSX.Element {
32+
const itemRef = useRef<HTMLHRElement | null>(null)
33+
const [show, setShow] = useState(true)
34+
35+
useEffect(() => {
36+
const item = itemRef.current
37+
const parent = parentRef.current
38+
39+
if (!item || !parent) return
40+
const observer = new IntersectionObserver(
41+
([entry]) => {
42+
setShow(entry.isIntersecting)
43+
},
44+
{root: parent, threshold: 0, rootMargin: DIVIDER_FADE_THRESHOLD},
45+
)
46+
47+
observer.observe(item)
48+
49+
// eslint-disable-next-line consistent-return
50+
return () => {
51+
observer.disconnect()
52+
}
53+
}, [parentRef])
54+
55+
return (
56+
<Box paddingBottom={4}>
57+
<Box paddingY={3} paddingX={3}>
58+
<Hr ref={itemRef} $show={show} />
59+
</Box>
60+
</Box>
61+
)
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/* eslint-disable camelcase */
2+
import {RemoveIcon} from '@sanity/icons'
3+
import {useTelemetry} from '@sanity/telemetry/react'
4+
import {Box, Card, Stack, Text} from '@sanity/ui'
5+
// eslint-disable-next-line camelcase
6+
import {getTheme_v2} from '@sanity/ui/theme'
7+
import {useEffect} from 'react'
8+
import {useTranslation} from 'sanity'
9+
import {css, keyframes, styled} from 'styled-components'
10+
11+
import {Button, Popover} from '../../../ui-components'
12+
import {SANITY_VERSION} from '../../version'
13+
import {ProductAnnouncementCardSeen} from './__telemetry__/studioAnnouncements.telemetry'
14+
15+
const keyframe = keyframes`
16+
0% {
17+
background-position: 100%;
18+
}
19+
100% {
20+
background-position: -100%;
21+
}
22+
`
23+
24+
const Root = styled.div((props) => {
25+
const theme = getTheme_v2(props.theme)
26+
const cardHoverBg = theme.color.selectable.default.hovered.bg
27+
const cardNormalBg = theme.color.selectable.default.enabled.bg
28+
29+
return css`
30+
position: relative;
31+
cursor: pointer;
32+
// hide the close button
33+
#close-floating-button {
34+
opacity: 0;
35+
transition: opacity 0.2s;
36+
}
37+
38+
&:hover {
39+
> [data-ui='whats-new-card'] {
40+
--card-bg-color: ${cardHoverBg};
41+
box-shadow: inset 0 0 2px 1px var(--card-skeleton-color-to);
42+
background-image: linear-gradient(
43+
to right,
44+
var(--card-bg-color),
45+
var(--card-bg-color),
46+
${cardNormalBg},
47+
var(--card-bg-color),
48+
var(--card-bg-color),
49+
var(--card-bg-color)
50+
);
51+
background-position: 100%;
52+
background-size: 200% 100%;
53+
background-attachment: fixed;
54+
animation-name: ${keyframe};
55+
animation-timing-function: ease-in;
56+
animation-iteration-count: infinite;
57+
animation-duration: 2000ms;
58+
}
59+
#close-floating-button {
60+
opacity: 1;
61+
background: transparent;
62+
63+
&:hover {
64+
transition: all 0.2s;
65+
box-shadow: 0 0 0 1px ${theme.color.selectable.default.hovered.border};
66+
}
67+
}
68+
}
69+
`
70+
})
71+
72+
const ButtonRoot = styled.div`
73+
z-index: 1;
74+
position: absolute;
75+
top: 4px;
76+
right: 6px;
77+
`
78+
79+
interface StudioAnnouncementCardProps {
80+
title: string
81+
id: string
82+
name: string
83+
isOpen: boolean
84+
preHeader: string
85+
onCardClick: () => void
86+
onCardDismiss: () => void
87+
}
88+
89+
/**
90+
* @internal
91+
* @hidden
92+
*/
93+
export function StudioAnnouncementsCard({
94+
title,
95+
id,
96+
isOpen,
97+
name,
98+
preHeader,
99+
onCardClick,
100+
onCardDismiss,
101+
}: StudioAnnouncementCardProps) {
102+
const {t} = useTranslation()
103+
const telemetry = useTelemetry()
104+
105+
useEffect(() => {
106+
if (isOpen) {
107+
telemetry.log(ProductAnnouncementCardSeen, {
108+
announcement_id: id,
109+
announcement_title: title,
110+
announcement_internal_name: name,
111+
source: 'studio',
112+
studio_version: SANITY_VERSION,
113+
})
114+
}
115+
}, [telemetry, id, title, isOpen, name])
116+
117+
return (
118+
<Popover
119+
open={isOpen}
120+
shadow={3}
121+
portal
122+
style={{
123+
bottom: 12,
124+
left: 12,
125+
top: 'none',
126+
}}
127+
width={0}
128+
placement="bottom-start"
129+
content={
130+
<Root data-ui="whats-new-root">
131+
<Card
132+
data-ui="whats-new-card"
133+
padding={3}
134+
radius={3}
135+
onClick={onCardClick}
136+
role="button"
137+
aria-label={t('announcement.floating-button.open-label')}
138+
>
139+
<Stack space={3}>
140+
<Box marginRight={6}>
141+
<Text as={'h3'} size={1} muted>
142+
{preHeader}
143+
</Text>
144+
</Box>
145+
<Text size={1} weight="medium">
146+
{title}
147+
</Text>
148+
</Stack>
149+
</Card>
150+
<ButtonRoot>
151+
<Button
152+
id="close-floating-button"
153+
mode="bleed"
154+
onClick={onCardDismiss}
155+
icon={RemoveIcon}
156+
tone="default"
157+
aria-label={t('announcement.floating-button.dismiss-label')}
158+
tooltipProps={{
159+
content: t('announcement.floating-button.dismiss'),
160+
}}
161+
/>
162+
</ButtonRoot>
163+
</Root>
164+
}
165+
/>
166+
)
167+
}

0 commit comments

Comments
 (0)