Skip to content

Commit 3dcd7e0

Browse files
authored
feat: Persist feature prompt dismiss (#4360)
1 parent 3ea9572 commit 3dcd7e0

File tree

2 files changed

+115
-6
lines changed

2 files changed

+115
-6
lines changed

src/app-layout/__tests__/runtime-feature-notifications.test.tsx

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,10 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
334334
});
335335

336336
test('shows feature prompt for a latest unseen features', async () => {
337-
mockRetrieveFeatureNotifications.mockResolvedValue({ 'feature-1': mockDate2025.toString() });
337+
mockRetrieveFeatureNotifications.mockResolvedValue({
338+
'feature-1': mockDate2025.toString(),
339+
'feature-1_feature-prompt': mockDate2025.toString(),
340+
});
338341
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
339342
const { container } = renderComponent(<AppLayout />);
340343
await delay();
@@ -376,7 +379,7 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
376379
'feature-2': mockDate2024.toString(),
377380
'feature-old': mockDateOld.toString(),
378381
});
379-
featureNotifications.registerFeatureNotifications({ ...featureNotificationsDefaults, suppressFeaturePrompt: true });
382+
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
380383
const { container } = renderComponent(<AppLayout />);
381384

382385
const featurePromptWrapper = new FeaturePromptWrapper(container);
@@ -408,9 +411,13 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
408411

409412
wrapper.findDrawerTriggerById(featureNotificationsDefaults.id)!.click();
410413

411-
expect(mockPersistFeatureNotifications).toHaveBeenCalled();
412-
413-
const persistedFeaturesMap = mockPersistFeatureNotifications.mock.calls[0][1];
414+
// The first persist call may be the feature prompt dismissal (if the prompt was shown and auto-dismissed).
415+
// The "mark all as read" call is the one that contains all feature IDs.
416+
const markAllAsReadCall = mockPersistFeatureNotifications.mock.calls.find(
417+
call => call[1]['feature-1'] && call[1]['feature-2']
418+
);
419+
expect(markAllAsReadCall).toBeTruthy();
420+
const persistedFeaturesMap = markAllAsReadCall![1];
414421

415422
expect(persistedFeaturesMap).toHaveProperty('feature-1');
416423
expect(persistedFeaturesMap).toHaveProperty('feature-2');
@@ -432,6 +439,87 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
432439
expect(wrapper.findDrawerTriggerById('empty-features')).toBeFalsy();
433440
});
434441

442+
test('does not show feature prompt if it was previously dismissed (persisted)', async () => {
443+
// Simulate that the feature prompt for feature-1 was previously dismissed and persisted
444+
mockRetrieveFeatureNotifications.mockResolvedValue({
445+
'feature-1_feature-prompt': mockDate2025.toString(),
446+
});
447+
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
448+
const { container } = renderComponent(<AppLayout />);
449+
await delay();
450+
451+
const featurePromptWrapper = new FeaturePromptWrapper(container);
452+
// Feature prompt should not appear because the latest unseen feature's prompt was already dismissed
453+
expect(featurePromptWrapper.findContent()).toBeFalsy();
454+
});
455+
456+
test('persists feature prompt dismissal when user dismisses the prompt', async () => {
457+
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
458+
const { container, unmount } = renderComponent(
459+
<TestI18nProvider messages={i18nMessages}>
460+
<AppLayout />
461+
</TestI18nProvider>
462+
);
463+
await delay();
464+
465+
const featurePromptWrapper = new FeaturePromptWrapper(container);
466+
expect(featurePromptWrapper.findContent()!.getElement()).toHaveTextContent('This is the first new feature content');
467+
468+
featurePromptWrapper.findDismissButton()!.click();
469+
470+
await waitFor(() => {
471+
expect(mockPersistFeatureNotifications).toHaveBeenCalled();
472+
const persistedMap = mockPersistFeatureNotifications.mock.calls[0][1];
473+
expect(persistedMap).toHaveProperty('feature-1_feature-prompt');
474+
});
475+
476+
unmount();
477+
478+
const { container: containerAfterRemount } = renderComponent(
479+
<TestI18nProvider messages={i18nMessages}>
480+
<AppLayout />
481+
</TestI18nProvider>
482+
);
483+
expect(new FeaturePromptWrapper(containerAfterRemount).findContent()).toBeFalsy();
484+
});
485+
486+
test('feature prompt dismissal persists with filtered outdated seen features', async () => {
487+
const oldSeenFeatureDate = new Date(mockCurrentDate);
488+
oldSeenFeatureDate.setDate(oldSeenFeatureDate.getDate() - 200); // More than 180 days ago
489+
490+
const recentSeenFeatureDate = new Date(mockCurrentDate);
491+
recentSeenFeatureDate.setDate(recentSeenFeatureDate.getDate() - 100); // Less than 180 days ago
492+
493+
mockRetrieveFeatureNotifications.mockResolvedValue({
494+
'old-seen-feature': oldSeenFeatureDate.toISOString(),
495+
'recent-seen-feature': recentSeenFeatureDate.toISOString(),
496+
});
497+
498+
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
499+
const { container } = renderComponent(
500+
<TestI18nProvider messages={i18nMessages}>
501+
<AppLayout />
502+
</TestI18nProvider>
503+
);
504+
await delay();
505+
506+
const featurePromptWrapper = new FeaturePromptWrapper(container);
507+
expect(featurePromptWrapper.findContent()!.getElement()).toHaveTextContent('This is the first new feature content');
508+
509+
featurePromptWrapper.findDismissButton()!.click();
510+
511+
await waitFor(() => {
512+
expect(mockPersistFeatureNotifications).toHaveBeenCalled();
513+
const persistedMap = mockPersistFeatureNotifications.mock.calls[0][1];
514+
// Should include the prompt dismissal key
515+
expect(persistedMap).toHaveProperty('feature-1_feature-prompt');
516+
// Should keep recent seen features
517+
expect(persistedMap).toHaveProperty('recent-seen-feature');
518+
// Should filter out outdated seen features (>180 days)
519+
expect(persistedMap).not.toHaveProperty('old-seen-feature');
520+
});
521+
});
522+
435523
test('renders feature notifications drawer alongside tools', () => {
436524
featureNotifications.registerFeatureNotifications(featureNotificationsDefaults);
437525
const { wrapper } = renderComponent(

src/app-layout/visual-refresh-toolbar/state/use-feature-notifications.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,12 @@ export function useFeatureNotifications() {
125125
setSeenFeatures(seenFeatureNotifications);
126126
const hasUnseenFeatures = features.some(feature => !seenFeatureNotifications[feature.id]);
127127
if (hasUnseenFeatures) {
128-
if (!payload.suppressFeaturePrompt && !featurePromptDismissed) {
128+
const latestFeature = features.find(feature => !seenFeatureNotifications[feature.id]) ?? null;
129+
if (
130+
!payload.suppressFeaturePrompt &&
131+
!featurePromptDismissed &&
132+
!seenFeatureNotifications[getFeaturePromptPersistenceId(latestFeature)]
133+
) {
129134
featurePromptMountPromise.then(() => {
130135
featurePromptRef.current?.show();
131136
});
@@ -173,6 +178,7 @@ export function useFeatureNotifications() {
173178
triggerRef.current!.dataset!.awsuiSuppressTooltip = 'false';
174179
}
175180
});
181+
persistFeaturePromptDismiss(latestFeature);
176182
}}
177183
header={
178184
<RuntimeContentPart mountContent={featureNotificationsData?.mountItem} content={latestFeature.header} />
@@ -206,6 +212,21 @@ export function useFeatureNotifications() {
206212
});
207213
};
208214

215+
const getFeaturePromptPersistenceId = (feature?: Feature<unknown> | null) => `${feature?.id}_feature-prompt`;
216+
217+
const persistFeaturePromptDismiss = (feature: Feature<unknown>) => {
218+
const persistenceConfig = featureNotificationsData?.persistenceConfig ?? DEFAULT_PERSISTENCE_CONFIG;
219+
const featuresMap = { [getFeaturePromptPersistenceId(feature)]: feature.releaseDate?.toString() };
220+
const filteredSeenFeaturesMap = filterOutdatedFeatures(seenFeatures);
221+
const allFeaturesMap = { ...featuresMap, ...filteredSeenFeaturesMap };
222+
(featureNotificationsData?.__persistFeatureNotifications ?? persistFeatureNotifications)(
223+
persistenceConfig,
224+
allFeaturesMap
225+
).then(() => {
226+
setSeenFeatures(allFeaturesMap);
227+
});
228+
};
229+
209230
const onOpenFeatureNotificationsDrawer = () => {
210231
if (!featureNotificationsData || allNotificationsSeen) {
211232
return;

0 commit comments

Comments
 (0)