Skip to content

Commit

Permalink
feat(clerk-js): Invitation+Suggestion count in OrganizationSwitcherTr…
Browse files Browse the repository at this point in the history
…igger (#1627)

* feat(clerk-js): Add NotificationBadge

* chore(clerk-js): Add notification badge descriptor

* chore(clerk-js): Show/hide selector icon when notification badge is visible

* Revert "chore(clerk-js): Show/hide selector icon when notification badge is visible"

This reverts commit a5cbdc923a87bf1adba4fdaccb6335ca2c3587dd.

* feat(clerk-js): Notification Badge in OrganizationSwitcher

* chore(clerk-js): Add changeset

* test(clerk-js): Add a case for the notification counter to appear in OrganizationSwitcher

* chore(clerk-js): Abstract badge in orgswitcher to a component
  • Loading branch information
panteliselef authored Aug 25, 2023
1 parent 21d8bee commit 0a5f632
Show file tree
Hide file tree
Showing 11 changed files with 165 additions and 12 deletions.
6 changes: 6 additions & 0 deletions .changeset/healthy-carrots-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Display a notification counter for organization invitations in OrganizationSwitcher
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
import { withOrganizationsEnabledGuard } from '../../common';
import { useCoreOrganizationList, withCoreUserGuard } from '../../contexts';
import { withCoreUserGuard } from '../../contexts';
import { Flow } from '../../customizables';
import { Popover, withCardStateProvider, withFloatingTree } from '../../elements';
import { usePopover } from '../../hooks';
import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover';
import { OrganizationSwitcherTrigger } from './OrganizationSwitcherTrigger';
import { organizationListParams } from './utils';

const _OrganizationSwitcher = withFloatingTree(() => {
const { floating, reference, styles, toggle, isOpen, nodeId, context } = usePopover({
placement: 'bottom-start',
offset: 8,
});

/**
* Prefetch user invitations and suggestions
*/
useCoreOrganizationList(organizationListParams);

return (
<Flow.Root flow='organizationSwitcher'>
<OrganizationSwitcherTrigger
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { forwardRef } from 'react';

import { useCoreOrganization, useCoreUser, useOrganizationSwitcherContext } from '../../contexts';
import { Button, descriptors, Icon, localizationKeys } from '../../customizables';
import {
useCoreOrganization,
useCoreOrganizationList,
useCoreUser,
useOrganizationSwitcherContext,
} from '../../contexts';
import { Box, Button, descriptors, Icon, localizationKeys, NotificationBadge } from '../../customizables';
import { OrganizationPreview, PersonalWorkspacePreview, withAvatarShimmer } from '../../elements';
import { useDelayedVisibility, usePrefersReducedMotion } from '../../hooks';
import { Selector } from '../../icons';
import type { PropsOfComponent } from '../../styledSystem';
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
import { animations } from '../../styledSystem';
import { organizationListParams } from './utils';

type OrganizationSwitcherTriggerProps = PropsOfComponent<typeof Button> & {
isOpen: boolean;
Expand All @@ -23,7 +31,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
elementDescriptor={descriptors.organizationSwitcherTrigger}
variant='ghost'
colorScheme='neutral'
sx={[t => ({ minHeight: 0, padding: `0 ${t.space.$2} 0 0` }), sx]}
sx={[t => ({ minHeight: 0, padding: `0 ${t.space.$2} 0 0`, position: 'relative' }), sx]}
ref={ref}
{...rest}
>
Expand All @@ -49,6 +57,9 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
}
/>
)}

<NotificationCountBadge />

<Icon
elementDescriptor={descriptors.organizationSwitcherTriggerIcon}
icon={Selector}
Expand All @@ -58,3 +69,35 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
);
}),
);

const NotificationCountBadge = () => {
const prefersReducedMotion = usePrefersReducedMotion();

/**
* Prefetch user invitations and suggestions
*/
const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams);
const notificationCount = (userInvitations.count || 0) + (userSuggestions.count || 0);
const showNotification = useDelayedVisibility(notificationCount > 0, 350) || false;

const enterExitAnimation: ThemableCssProp = t => ({
animation: prefersReducedMotion
? 'none'
: `${notificationCount ? animations.notificationAnimation : animations.outAnimation} ${
t.transitionDuration.$textField
} ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`,
});

return (
<Box
sx={t => ({
position: 'relative',
width: t.sizes.$4,
height: t.sizes.$4,
marginLeft: `${t.space.$2}`,
})}
>
{showNotification && <NotificationBadge sx={enterExitAnimation}>{notificationCount}</NotificationBadge>}
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MembershipRole } from '@clerk/types';
import { describe } from '@jest/globals';

import { render } from '../../../../testUtils';
import { render, runFakeTimers, waitFor } from '../../../../testUtils';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { OrganizationSwitcher } from '../OrganizationSwitcher';
import { createFakeUserOrganizationInvitation, createFakeUserOrganizationSuggestion } from './utlis';
Expand Down Expand Up @@ -42,6 +42,37 @@ describe('OrganizationSwitcher', () => {
});
});

describe('OrganizationSwitcherTrigger', () => {
it('shows the counter for pending suggestions and invitations', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({ email_addresses: ['test@clerk.dev'] });
});

fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce(
Promise.resolve({
data: [],
total_count: 2,
}),
);

fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValueOnce(
Promise.resolve({
data: [],
total_count: 3,
}),
);

await runFakeTimers(async () => {
const { getByText } = render(<OrganizationSwitcher />, { wrapper });

await waitFor(() => {
expect(getByText('5')).toBeInTheDocument();
});
});
});
});

describe('OrganizationSwitcherPopover', () => {
it('opens the organization switcher popover when clicked', async () => {
const { wrapper, props } = await createFixtures(f => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'qrCodeContainer',

'badge',
'notificationBadge',
'button',
'providerIcon',
// Decide if we want to keep the keys as camel cased in HTML as well,
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/ui/customizables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export const Badge = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitive
defaultDescriptor: descriptors.badge,
});

export const NotificationBadge = makeCustomizable(makeLocalizable(sanitizeDomProps(Primitives.NotificationBadge)), {
defaultDescriptor: descriptors.notificationBadge,
});

export const Table = makeCustomizable(sanitizeDomProps(Primitives.Table));
export const Thead = makeCustomizable(sanitizeDomProps(Primitives.Thead));
export const Tbody = makeCustomizable(sanitizeDomProps(Primitives.Tbody));
Expand Down
5 changes: 5 additions & 0 deletions packages/clerk-js/src/ui/hooks/useDelayedVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useEffect, useState } from 'react';

/**
* Utility hook for delaying mounting of components for enter and exit animations.
* Delays to update the state when is switched from/to undefined.
* Immediate change for in-between changes
*/
export function useDelayedVisibility<T>(valueToDelay: T, delayInMs: number) {
const [isVisible, setVisible] = useState<T | undefined>();

Expand Down
49 changes: 49 additions & 0 deletions packages/clerk-js/src/ui/primitives/NotificationBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PropsOfComponent, StyleVariants } from '../styledSystem';
import { common, createCssVariables, createVariants } from '../styledSystem';
import { Flex } from './Flex';

const vars = createCssVariables('accent', 'bg');

const { applyVariants, filterProps } = createVariants(theme => ({
base: {
color: vars.accent,
backgroundColor: vars.bg,
borderRadius: theme.radii.$sm,
height: theme.space.$4,
minWidth: theme.space.$4,
padding: `${theme.space.$0x5}`,
display: 'inline-flex',
},
variants: {
textVariant: { ...common.textVariants(theme) },
colorScheme: {
primary: {
[vars.accent]: theme.colors.$colorTextOnPrimaryBackground,
[vars.bg]: theme.colors.$primary500,
},
},
},
defaultVariants: {
colorScheme: 'primary',
textVariant: 'extraSmallRegular',
},
}));

// @ts-ignore
export type NotificationBadgeProps = PropsOfComponent<typeof Flex> & StyleVariants<typeof applyVariants>;

export const NotificationBadge = (props: NotificationBadgeProps) => {
return (
<Flex
{...filterProps(props)}
center
as='span'
css={[
applyVariants(props),
{
lineHeight: 0,
},
]}
/>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export * from './Tbody';
export * from './Tr';
export * from './Th';
export * from './Td';
export * from './NotificationBadge';
18 changes: 18 additions & 0 deletions packages/clerk-js/src/ui/styledSystem/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ const inAnimation = keyframes`
}
`;

const notificationAnimation = keyframes`
0% {
opacity: 0;
transform: translateY(5px) scale(.5);
}
50% {
opacity: 1;
transform: translateY(0px) scale(1.2);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
`;

const outAnimation = keyframes`
20% {
opacity: 1;
Expand Down Expand Up @@ -102,4 +119,5 @@ export const animations = {
navbarSlideIn,
inAnimation,
outAnimation,
notificationAnimation,
};
1 change: 1 addition & 0 deletions packages/types/src/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export type ElementsConfig = {

// default descriptors
badge: WithOptions<'primary' | 'actionRequired', never, never>;
notificationBadge: WithOptions<never, never, never>;
button: WithOptions<never, LoadingState, never>;
providerIcon: WithOptions<OAuthProvider | Web3Provider, never, never>;
};
Expand Down

0 comments on commit 0a5f632

Please sign in to comment.