Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-owls-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Display a notification counter for admins with pending request in the active organization. The counter is it visible in OrganizationSwitcher and OrganizationProfile ("Requests" tab)
38 changes: 38 additions & 0 deletions packages/clerk-js/src/ui/common/NotificationCountBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Box, NotificationBadge } from '../customizables';
import { useDelayedVisibility, usePrefersReducedMotion } from '../hooks';
import type { ThemableCssProp } from '../styledSystem';
import { animations } from '../styledSystem';

export const NotificationCountBadge = ({
notificationCount,
containerSx,
}: {
notificationCount: number;
containerSx?: ThemableCssProp;
}) => {
const prefersReducedMotion = usePrefersReducedMotion();
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,
}),
containerSx,
]}
>
{showNotification && <NotificationBadge sx={enterExitAnimation}>{notificationCount}</NotificationBadge>}
</Box>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './EmailLinkStatusCard';
export * from './Wizard';
export * from './RemoveResourcePage';
export * from './PrintableComponent';
export * from './NotificationCountBadge';
export * from './RemoveResourcePage';
export * from './withOrganizationsEnabledGuard';
export * from './QRCode';
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NotificationCountBadge } from '../../common';
import { useCoreOrganization, useEnvironment, useOrganizationProfileContext } from '../../contexts';
import { Col, descriptors, Flex, localizationKeys } from '../../customizables';
import {
Expand All @@ -21,11 +22,13 @@ export const OrganizationMembers = withCardStateProvider(() => {
const { organizationSettings } = useEnvironment();
const card = useCardState();
const { membership } = useCoreOrganization();
//@ts-expect-error
const { __unstable_manageBillingUrl } = useOrganizationProfileContext();
const isAdmin = membership?.role === 'admin';

const allowRequests = organizationSettings?.domains?.enabled && isAdmin;
const { membershipRequests } = useCoreOrganization({
membershipRequests: allowRequests || undefined,
});
//@ts-expect-error
const { __unstable_manageBillingUrl } = useOrganizationProfileContext();

return (
<Col
Expand Down Expand Up @@ -55,7 +58,9 @@ export const OrganizationMembers = withCardStateProvider(() => {
/>
)}
{allowRequests && (
<Tab localizationKey={localizationKeys('organizationProfile.membersPage.start.headerTitle__requests')} />
<Tab localizationKey={localizationKeys('organizationProfile.membersPage.start.headerTitle__requests')}>
<NotificationCountBadge notificationCount={membershipRequests?.count || 0} />
</Tab>
)}
</TabsList>
<TabPanels>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { OrganizationInvitationResource, OrganizationMembershipResource } f
import { describe, it } from '@jest/globals';

import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { runFakeTimers } from '../../../utils/test/runFakeTimers';
import { OrganizationMembers } from '../OrganizationMembers';
import { createFakeMember, createFakeOrganizationInvitation, createFakeOrganizationMembershipRequest } from './utils';

Expand Down Expand Up @@ -132,6 +133,31 @@ describe('OrganizationMembers', () => {
expect(queryByText('Member')).toBeDefined();
});

it('displays counter in requests tab', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withOrganizationDomains();
f.withUser({
email_addresses: ['test@clerk.dev'],
organization_memberships: [{ name: 'Org1', id: '1', role: 'admin' }],
});
});

fixtures.clerk.organization?.getMembershipRequests.mockReturnValue(
Promise.resolve({
data: [],
total_count: 2,
}),
);

await runFakeTimers(async () => {
const { getByText } = render(<OrganizationMembers />, { wrapper });
await waitFor(() => {
expect(getByText('2')).toBeInTheDocument();
});
});
});

it.todo('removes member from organization when clicking the respective button on a user row');
it.todo('changes role on a member from organization when clicking the respective button on a user row');
it('changes tab and renders the pending invites list', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { OrganizationResource } from '@clerk/types';
import React from 'react';

import { runIfFunctionOrReturn } from '../../../utils';
import { NotificationCountBadge } from '../../common';
import {
useCoreClerk,
useCoreOrganization,
useCoreOrganizationList,
useCoreUser,
useEnvironment,
useOrganizationSwitcherContext,
} from '../../contexts';
import { descriptors, localizationKeys } from '../../customizables';
Expand Down Expand Up @@ -112,6 +114,7 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
icon={CogFilled}
label={localizationKeys('organizationSwitcher.action__manageOrganization')}
onClick={handleManageOrganizationClicked}
trailing={<NotificationCountBadgeManageButton />}
/>
);

Expand Down Expand Up @@ -167,3 +170,16 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
);
},
);

const NotificationCountBadgeManageButton = () => {
const { membership } = useCoreOrganization();
const { organizationSettings } = useEnvironment();
const isAdmin = membership?.role === 'admin';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Irrelevant to this PR, but that's a nice candidate to introduce a HasRole() method on the MembershipResource

const allowRequests = organizationSettings?.domains?.enabled && isAdmin;

const { membershipRequests } = useCoreOrganization({
membershipRequests: allowRequests || undefined,
});

return <NotificationCountBadge notificationCount={membershipRequests?.count || 0} />;
};
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { forwardRef } from 'react';

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

type OrganizationSwitcherTriggerProps = PropsOfComponent<typeof Button> & {
Expand Down Expand Up @@ -58,7 +58,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
/>
)}

<NotificationCountBadge />
<NotificationCountBadgeSwitcherTrigger />

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

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

const NotificationCountBadgeSwitcherTrigger = () => {
/**
* 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`,
const { membership } = useCoreOrganization();
const { organizationSettings } = useEnvironment();
const isAdmin = membership?.role === 'admin';
const allowRequests = organizationSettings?.domains?.enabled && isAdmin;
const { membershipRequests } = useCoreOrganization({
membershipRequests: allowRequests || undefined,
});

const notificationCount =
(userInvitations.count || 0) + (userSuggestions.count || 0) + (membershipRequests?.count || 0);

return (
<Box
sx={t => ({
position: 'relative',
width: t.sizes.$4,
height: t.sizes.$4,
<NotificationCountBadge
containerSx={t => ({
marginLeft: `${t.space.$2}`,
})}
>
{showNotification && <NotificationBadge sx={enterExitAnimation}>{notificationCount}</NotificationBadge>}
</Box>
notificationCount={notificationCount}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ describe('OrganizationSwitcher', () => {
});
});
});

it('shows the counter for pending suggestions and invitations and membership requests', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withOrganizationDomains();
f.withUser({
email_addresses: ['test@clerk.dev'],
organization_memberships: [{ name: 'Org1', id: '1', role: 'admin' }],
});
});

fixtures.clerk.organization?.getMembershipRequests.mockReturnValue(
Promise.resolve({
data: [],
total_count: 2,
}),
);

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('7')).toBeInTheDocument();
});
});
});
});

describe('OrganizationSwitcherPopover', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/elements/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const SecondaryActions = (props: PropsOfComponent<typeof Flex>) => {

type ActionProps = Omit<PropsOfComponent<typeof Button>, 'label'> & {
icon: React.ComponentType;
trailing?: React.ReactNode;
label: LocalizationKey;
iconBoxElementDescriptor?: ElementDescriptor;
iconBoxElementId?: ElementId;
Expand All @@ -50,6 +51,7 @@ export const Action = (props: ActionProps) => {
textElementId,
iconBoxElementDescriptor,
iconBoxElementId,
trailing,
...rest
} = props;

Expand Down Expand Up @@ -115,6 +117,7 @@ export const Action = (props: ActionProps) => {
variant='smallRegular'
colorScheme='neutral'
/>
{trailing}
</Button>
);
};
6 changes: 4 additions & 2 deletions packages/clerk-js/src/ui/elements/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createContextAndHook } from '@clerk/shared';
import type { PropsWithChildren } from 'react';
import React from 'react';

import { Button, descriptors, Flex } from '../customizables';
import { Button, descriptors, Flex, useLocalizations } from '../customizables';
import type { PropsOfComponent } from '../styledSystem';
import { getValidChildren } from '../utils';

Expand Down Expand Up @@ -87,7 +87,8 @@ export const TabsList = (props: TabsListProps) => {
type TabProps = PropsOfComponent<typeof Button>;
type TabPropsWithTabIndex = TabProps & { tabIndex?: number };
export const Tab = (props: TabProps) => {
const { children, sx, tabIndex, isDisabled, ...rest } = props as TabPropsWithTabIndex;
const { t } = useLocalizations();
const { children, sx, tabIndex, isDisabled, localizationKey, ...rest } = props as TabPropsWithTabIndex;

if (tabIndex === undefined) {
throw new Error('Tab component must be a direct child of TabList.');
Expand Down Expand Up @@ -145,6 +146,7 @@ export const Tab = (props: TabProps) => {
]}
{...rest}
>
{t(localizationKey)}
{children}
</Button>
);
Expand Down