Skip to content

feat: Feature notifications#4186

Merged
georgylobko merged 5 commits intomainfrom
feat/feature-notitications
Feb 26, 2026
Merged

feat: Feature notifications#4186
georgylobko merged 5 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 98.46154% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.43%. Comparing base (5163256) to head (c73929a).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...rc/internal/do-not-use/feature-prompt/internal.tsx 90.90% 2 Missing ⚠️
...efresh-toolbar/state/use-feature-notifications.tsx 98.92% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #4186    +/-   ##
========================================
  Coverage   97.42%   97.43%            
========================================
  Files         893      896     +3     
  Lines       26127    26298   +171     
  Branches     9438     9495    +57     
========================================
+ Hits        25455    25623   +168     
- Misses        666      669     +3     
  Partials        6        6            

☔ 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
filterFeatures: feature => {
const halfYearAgo = new Date();
halfYearAgo.setDate(halfYearAgo.getDate() - 180);
return feature.releaseDate >= halfYearAgo;
Copy link
Member

Choose a reason for hiding this comment

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

nit: this implementation is unnecessary as using filterFeatures: () => true would give the same result.

If you also want to verify that the payload is correct, it is best to use:

const filterFeatures = jest.fn().returns(() => true);

, in which case you can explicitly check that the function was called the correct amount of times and with correct payload

Copy link
Member Author

Choose a reason for hiding this comment

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

the purpose of this test is to provide a usage example, so I would keep it as is

});

test('should not show feature prompt if all feature are seen', async () => {
mockRetrieveFeatureNotifications.mockResolvedValue({
Copy link
Member

Choose a reason for hiding this comment

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

I changed this to:

    mockRetrieveFeatureNotifications.mockResolvedValue({
      'feature-1': 'mockDate2025.toString()',
      'feature-2': 'mockDate2024.toString()',
      'feature-old': 'mockDateOld.toString()',
    });

and the test still passed. Why do we need these values?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we need this test. However the dates are not important here, because we check only features ids - we check dates only when we persist values to filter out outdated values

{formatDate(item.releaseDate)}
</Box>
{!!item.contentCategory && (
<Box>
Copy link
Member

Choose a reason for hiding this comment

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

The RuntimeContentPart already wraps content with a div or span. Why do we need the surrounding Box elements for content category and content?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, we need them for right paddings

/>

{!!featuresPageLink && (
<footer className={styles['runtime-feature-notifications-footer']}>
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

No, I tried. The footer is sticky, which we don't need here


const isDialog = showDismissButton;
const shouldTrapFocus = showDismissButton && variant !== 'annotation';
const shouldTrapFocus = trapFocus || (showDismissButton && variant !== 'annotation');
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 introduce a distinct variant for feature notifications instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes

return Promise.resolve(false);
};

// eslint-disable-next-line require-await
Copy link
Member

Choose a reason for hiding this comment

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

nit: should we disable these two eslint rules for the entire file instead?

@pan-kot
Copy link
Member

pan-kot commented Feb 20, 2026

When register API is used from inside an iframe - the initial feature prompt is not shown. The "show a standalone prompt" still works - but focus is not handled correctly and click outside does not cause a dismiss.

I tested that by wrapping the new test page with the IframeWrapper util.

fireNonCancelableEvent(onDismiss, { method: 'click-outside' });
}
};
window.addEventListener('click', clickListener, 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 and the nodeBelongs can be a problem when the API is called from inside an iframe. I managed to confirm it by rendering the test page inside an iframe helper.

Copy link
Member Author

Choose a reason for hiding this comment

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

fixed

const checkStickyState = throttle(
useCallback(() => {
if (!drawerRef.current || !footerRef.current) {
if (!drawerRef?.current || !footerRef?.current) {
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 this change needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

otherwise it throws an error

}, [featureNotificationsData, seenFeatures]);

const defaultFeaturesFilter = (feature: Feature<unknown>) => {
return feature.releaseDate >= subtractDaysFromDate(new Date(), 90);
Copy link
Member

Choose a reason for hiding this comment

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

let's use a constant for 90 as well

const featurePromptRef = useRef<FeaturePromptProps.Ref>(null);
const { ref: featurePromptMountRef, promise: featurePromptMountPromise } = useMountRefPromise();
const featurePromptMergedRef = useMergeRefs(featurePromptRef, featurePromptMountRef);
const allNotificationsSeen = useMemo(() => {
Copy link
Member

Choose a reason for hiding this comment

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

nit: this memo is not really needed - the cost of checking the data (which is already filtered) is negligible.

resizeHandle: i18n('ariaLabels.resizeHandle', undefined),
},
resizable: true,
defaultSize: 320,
Copy link
Member

@pan-kot pan-kot Feb 20, 2026

Choose a reason for hiding this comment

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

nit: let's create a constant for default drawer size, too


function featureNotificationsMessageHandler(event: WidgetMessage) {
if (event.type === 'registerFeatureNotifications') {
warnOnce('feature notifications', 'Feature notifications drawer is overriding the tools panel');
Copy link
Member

Choose a reason for hiding this comment

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

This renders a warning when the feature notifications feature is used correctly - why?

We should only warn if the tools feature is actually used, and in that case the feature notifications must not be rendered.

Copy link
Member

Choose a reason for hiding this comment

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

Also, we should add a unit test for the warning.

Copy link
Member Author

Choose a reason for hiding this comment

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

not needed anymore as the drawer works along with tools

*
* @returns An object containing a ref callback and a promise that resolves with the mounted element
*/
export function useMountRefPromise<T extends HTMLElement>() {
Copy link
Member

Choose a reason for hiding this comment

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

nice util!

}

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

Choose a reason for hiding this comment

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

nit: we can simplify the check to:

if (event.type === 'showFeaturePromptIfPossible' && !allNotificationsSeen) {
  featurePromptRef.current?.show();
}

Alternatively, the event mapping can be made more explicit like:

switch (event.type) {
  case 'registerFeatureNotifications':
    return handleRegisterFeatureNotifications(...);
  case 'showFeaturePromptIfPossible':
    return showFeaturePromptIfApplicable();
  case 'clearFeatureNotifications':
    return setFeatureNotificationsData(null);
}


// Features array is already sorted in reverse chronological order (most recent first)
// Find the first feature that hasn't been seen
for (const feature of featureNotificationsData.features) {
Copy link
Member

@pan-kot pan-kot Feb 20, 2026

Choose a reason for hiding this comment

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

nit: this can be simplified to:

featureNotificationsData.features.find(feature => !seenFeatures[feature.id]) ?? null;

or even one line:

function getLatestUnseenFeature() {
  return featureNotificationsData?.features.find(feature => !seenFeatures[feature.id]) ?? null;
}


function renderLatestFeaturePrompt({ triggerRef }: RenderLatestFeaturePromptProps) {
const latestFeature = getLatestUnseenFeature();
if (!(triggerRef?.current && featureNotificationsData && latestFeature)) {
Copy link
Member

Choose a reason for hiding this comment

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

this can be simplified to:

    if (!triggerRef.current || !latestFeature) {
      return null;
    }

}}
onDismiss={event => {
if (event.detail?.method !== 'blur') {
triggerRef?.current!.focus();
Copy link
Member

Choose a reason for hiding this comment

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

This .focus() already accepts options object. Can we add support for suppressTooltip in there?

Copy link
Member Author

Choose a reason for hiding this comment

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

The suppress should be controlled not only when focus is set, but also when we show the feature popover. In that case, focus goes to the feature popover while the tooltip for its trigger is suppressed

}, {});
const filteredSeenFeaturesMap = filterOutdatedFeatures(seenFeatures);
const allFeaturesMap = { ...featuresMap, ...filteredSeenFeaturesMap };
const __persistFeatureNotifications:
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 abstract the persistence related code into a dedicated function, so that the logic inside onOpenFeatureNotificationsDrawer is more clear:

const onOpenFeatureNotificationsDrawer = () => {
  if (!featureNotificationsData || allNotificationsSeen) {
    return;
  }
  const persistenceConfig = featureNotificationsData.persistenceConfig ?? DEFAULT_PERSISTENCE_CONFIG;
  persistFeatureNotificationsDismissState(persistenceConfig, featureNotificationsData).then((seenFeatures) => {
    setSeenFeatures(seenFeatures);
    setFeatureNotificationsData(data => (data ? { ...data, badge: false } : data));
  });
};

Copy link
Member

@pan-kot pan-kot left a comment

Choose a reason for hiding this comment

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

Significant:

  1. Feature notifications must not render if tools are present;
  2. Dev warning should only be shown if feature notifications and tools are used together;
  3. The API and feature prompt do not work correctly when used from an iframe;
  4. The API is exported as a set of standalone methods w/o structural grouping.

Less significant:

  1. Test coverage is not sufficient;
  2. Code structure can be improved: remove tabTrap, awsuiSuppressTooltip;
  3. Internal APIs for persistence are accessible via public API (with a ...{} hack);
  4. Persisted structure includes date snapshots that are not used;
  5. The dedicated test page requires an URL flag to be used;
  6. I18n strings API properties are not descriptive enough.

Significant, but can be done as a followup:

  1. No dedicated test utils for the UX features;
  2. I18n messages cannot be overridden.

srungta08
srungta08 previously approved these changes Feb 25, 2026
srungta08
srungta08 previously approved these changes Feb 26, 2026
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.

3 participants