Skip to content

feat: Feature notifications#4186

Open
georgylobko wants to merge 11 commits intomainfrom
feat/feature-notitications
Open

feat: Feature notifications#4186
georgylobko wants to merge 11 commits intomainfrom
feat/feature-notitications

Conversation

@georgylobko
Copy link
Member

@georgylobko georgylobko commented Jan 14, 2026

Description

Introduced a feature notifications API that allows dynamically injecting a feature notifications drawer on pages using the AppLayout component.

The new API includes 3 methods:

  • registerFeatureNotifications - registers a new drawer
  • showFeaturePromptIfPossible - manually shows a feature prompt next to the drawer's trigger button
  • clearFeatureNotifications - clears the feature notifications drawer

Also enhanced the existing feature prompt internal component by adding dismissal context to the onDismiss method (blur, close-button, outside-click, etc.).

Related links, issue #, if available: n/a

How has this been tested?

U tests / manually

Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md.
  • Changes were manually tested for accessibility, see accessibility guidelines.

Security

Testing

  • Changes are covered with new/existing unit tests?
  • Changes are covered with new/existing integration tests?

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link

codecov bot commented Jan 14, 2026

Codecov Report

❌ Patch coverage is 99.48454% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.37%. Comparing base (e135e95) to head (1618aa1).

Files with missing lines Patch % Lines
...efresh-toolbar/state/use-feature-notifications.tsx 99.09% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4186      +/-   ##
==========================================
+ Coverage   97.35%   97.37%   +0.01%     
==========================================
  Files         889      891       +2     
  Lines       26040    26210     +170     
  Branches     9417     9485      +68     
==========================================
+ Hits        25352    25521     +169     
- Misses        641      642       +1     
  Partials       47       47              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@georgylobko georgylobko force-pushed the feat/feature-notitications branch from 4fa101b to efb51d9 Compare January 14, 2026 12:20
@georgylobko georgylobko force-pushed the feat/feature-notitications branch 2 times, most recently from 8343dd7 to aea04d1 Compare January 19, 2026 10:37
@georgylobko georgylobko force-pushed the feat/feature-notitications branch from 2e7cb8e to 1f4c85f Compare January 20, 2026 12:51
@georgylobko georgylobko force-pushed the feat/feature-notitications branch from 221927b to 4379ee3 Compare January 20, 2026 15:23
@georgylobko georgylobko force-pushed the feat/feature-notitications branch 2 times, most recently from 493d050 to fbd48d1 Compare January 27, 2026 09:31
@georgylobko georgylobko force-pushed the feat/feature-notitications branch from 9dc81fc to 6c9b625 Compare January 28, 2026 14:53
* clicking outside the prompt, shifting focus out of the prompt or pressing ESC.
*/
onDismiss?: NonCancelableEventHandler<null>;
onDismiss?: NonCancelableEventHandler<{ method?: string }>;
Copy link
Member Author

@georgylobko georgylobko Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method is a way the feature prompt was closed - blur, escape, close-button, click-outside.

@georgylobko georgylobko marked this pull request as ready for review February 17, 2026 16:23
@georgylobko georgylobko requested a review from a team as a code owner February 17, 2026 16:23
@georgylobko georgylobko requested review from SpyZzey, gethinwebster and pan-kot and removed request for a team February 17, 2026 16:23
'use client';
export * from '../internal/plugins/widget/interfaces';
export {
registerFeatureNotifications,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we group the APIs, same as we do for internal ones?

Like:

const api = {
  featureNotifications: {
    registerFeatureNotifications,
    showFeaturePromptIfPossible,
    clearFeatureNotifications,
  }
}

export default api;

id: 'local-feature-notifications',
suppressFeaturePrompt: false,
featuresPageLink: '/#/feature-notifications/feature-prompt?appLayoutToolbar=true',
filterFeatures: () => true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The filterFeatures is unnecessary here

persistenceConfig: {
uniqueKey: 'feature-notifications',
},
// DON'T USE
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this comment about?

});

function delay() {
return act(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is act() really needed? I think the delay() function can potentially be replaced with jest.runAllTimersAsync() or, better, jest.advanceTimersByTimeAsync(time).

Also, the name "delay()" it not semantically sound as we do not use it to delay anything - we use it to wait for the component delays.

async function renderComponent(jsx: React.ReactElement) {
const { container, rerender, ...rest } = render(jsx);
const wrapper = createWrapper(container).findAppLayout()!;
await delay();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we call delay() here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if that is needed for all tests to wait until the initial appearance of feature notifications - I would call it explicitly in each test suite to represent the intent better.

test('shows feature prompt for a latest features', async () => {
awsuiWidgetPlugins.registerFeatureNotifications(featureNotificationsDefaults);
const { container } = await renderComponent(<AppLayout />);
await delay();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call delay() inside renderComponent, so this call should not be necessary - there are tests where it is called and some where it is not called immediately after render. I recommend to remove it from the render helper and always call it explicitly.

expect(persistedFeaturesMap).toHaveProperty('feature-1');
expect(persistedFeaturesMap).toHaveProperty('feature-2');
expect(persistedFeaturesMap).toHaveProperty('recent-seen-feature');
expect(persistedFeaturesMap).not.toHaveProperty('old-seen-feature');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would it makes sense to move persistence-related tests to a separate file?

if (!mountContent) {
return;
}
const container = ref.current!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of expecting the content to be there to satisfy TS - should we include it to the if check above?

if (!mountContent || !ref.content) {
  return;
}

Demo page
</Header>
</div>
<Button
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: these buttons do not have margins between them

import * as toolsContent from '../app-layout/utils/tools-content';
import ScreenshotArea from '../utils/screenshot-area';

registerFeatureNotifications({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These only work when using appLayoutToolbar=true. I suggest to test if the non-toolbar layout does not crash when using this feature in unit tests, and always render the toolbar variant on this test page - it is confusing otherwise.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature does not seem to be working correctly. The prompt shows when I open dev tools, but not upon page reload. I am testing it in Chrome.

Screen.Recording.2026-02-18.at.15.18.31.mov

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When feature notifications are registered (I added a delay for this to be better noticeable) - the tools disappear.

Screen.Recording.2026-02-18.at.15.24.57.mov

const i18n = useInternalI18n('features-notification-drawer');

return (
<InternalDrawer header={i18n('i18nStrings.title', undefined)} disableContentPaddings={true}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that the feature cannot be used w/o built-in i18n, and that changing the default messages is only possible with a workaround (by overriding our messages objects).

Should we include (optional) i18nStrings to the runtime API instead?

),
secondaryContent: (
<>
{!!item.releaseDate && (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The releaseDate is required as per our types.

secondaryContent: (
<>
{!!item.releaseDate && (
<Box margin={{ top: 'xs' }} fontSize="body-s" color="text-body-secondary">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a default padding between content and secondary content - why do we add some more?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use space-between instead of boxes with margins?

destructor?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a unit tests where registerFeatureNotifications is called multiple times - sync or async, to assert that the latest content and the latest mountItem are used.

<InternalDrawer header={i18n('i18nStrings.title', undefined)} disableContentPaddings={true}>
<Box
padding={{ top: 'm', left: 'xl', right: 'xl', bottom: 'm' }}
className={styles['runtime-feature-notifications-drawer-content']}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overriding styles on our components is not recommended. Can we use a custom div here instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same applies to the .runtime-feature-notifications-footer

}
}

.runtime-feature-notifications-drawer-content {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have plans to support test-utils for this feature? I believe we should, as the consumers should have a reliable way to assert the feature prompt and drawer content.

};

const { featureNotificationsProps, onOpenFeatureNotificationsDrawer, featureNotificationsMessageHandler } =
useFeatureNotifications({ getDrawersIds: () => drawers?.map(drawer => drawer.id) ?? [] });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it necessary to pass the getDrawersIds to the hook? The feature notifications should not care about other drawers.

If I understand it correctly, the registerFeatureNotifications API is responsible for rendering the drawer and also the feature prompt. Can this two actions be joined? For instance, the featureNotificationsProps.drawer can include an optional featurePrompt object - describing the prompt. When it is present - the prompt is shown, but only during the initial render (app layout would handle that).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise, there is a bi-directional connection between the app layout state and feature notifications state, which can cause race conditions and is overall difficult to understand.


setFeatureNotificationsData({ ...payload, features });

const persistenceConfig = payload.persistenceConfig ?? DEFAULT_PERSISTENCE_CONFIG;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would add some code comments here explaining the relation between the event payload and the data we extract from the persistence mechanism.

const persistenceConfig = payload.persistenceConfig ?? DEFAULT_PERSISTENCE_CONFIG;
const __retrieveFeatureNotifications:
| ((persistenceConfig: FeatureNotificationsPersistenceConfig) => Promise<Record<string, string>>)
| undefined = (payload as any)?.__retrieveFeatureNotifications;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this __retrieveFeatureNotifications be used from the public API? If so - do we have use cases besides testing? If no - I recommend doing some internal version of the API that allows it and make sure that everything starting from __ is omitted from the public plugin (using getExternalProps util).

}

if (event.type === 'showFeaturePromptIfPossible') {
if (markAllAsRead) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this extra state? Can we check the featureNotificationsData instead?

triggerRef?.current!.focus();
}
setFeaturePromptDismissed(true);
Promise.resolve().then(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the feature prompt was rendered by the app layout (with an extra internal prop on the drawer) - then we could suppress the tooltip whenever the feature prompt is present, w/o introducing the data-attributes.

Is there a reason why the feature cannot be fully controlled with the React state alone?

const featuresMap = featureNotificationsData.features.reduce((acc, feature) => {
return {
...acc,
[feature.id]: feature.releaseDate.toString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the Date.toString() is browser and locale dependent. If the persistence is cross-browser - the keys won't match. If the user would change their locale - the keys won't match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments