Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invitation+Suggestion count in OrganizationSwitcherTrigger #1627

Merged
merged 8 commits into from
Aug 25, 2023
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
*/
Copy link
Member

Choose a reason for hiding this comment

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

🙏🏻

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) => {
nikosdouvlis marked this conversation as resolved.
Show resolved Hide resolved
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