Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/hungry-lands-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': minor
'@clerk/ui': minor
---

Add `showPlanName` property to `<OrganizationSwitcher>` component. When `true`, will render the name of the Clerk Billing plan alongside the Organization name.
6 changes: 6 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2226,6 +2226,12 @@ export type OrganizationSwitcherProps = CreateOrganizationMode &
* e.g. <UserButton userProfileProps={{appearance: {...}}} />
*/
organizationProfileProps?: Pick<OrganizationProfileProps, 'appearance' | 'customPages'>;
/**
* Shows the Clerk Billing Plan name for the currently active organization.
*
* @default false
*/
renderPlanBadge?: boolean | (() => Promise<{ label: string; slug: string; colorScheme?: 'primary' | 'secondary' }>);
};

/** @generateWithEmptyComment */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useOrganization, useOrganizationList, useUser } from '@clerk/shared/react';
import { forwardRef } from 'react';
import { forwardRef, useEffect, useState } from 'react';

import { LoadingBadge } from '@/ui/elements/Badge';
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
import { PersonalWorkspacePreview } from '@/ui/elements/PersonalWorkspacePreview';
import { withAvatarShimmer } from '@/ui/elements/withAvatarShimmer';

import { NotificationCountBadge, useProtect } from '../../common';
import { useEnvironment, useOrganizationSwitcherContext } from '../../contexts';
import { Button, descriptors, Icon, localizationKeys, useLocalizations } from '../../customizables';
import { useEnvironment, useOrganizationSwitcherContext, useSubscription } from '../../contexts';
import { Badge, Box, Button, descriptors, Icon, localizationKeys, useLocalizations } from '../../customizables';
import { ChevronDown } from '../../icons';
import type { PropsOfComponent } from '../../styledSystem';
import { organizationListParams } from './utils';
Expand Down Expand Up @@ -62,6 +63,7 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
gap={3}
size='xs'
organization={organization}
badge={<PlanBadge />}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
avatarSx={t => ({ borderRadius: t.radii.$sm })}
sx={{ maxWidth: '30ch' }}
/>
Expand Down Expand Up @@ -94,6 +96,107 @@ export const OrganizationSwitcherTrigger = withAvatarShimmer(
}),
);

const SwitcherPlanBadge = ({
label,
slug,
colorScheme = 'secondary',
}: {
label: string;
slug: string;
colorScheme?: 'primary' | 'secondary';
}) => (
<Badge
elementDescriptor={descriptors.organizationSwitcherTriggerBadge}
elementId={descriptors.organizationSwitcherTriggerBadge.setId(slug)}
colorScheme={colorScheme}
title={label}
sx={{ minWidth: 0, maxWidth: '14ch' }}
>
<Box
as='span'
sx={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{label}
</Box>
</Badge>
);

const ClerkBillingPlanBadge = () => {
const { isLoading, isFetching, subscriptionItems } = useSubscription();

if (isLoading || isFetching) {
// 5ch is specifically chosen to balance the size of "Free" versus paid plans in Inter
return <LoadingBadge sx={{ width: '5ch' }} />;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

the screenshots in the PR make this look larger than it actually is. This is what it looks like when not zoomed in.

Image

}

const activeSubscriptionItem = subscriptionItems.find(si => si.status === 'active' || si.status === 'past_due');
if (!activeSubscriptionItem) {
return null;
}

const { slug, name, isDefault } = activeSubscriptionItem.plan;

return (
<SwitcherPlanBadge
label={name}
slug={slug}
colorScheme={isDefault ? 'primary' : 'secondary'}
/>
);
};

const CustomRenderPlanBadge = ({
renderPlanBadge,
}: {
renderPlanBadge: () => Promise<{ label: string; slug: string; colorScheme?: 'primary' | 'secondary' }>;
}) => {
const [state, setState] = useState<{
isLoading: boolean;
data: { label: string; slug: string; colorScheme?: 'primary' | 'secondary' } | null;
}>({ isLoading: true, data: null });

useEffect(() => {
(async () => {
try {
const data = await renderPlanBadge();
setState({ isLoading: false, data });
} catch {
setState({ isLoading: false, data: null });
}
})();
}, [renderPlanBadge]);

if (state.isLoading) {
return <LoadingBadge sx={{ width: '5ch' }} />;
}

if (state.data) {
const { label, slug, colorScheme } = state.data;
return (
<SwitcherPlanBadge
label={label}
slug={slug}
colorScheme={colorScheme}
/>
);
}

return null;
};

const PlanBadge = () => {
const { renderPlanBadge } = useOrganizationSwitcherContext();
if (!renderPlanBadge) {
return null;
}

if (typeof renderPlanBadge === 'boolean') {
return <ClerkBillingPlanBadge />;
}

return <CustomRenderPlanBadge renderPlanBadge={renderPlanBadge} />;
};

const NotificationCountBadgeSwitcherTrigger = () => {
/**
* Prefetch user invitations and suggestions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,84 @@ describe('OrganizationSwitcher', () => {
expect(getByText('Test Organization')).toBeInTheDocument();
});

it('shows the active organization plan name when enabled', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [
{
name: 'Test Organization',
id: '1',
role: 'admin',
permissions: ['org:sys_billing:read'],
},
],
});
});

props.setProps({ renderPlanBadge: true });
fixtures.environment.commerceSettings.billing.organization.enabled = true;
fixtures.clerk.billing.getSubscription.mockResolvedValue({
id: 'sub_1',
subscriptionItems: [
{
id: 'sub_item_1',
status: 'active',
plan: {
name: 'Pro Plan',
slug: 'pro-plan',
isDefault: false,
},
},
],
} as any);

const { findByText } = render(<OrganizationSwitcher />, { wrapper });
const planName = await findByText('Pro Plan');

expect(planName.closest('.cl-organizationSwitcherTriggerBadge')).toBeInTheDocument();
});

it('does not show a plan badge when organization billing is disabled', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
organization_memberships: [
{
name: 'Test Organization',
id: '1',
role: 'admin',
permissions: ['org:sys_billing:read'],
},
],
});
});

props.setProps({ renderPlanBadge: true });
fixtures.environment.commerceSettings.billing.organization.enabled = false;
fixtures.clerk.billing.getSubscription.mockResolvedValue({
id: 'sub_1',
subscriptionItems: [
{
id: 'sub_item_1',
status: 'active',
plan: {
name: 'Pro Plan',
slug: 'pro-plan',
isDefault: false,
},
},
],
} as any);

const { container } = render(<OrganizationSwitcher />, { wrapper });

expect(fixtures.clerk.billing.getSubscription).not.toHaveBeenCalled();
expect(container.querySelector('.cl-badge')).not.toBeInTheDocument();
});

it('shows PersonalWorkspacePreview when user has no active organization and hidePersonal is false', async () => {
const { wrapper, props } = await createFixtures(f => {
f.withOrganizations();
Expand Down
41 changes: 24 additions & 17 deletions packages/ui/src/components/OrganizationSwitcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { cloneElement, useCallback, useId, useRef } from 'react';
import { withCardStateProvider, withFloatingTree } from '@/ui/elements/contexts';
import { Popover } from '@/ui/elements/Popover';

import { AcceptedInvitationsProvider, useOrganizationSwitcherContext, withCoreUserGuard } from '../../contexts';
import {
AcceptedInvitationsProvider,
SubscriberTypeContext,
useOrganizationSwitcherContext,
withCoreUserGuard,
} from '../../contexts';
import { Flow } from '../../customizables';
import { usePopover } from '../../hooks';
import { OrganizationSwitcherPopover } from './OrganizationSwitcherPopover';
Expand Down Expand Up @@ -64,22 +69,24 @@ const _OrganizationSwitcher = () => {
const { __experimental_asStandalone } = useOrganizationSwitcherContext();

return (
<Flow.Root
flow='organizationSwitcher'
sx={{ display: 'inline-flex' }}
>
<AcceptedInvitationsProvider>
{__experimental_asStandalone ? (
<OrganizationSwitcherPopover
close={typeof __experimental_asStandalone === 'function' ? __experimental_asStandalone : undefined}
/>
) : (
<OrganizationSwitcherWithFloatingTree>
<OrganizationSwitcherPopover />
</OrganizationSwitcherWithFloatingTree>
)}
</AcceptedInvitationsProvider>
</Flow.Root>
<SubscriberTypeContext.Provider value='organization'>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

the only thing changed here is wrapping the existing Flow.Root in the SubscriberTypeContext.Provider

<Flow.Root
flow='organizationSwitcher'
sx={{ display: 'inline-flex' }}
>
<AcceptedInvitationsProvider>
{__experimental_asStandalone ? (
<OrganizationSwitcherPopover
close={typeof __experimental_asStandalone === 'function' ? __experimental_asStandalone : undefined}
/>
) : (
<OrganizationSwitcherWithFloatingTree>
<OrganizationSwitcherPopover />
</OrganizationSwitcherWithFloatingTree>
)}
</AcceptedInvitationsProvider>
</Flow.Root>
</SubscriberTypeContext.Provider>
);
};

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([

'organizationSwitcherTrigger',
'organizationSwitcherTriggerIcon',
'organizationSwitcherTriggerBadge',
'organizationSwitcherPopoverRootBox',
'organizationSwitcherPopoverCard',
'organizationSwitcherPopoverMain',
Expand Down
26 changes: 24 additions & 2 deletions packages/ui/src/elements/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { descriptors, localizationKeys, Span } from '../customizables';
import { common, type PropsOfComponent } from '../styledSystem';
import { Badge, descriptors, localizationKeys, Span } from '../customizables';
import { animations, common, type PropsOfComponent } from '../styledSystem';

export type LoadingBadgeProps = Omit<PropsOfComponent<typeof Badge>, 'children' | 'colorScheme'>;
export const LoadingBadge = ({ sx, ...props }: LoadingBadgeProps): JSX.Element => {
return (
<Badge
{...props}
aria-hidden
sx={[
t => ({
backgroundColor: `${t.colors.$colorMutedForeground} !important`,
border: '0 !important',
boxShadow: 'none !important',
opacity: 0.16,
animation: `${animations.pulse} 2s ${t.transitionTiming.$bezier} infinite`,
}),
sx,
]}
>
{'\u00A0'}
</Badge>
);
};

export type LastAuthenticationStrategyBadgeProps = PropsOfComponent<typeof Span> & { overlay?: boolean };
export const LastAuthenticationStrategyBadge = ({
Expand Down
13 changes: 9 additions & 4 deletions packages/ui/src/elements/OrganizationPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OrganizationPreviewId, UserOrganizationInvitationResource, UserResource } from '@clerk/shared/types';
import React from 'react';

import { descriptors, Flex, Text } from '../customizables';
import { Box, descriptors, Flex, Text } from '../customizables';
import { useLocalizeCustomRoles } from '../hooks/useFetchRoles';
import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
import { OrganizationAvatar } from './OrganizationAvatar';
Expand Down Expand Up @@ -84,11 +84,16 @@ export const OrganizationPreview = (props: OrganizationPreviewProps) => {
elementId={descriptors.organizationPreviewMainIdentifier.setId(elementId)}
variant={mainTextSize}
as='span'
truncate
sx={mainIdentifierSx}
sx={[t => ({ display: 'flex', alignItems: 'center', gap: t.space.$1, minWidth: 0 }), mainIdentifierSx]}
title={organization.name}
>
{organization.name} {badge}
<Box
as='span'
sx={{ flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}

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.

I wonder if the org name should include a longer min width to ensure it never is completely hidden with a long plan name. not sure if you tried that combo.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The styling is somewhat confusing, but the general structure is that the parent element has max-width: 30ch, and the badge has max-width: 14ch, so the badge will only ever take up slightly less than half of the total space. The min-width rule here is to allow for a flex item to be narrower than its intrinsic width. From testing I don't think the min-width actually has any impact on the minimum width the organization name has to render within (which should always be 16ch).

>
{organization.name}
</Box>
{badge}
</Text>

{roleLabel && (
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/internal/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export type ElementsConfig = {

organizationSwitcherTrigger: WithOptions<'personal' | 'organization', 'open'>;
organizationSwitcherTriggerIcon: WithOptions<never, 'open'>;
organizationSwitcherTriggerBadge: WithOptions<string>;
organizationSwitcherPopoverRootBox: WithOptions;
organizationSwitcherPopoverCard: WithOptions;
organizationSwitcherPopoverMain: WithOptions;
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/styledSystem/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ const navbarSlideIn = keyframes`
100% {opacity: 1; transform: translateX(0);}
`;

const pulse = keyframes`
50% { opacity: 0.5; }
`;

export const animations = {
spinning,
dropdownSlideInScaleAndFade,
Expand All @@ -143,4 +147,5 @@ export const animations = {
inDelayAnimation,
outAnimation,
notificationAnimation,
pulse,
};
Loading