From 309612e574cc9c1fef1b8f62dee61fa1b31ce841 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 11:15:12 -0400 Subject: [PATCH 1/9] chore(swingset): Add profile permutations exploration page --- .changeset/profile-permutations-swingset.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 2 + .../src/stories/profile-permutations.mdx | 149 ++++++++++++ .../stories/profile-permutations.stories.tsx | 221 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 .changeset/profile-permutations-swingset.md create mode 100644 packages/swingset/src/stories/profile-permutations.mdx create mode 100644 packages/swingset/src/stories/profile-permutations.stories.tsx diff --git a/.changeset/profile-permutations-swingset.md b/.changeset/profile-permutations-swingset.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-permutations-swingset.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 70a536937e0..9094b2133de 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -11,6 +11,7 @@ import { PlaygroundProvider } from './PlaygroundContext'; const docModules: Record> = { 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + 'profile-permutations': dynamic(() => import('../stories/profile-permutations.mdx')), }, 'user-profile': { 'user-profile': dynamic(() => import('../stories/user-profile.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 460afde6778..0a7b79fa569 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -246,6 +246,7 @@ import { meta as profileComponentMeta, Transitions as ProfileTransitions, } from '../stories/profile.component.stories'; +import { meta as profilePermutationsMeta } from '../stories/profile-permutations.stories'; import { Default as ProviderButtonDefault, Many as ProviderButtonMany, @@ -1112,6 +1113,7 @@ const organizationProfileMembersPanelModule: StoryModule = { export const registry: StoryModule[] = [ // User Button userButtonModule, + { meta: profilePermutationsMeta }, // User Profile userProfileModule, // User Profile · Panels diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx new file mode 100644 index 00000000000..d75687b7002 --- /dev/null +++ b/packages/swingset/src/stories/profile-permutations.mdx @@ -0,0 +1,149 @@ +import * as Stories from './profile-permutations.stories'; + +# Profile permutations + +Exploration page: every pairing of switchers and profile shape, running on fixtures. Each example shares +one account and organization across its triggers, so switching in one updates the other and the profile. + +Today `mode` on `UserButton` sets both the menu and the profile. `OrganizationSwitcher` always opens the +standalone organization profile. + +The snippets show the proposed API, which is not implemented. `combined` defaults to `true` in two places: +on `UserButton` it decides what the menu lists, and on `MosaicProvider`'s `profile` it decides what the +profile shows. + +| # | Switchers | Profile | With the modal today | Proposed config | +| --- | ---------------------------------------------- | -------- | --------------------------------------------- | ---------------------- | +| 1 | `UserButton` combined | Combined | Yes (the default) | None | +| 2 | `UserButton` combined | Separate | No | Profile opt-out | +| 3 | `UserButton` user | Account | Yes | Menu + profile opt-out | +| 4 | `UserButton` user | Combined | No | Menu opt-out | +| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Yes | Menu + profile opt-out | +| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | No | Menu opt-out | +| 7 | `OrganizationSwitcher` | Org | Yes | Profile opt-out | +| 8 | `OrganizationSwitcher` | Combined | No | None | +| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | No: the switcher opens the standalone profile | None | + +The `No` rows can be approximated by routing with `userProfileUrl` and `organizationProfileUrl` to pages +that mount ``. + +## 1. Combined button, combined profile + +Settings opens one profile. + +```tsx + +``` + + + +## 2. Combined button, separate profiles + +Settings offers the account and the organization as two targets. + +```tsx + + + +``` + + + +## 3. User button, account profile + +```tsx + + + +``` + + + +## 4. User button, combined profile + +No organization rows in the menu, but the profile carries the active organization's pages. + +```tsx + +``` + + + +## 5. User button and switcher, separate profiles + +```tsx + + + + +``` + + + +## 6. User button and switcher, combined profile + +Both triggers open the same profile. The button lands on the account's pages and the switcher on the +organization's. Switch to the personal account and the organization group leaves the profile. + +```tsx + + +``` + + + +## 7. Switcher, organization profile + +```tsx + + + +``` + + + +## 8. Switcher, combined profile + +```tsx + +``` + + + +## 9. Combined button and switcher + +Both menus list the organizations, and both open the combined profile. + +```tsx + + +``` + + diff --git a/packages/swingset/src/stories/profile-permutations.stories.tsx b/packages/swingset/src/stories/profile-permutations.stories.tsx new file mode 100644 index 00000000000..2a09f1ce129 --- /dev/null +++ b/packages/swingset/src/stories/profile-permutations.stories.tsx @@ -0,0 +1,221 @@ +import type { + OrganizationProfileModalContentProps, + OrganizationProfileModalOptions, +} from '@clerk/mosaic/features/organization-profile/organization-profile.modal.types'; +import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; +import type { SwitcherViewProps } from '@clerk/mosaic/features/switcher/switcher.view'; +import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view'; +import type { UserButtonMode } from '@clerk/mosaic/features/user-button/user-button'; +import type { + UserProfileModalContentProps, + UserProfileModalOptions, +} from '@clerk/mosaic/features/user-profile/user-profile.modal.types'; +import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view'; +import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface'; +import type { ModalSurfaces } from '@clerk/mosaic/modals/modal.types'; +import { ModalHost } from '@clerk/mosaic/modals/modal-host'; +import { useModal } from '@clerk/mosaic/modals/use-modal'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationProfileFixture } from './fixtures/organization-profile'; +import { usePrototype } from './fixtures/switcher'; +import { useUserProfileFixture } from './fixtures/user-profile'; + +export const meta: StoryMeta = { + group: 'User Button', + title: 'ProfilePermutations', + label: 'Profile permutations', + status: 'wip', + source: 'packages/swingset/src/stories/profile-permutations.stories.tsx', +}; + +export function CombinedButtonCombinedProfile(_args: Record) { + return ( + + ); +} + +export function CombinedButtonSeparateProfiles(_args: Record) { + return ( + + ); +} + +export function UserButtonAccountProfile(_args: Record) { + return ( + + ); +} + +export function UserButtonCombinedProfile(_args: Record) { + return ( + + ); +} + +export function UserButtonAndSwitcherSeparateProfiles(_args: Record) { + return ( + + ); +} + +export function UserButtonAndSwitcherCombinedProfile(_args: Record) { + return ( + + ); +} + +export function SwitcherOrganizationProfile(_args: Record) { + return ( + + ); +} + +export function SwitcherCombinedProfile(_args: Record) { + return ( + + ); +} + +export function CombinedButtonAndSwitcher(_args: Record) { + return ( + + ); +} + +interface PermutationProps { + userButton?: UserButtonMode; + organizationSwitcher?: boolean; + profile: 'combined' | 'separate'; +} + +function Permutation(props: PermutationProps) { + return ( + + + + ); +} + +function Triggers({ userButton, organizationSwitcher = false, profile }: PermutationProps) { + const prototype = usePrototype(); + const [openSurface, setOpenSurface] = useState<'userButton' | 'organizationSwitcher'>('userButton'); + const userProfile = useModal(fixtureUserProfileModal); + const organizationProfile = useModal(fixtureOrganizationProfileModal); + const combined = profile === 'combined' && prototype.activeOrganization ? 'combined' : 'user'; + + const manageAccount = () => { + prototype.onManageAccount?.(); + userProfile.open({ mode: combined }); + }; + const manageOrganization = () => { + prototype.onManageOrganization?.(); + if (profile === 'combined') { + userProfile.open({ mode: combined, page: 'organization/general' }); + } else { + organizationProfile.open(); + } + }; + const surfaceProps = (surface: typeof openSurface): Omit => ({ + ...prototype, + open: prototype.open && openSurface === surface, + onOpenChange: open => { + setOpenSurface(surface); + prototype.onOpenChange?.(open); + }, + onManageAccount: manageAccount, + onManageOrganization: manageOrganization, + }); + + return ( +
+ {organizationSwitcher ? ( + + ) : null} + {userButton ? ( + + ) : null} +
+ ); +} + +const fixtureUserProfileModal = defineModalSurface({ + id: 'permutationUserProfile', + variant: 'profile', + load: () => Promise.resolve({ default: FixtureUserProfile }), +}); + +const fixtureOrganizationProfileModal = defineModalSurface({ + id: 'permutationOrganizationProfile', + variant: 'profile', + load: () => Promise.resolve({ default: FixtureOrganizationProfile }), +}); + +const surfaces: ModalSurfaces = [fixtureUserProfileModal, fixtureOrganizationProfileModal]; + +function FixtureUserProfile({ options }: UserProfileModalContentProps) { + const { pages } = useUserProfileFixture(); + const organization = useOrganizationProfileFixture(); + const [activePage, setActivePage] = useState(options?.page ?? 'account'); + + return ( + + ); +} + +function FixtureOrganizationProfile(_props: OrganizationProfileModalContentProps) { + const { activePage, setActivePage, pages } = useOrganizationProfileFixture(); + + return ( + + ); +} From f33ffb30f83ffa53337451a9e6f9f74aa33622eb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 13:47:18 -0400 Subject: [PATCH 2/9] feat(mosaic): Configure the profile on MosaicProvider --- .changeset/mosaic-profile-provider.md | 20 ++ .changeset/profile-permutations-swingset.md | 2 - .../src/app/custom/page.tsx | 14 +- .../src/app/organization-switcher/page.tsx | 12 +- .../mosaic/src/__tests__/feature/render.tsx | 3 + .../organization-switcher.feature.test.tsx | 2 +- .../organization-switcher.tsx | 12 +- .../profile-pages/use-mosaic-profile.ts | 58 +++++ .../switcher.profile.feature.test.tsx | 236 ++++++++++++++++++ .../src/features/switcher/switcher.model.tsx | 12 +- .../src/features/switcher/switcher.pages.tsx | 12 +- .../mosaic/src/features/switcher/switcher.tsx | 52 +--- .../__tests__/user-button.feature.test.tsx | 58 +++-- .../user-button.new-profiles.feature.test.tsx | 19 +- .../src/features/user-button/user-button.tsx | 47 ++-- .../__tests__/user-profile.feature.test.tsx | 4 +- .../user-profile/user-profile.modal.types.ts | 2 +- .../features/user-profile/user-profile.tsx | 54 ++-- packages/mosaic/src/index.ts | 3 +- packages/mosaic/src/mosaic-provider.tsx | 20 +- .../live/user-button/user-button/page.tsx | 2 +- .../src/stories/profile-permutations.mdx | 55 ++-- .../stories/profile-permutations.stories.tsx | 11 +- .../src/stories/user-button.stories.tsx | 4 +- 24 files changed, 514 insertions(+), 200 deletions(-) create mode 100644 .changeset/mosaic-profile-provider.md delete mode 100644 .changeset/profile-permutations-swingset.md create mode 100644 packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts create mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx diff --git a/.changeset/mosaic-profile-provider.md b/.changeset/mosaic-profile-provider.md new file mode 100644 index 00000000000..3dc61a6068f --- /dev/null +++ b/.changeset/mosaic-profile-provider.md @@ -0,0 +1,20 @@ +--- +'@clerk/mosaic': minor +--- + +Configure the profile on `MosaicProvider` with the new `profile` prop, so `UserButton`, `OrganizationSwitcher` and `UserProfile` share one setup. This makes pairings like a user-only `UserButton` beside an `OrganizationSwitcher` with one combined profile possible. + +```tsx + + + + +``` + +Breaking changes: + +- `UserButton`: `mode` is replaced by `combined`, which only decides what the menu lists. `mode='user'` becomes `combined={false}`, and to keep the account-only profile too, also set `profile={{ combined: false }}` on `MosaicProvider`. +- `UserButton` and `OrganizationSwitcher`: `userProfileProps` and `organizationProfileProps` move to `MosaicProvider`'s `profile`. +- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning. +- With `experimental.newProfiles`, `OrganizationSwitcher` now opens the combined profile by default. Set `profile={{ combined: false }}` for the standalone organization profile. +- The `UserProfileMode` type is removed. diff --git a/.changeset/profile-permutations-swingset.md b/.changeset/profile-permutations-swingset.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/profile-permutations-swingset.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx index 8fc90b59a4a..766d3f60a90 100644 --- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx @@ -4,13 +4,15 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic'; export default function Page() { return ( - - custom-page-content

}], - }} - /> + }, + }} + > +
); } diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx index 876c036cf59..67dab4c1f5c 100644 --- a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx @@ -4,12 +4,16 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic'; export default function Page() { return ( - + custom-page-content

}], + }, + }} + > custom-page-content

}], - }} />
); diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index cbcf75842b2..b0aed1469b3 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { vi } from 'vitest'; +import type { MosaicProfile } from '../../features/profile-pages/use-mosaic-profile'; import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental'; import type { MosaicLocalization } from '../../localization'; import { MosaicProvider } from '../../mosaic-provider'; @@ -14,6 +15,7 @@ export async function renderWithClerk( options?: Parameters[0], localization?: MosaicLocalization, experimental?: MosaicExperimental, + profile?: MosaicProfile, ) { const clerk = new Clerk(publishableKey()); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -26,6 +28,7 @@ export async function renderWithClerk( {element} diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 4c617211702..2e358bb8146 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -382,7 +382,7 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => { await page.viewport(1280, 800); onTestFinished(() => page.viewport(414, 896)); serveFapi(noOffers()); - return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }); + return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }, { combined: false }); } it('opens the OrganizationProfile, without the account pages, from Settings', async () => { diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx index ace9a4a3f94..2596a7f56a8 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx @@ -8,11 +8,11 @@ import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.mode import type { SwitcherMenuProps } from '../switcher/switcher.types'; import type { SwitcherTriggerProps } from '../switcher/switcher.view'; -/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ +/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions & SwitcherTriggerProps & SwitcherMenuProps & - Pick; + Pick; /** * The active organization's avatar, and the menu behind it: switch organization or back to the @@ -47,14 +47,10 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions & * ``` * * @example - * `customPages` adds your own pages to the organization profile; `customMenuItems` adds your own rows - * beside Settings, each one either an `onClick` action or an `href` link. + * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an + * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`. * ```tsx * , content: }], - * pageOrder: ['general', 'members', 'audit'], - * }} * customMenuItems={[{ id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }]} * /> * ``` diff --git a/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts new file mode 100644 index 00000000000..dac561e6e14 --- /dev/null +++ b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts @@ -0,0 +1,58 @@ +import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; +import { createContext, useContext } from 'react'; + +import type { OrganizationProfilePageId } from '../switcher/switcher.pages'; +import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types'; + +export interface MosaicProfile { + /** + * Whether one profile carries the account's pages and the active organization's. `false` keeps them + * in separate profiles. It decides the profile's shape, not what `UserButton`'s menu lists. + * + * @default true + */ + combined?: boolean; + /** + * Options for the user profile, whichever component opens or mounts it. + */ + userProfileProps?: ProfileUserProps; + /** + * Options for the organization profile, or the organization's pages in a combined profile, + * whichever component opens or mounts it. + */ + organizationProfileProps?: ProfileOrganizationProps; +} + +/** What a profile takes beyond the profile component's own props. */ +export interface ProfilePages { + /** + * Provide custom pages and links to be rendered inside the profile. + */ + customPages?: CustomProfileItem[]; + /** + * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the + * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is + * the page the profile opens on, so it cannot be a link. + * + * @default undefined + */ + pageOrder?: (PageId | (string & {}))[]; +} + +/** Options for the underlying `` component. */ +export interface ProfileUserProps + extends + ProfilePages, + Pick {} + +/** Options for the underlying `` component. */ +export interface ProfileOrganizationProps + extends ProfilePages, Pick {} + +const MosaicProfileContext = createContext({}); + +export const MosaicProfileProvider = MosaicProfileContext.Provider; + +export function useMosaicProfile(): MosaicProfile { + return useContext(MosaicProfileContext); +} diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx new file mode 100644 index 00000000000..a3cf78ec7a9 --- /dev/null +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx @@ -0,0 +1,236 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; + +import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { openMenu } from '../../../__tests__/feature/menu'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { OrganizationSwitcher } from '../../organization-switcher/organization-switcher'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; +import { UserButton } from '../../user-button/user-button'; +import { UserProfile } from '../../user-profile/user-profile'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); +const memberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] })]; +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: memberships, +}); + +const inOrganization: FakeFapiSeed = { + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' })]), + memberships, +}; +const inPersonalAccount: FakeFapiSeed = { + client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), + memberships, +}; + +const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; +const accountTabs = ['Account', 'Security']; +const organizationTabs = ['General', 'Members']; +const allTabs = [...accountTabs, ...organizationTabs]; +const separate: MosaicProfile = { combined: false }; + +async function renderWithProfile( + ui: ReactElement, + profile?: MosaicProfile, + seed: FakeFapiSeed = inOrganization, + experimental: { newProfiles?: boolean } = { newProfiles: true }, +) { + serveFapi(seed); + return renderWithClerk(ui, undefined, undefined, experimental, profile); +} + +const buttonTrigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const switcherTrigger = () => screen.getByRole('button', { name: /Open organization menu/ }); +const settings = () => screen.getByRole('button', { name: 'Settings' }); +const tabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); +const selectedTab = (name: string) => screen.findByRole('tab', { name, selected: true }); + +async function clickSettings(trigger: () => HTMLElement) { + const user = userEvent.setup(); + await user.click(trigger()); + await user.click(settings()); + return user; +} + +async function chooseSetting(trigger: () => HTMLElement, label: string) { + const user = userEvent.setup(); + await user.click(trigger()); + await openMenu(user, settings()); + await user.click(screen.getByRole('menuitem', { name: label })); +} + +beforeEach(async () => { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +}); + +describe('the profile the switchers open', () => { + it.each([ + { name: 'combined by default', profile: undefined, expected: allTabs }, + { name: 'combined when asked', profile: { combined: true }, expected: allTabs }, + { name: 'separate when opted out', profile: separate, expected: accountTabs }, + ])('a user-only button opens the account pages first: $name', async ({ profile, expected }) => { + await renderWithProfile(, profile); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(expected); + }); + + it('a combined button opens one profile straight from Settings', async () => { + await renderWithProfile(); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('a combined button offers two targets over separate profiles', async () => { + await renderWithProfile(, separate); + + await chooseSetting(buttonTrigger, 'Manage organization'); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(organizationTabs); + }); + + it('a lone switcher lands on the organization pages of the combined profile', async () => { + await renderWithProfile(); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('a lone switcher opens the standalone organization profile when opted out', async () => { + await renderWithProfile(, separate); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(organizationTabs); + }); + + it.each([ + { name: 'a user-only button', button: }, + { name: 'a combined button', button: }, + ])('a switcher beside $name keeps its Settings row', async ({ button }) => { + await renderWithProfile( + <> + + {button} + , + ); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('leaves the organization pages out of the combined profile on a personal account', async () => { + await renderWithProfile(, undefined, inPersonalAccount); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(accountTabs); + }); + + it('routes the one profile with a URL, and opens the other as a modal', async () => { + const { navigate } = await renderWithProfile(); + + await chooseSetting(buttonTrigger, 'Manage profile'); + expect(navigate).toHaveBeenCalledWith('/account'); + + await chooseSetting(buttonTrigger, 'Manage organization'); + expect(await selectedTab('General')).toBeInTheDocument(); + }); + + it.each([ + { name: 'the button', ui: , trigger: buttonTrigger }, + { name: 'the switcher', ui: , trigger: switcherTrigger }, + ])('applies the custom pages and order set on the provider when $name opens it', async ({ ui, trigger }) => { + await renderWithProfile(ui, { + userProfileProps: { customPages: [terms], pageOrder: ['account', 'terms'] }, + organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }], pageOrder: ['audit'] }, + }); + + await clickSettings(trigger); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Terms', 'Security', 'Audit', 'General', 'Members'])); + }); + + it("hands the provider's custom pages to Clerk's modals without newProfiles", async () => { + const { clerk } = await renderWithProfile( + , + { userProfileProps: { customPages: [terms] } }, + inOrganization, + {}, + ); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + + await clickSettings(buttonTrigger); + + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + const el = document.body.appendChild(document.createElement('div')); + onTestFinished(() => el.remove()); + act(() => { + customPages[0]?.mount?.(el); + }); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); +}); + +describe('a mounted UserProfile under a provider profile', () => { + it.each([ + { name: 'follows the provider', profile: separate, combined: undefined, expected: accountTabs }, + { name: 'defaults to combined', profile: undefined, combined: undefined, expected: allTabs }, + { name: 'wins over the provider when combined', profile: separate, combined: true, expected: allTabs }, + { name: 'wins over the provider when not', profile: { combined: true }, combined: false, expected: accountTabs }, + ])('$name', async ({ profile, combined, expected }) => { + await renderWithProfile(, profile); + + await waitFor(() => expect(tabs()).toEqual(expected)); + }); + + it("uses the provider's profile props, leaving links out", async () => { + await renderWithProfile(, { + userProfileProps: { + customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }], + }, + organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members', 'Audit'])); + }); + + it('lets its own props win over the provider', async () => { + await renderWithProfile(, { + userProfileProps: { customPages: [terms] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Own', 'General', 'Members'])); + }); +}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index b12cbd6fb3d..f5b788e39ba 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -28,6 +28,7 @@ import type { SwitcherData, SwitcherInvitation, SwitcherMembership, + SwitcherMode, SwitcherSession, SwitcherSuggestion, } from './switcher.types'; @@ -122,9 +123,11 @@ export interface SwitcherModalProps { newProfiles?: { userProfile?: Pick< UserProfileModalOptions, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined' >; organizationProfile?: Pick; + /** Which switchers the opening menu carries. Only a menu with both has one Settings row for two targets. */ + menuMode?: SwitcherMode; }; } @@ -191,7 +194,7 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { /** * @param modals - Props forwarded to the profile modals. Custom pages for Clerk's arrive already bridged, * since only the wrapper can render the portals behind them. With `experimental.newProfiles`, a - * `combined` user profile also carries the organization's pages, so managing the organization opens it + * combined user profile also carries the organization's pages, so managing the organization opens it * on them. */ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { @@ -241,7 +244,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch openModal: () => { if (!newProfiles) { clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl }); - } else if (mosaicUserProfile?.mode === 'combined') { + } else if (mosaicUserProfile?.combined) { userProfile.open( { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' }, getContainer?.(), @@ -256,7 +259,8 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const managesBothInOneProfile = newProfiles && - mosaicUserProfile?.mode === 'combined' && + modals?.newProfiles?.menuMode === 'combined' && + mosaicUserProfile?.combined && opensModal(options?.userProfileUrl, options?.userProfileMode) && opensModal(options?.organizationProfileUrl, options?.organizationProfileMode); diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index fe0598ab6f9..996a6314266 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -16,7 +16,12 @@ import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { applyOrder } from '../../utils/apply-order'; import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; -import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { + CustomProfileItem, + CustomProfileLink, + CustomProfilePage, + UserProfilePageId, +} from '../user-profile/user-profile.types'; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -151,3 +156,8 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption return { customPages, portals }; } + +/** The pages among a profile's custom items. Mosaic's own profiles skip the link entries. */ +export function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined { + return items?.filter((item): item is CustomProfilePage => item.href === undefined); +} diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index e5ea93af61b..9cf9bae065f 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -1,57 +1,19 @@ 'use client'; -import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; -import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; +import { useMosaicProfile } from '../profile-pages/use-mosaic-profile'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; -import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; +import { pagesOnly, useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; -/** What a profile opened from the switcher takes beyond the profile component's own props. */ -export interface SwitcherProfilePages { - /** - * Provide custom pages and links to be rendered inside the profile. - */ - customPages?: CustomProfileItem[]; - /** - * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the - * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is - * the page the profile opens on, so it cannot be a link. - * - * @default undefined - */ - pageOrder?: (PageId | (string & {}))[]; -} - -/** Options for the underlying `` component. */ -export interface SwitcherUserProfileProps - extends - SwitcherProfilePages, - Pick {} - -/** Options for the underlying `` component. */ -export interface SwitcherOrganizationProfileProps - extends SwitcherProfilePages, Pick {} - export type SwitcherProps = SwitcherModelOptions & SwitcherTriggerProps & SwitcherMenuProps & { - /** - * Specify options for the underlying component. - * e.g., - */ - userProfileProps?: SwitcherUserProfileProps; - /** - * Specify options for the underlying component. - * e.g., organizationProfileProps={{appearance: {...}}} - */ - organizationProfileProps?: SwitcherOrganizationProfileProps; /** * Fallback while loading. * @@ -69,13 +31,12 @@ export function Switcher( triggerBadge, triggerVariant, mode, - userProfileProps, - organizationProfileProps, customMenuItems, menuItemOrder, fallback, ...options } = props; + const { combined = true, userProfileProps, organizationProfileProps } = useMosaicProfile(); // The portals must outlive the popover, so custom pages are bridged here rather than inside it. const userProfile = useCustomPages({ items: userProfileProps?.customPages, @@ -112,12 +73,13 @@ export function Switcher( pageOrder: userProfileProps?.pageOrder, additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, apiKeysProps: userProfileProps?.apiKeysProps, - mode: mode === 'combined' ? 'combined' : 'user', + combined, }, organizationProfile: { customPages: pagesOnly(organizationProfileProps?.customPages), pageOrder: organizationProfileProps?.pageOrder, }, + menuMode: mode, }, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); @@ -150,7 +112,3 @@ export function Switcher( ); } - -function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined { - return items?.filter((item): item is CustomProfilePage => item.href === undefined); -} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 757efb0102f..ba02dbbc97f 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -20,6 +20,7 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -67,9 +68,9 @@ function tree(props: UserButtonProps = {}) { ); } -function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { +function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn(), profile?: MosaicProfile) { const fapi = serveFapi(seed); - return renderWithClerk(tree(props)).then(view => ({ ...view, fapi })); + return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi })); } type User = ReturnType; @@ -268,7 +269,7 @@ describe('UserButton', () => { }); it('lists no organizations on an account-only surface', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); const user = await open(); const menu = await openAccounts(user); @@ -501,7 +502,7 @@ describe('UserButton', () => { }); it('renders a custom page into the element the opened profile hands back', async () => { - const { clerk } = await renderUserButton({ + const { clerk } = await renderUserButton({}, undefined, { userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); @@ -524,7 +525,7 @@ describe('UserButton', () => { }); it('opens the profile with its pages in the order it was given', async () => { - const { clerk } = await renderUserButton({ + const { clerk } = await renderUserButton({}, undefined, { userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], pageOrder: ['account', 'terms'], @@ -544,10 +545,9 @@ describe('UserButton', () => { { features: {}, pages: ['Terms', 'account', 'security'] }, { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] }, ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => { - const { clerk } = await renderUserButton( - { userProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, - signedIn({ environment: instanceWith(features) }), - ); + const { clerk } = await renderUserButton({}, signedIn({ environment: instanceWith(features) }), { + userProfileProps: { customPages: [terms], pageOrder: ['terms'] }, + }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); @@ -587,7 +587,7 @@ describe('UserButton', () => { const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization }); const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })]; const { clerk } = await renderUserButton( - { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, + {}, signedIn({ environment: instanceWith(features), client: fapiClient([ @@ -599,6 +599,7 @@ describe('UserButton', () => { ]), memberships, }), + { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, ); const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await open(); @@ -904,7 +905,7 @@ describe('UserButton', () => { describe('in user mode', () => { it('heads the surface with the account, never the active organization', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); await open(); expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); @@ -915,7 +916,7 @@ describe('UserButton', () => { it('drops the identifier line when it would only repeat the name', async () => { const nameless = fapiUser({ ...alice, first_name: null, last_name: null }); await renderUserButton( - { mode: 'user' }, + { combined: false }, signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless }), bobSession]) }), ); await open(); @@ -924,7 +925,7 @@ describe('UserButton', () => { }); it('lists no organizations and offers no way to make one', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); await open(); expect(screen.queryByText('Personal account')).toBeNull(); @@ -933,7 +934,7 @@ describe('UserButton', () => { }); it('manages the account from its settings row, and signs out at the foot', async () => { - const { clerk } = await renderUserButton({ mode: 'user' }); + const { clerk } = await renderUserButton({ combined: false }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); @@ -949,7 +950,7 @@ describe('UserButton', () => { }); it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); const user = await open(); const items = within(await openAccounts(user)).getAllByRole('menuitem'); @@ -1081,7 +1082,7 @@ describe('UserButton', () => { ['username', { first_name: null, last_name: null }, 'alice'], ['email address', { first_name: null, last_name: null, username: null }, 'alice@example.com'], ])('names it by its %s', async (_source, overrides, name) => { - await renderUserButton({ mode: 'user' }, signedInAs(overrides)); + await renderUserButton({ combined: false }, signedInAs(overrides)); expect(trigger()).toHaveAccessibleName(`Open account menu for ${name}`); }); @@ -1342,8 +1343,8 @@ describe('UserButton', () => { describe('the trigger', () => { it.each([ - [{ mode: 'combined' as const }, 'Acme'], - [{ mode: 'user' as const }, 'Alice Smith'], + [{ combined: true }, 'Acme'], + [{ combined: false }, 'Alice Smith'], ])('with %o names %s beside the avatar', async (props, name) => { await renderUserButton(props); @@ -1384,7 +1385,7 @@ describe('UserButton', () => { }); it('keeps the condensed trigger in user mode', async () => { - await renderUserButton({ mode: 'user', triggerVariant: 'full' }); + await renderUserButton({ combined: false, triggerVariant: 'full' }); expect(trigger()).toHaveAttribute('data-variant', 'condensed'); }); @@ -1398,19 +1399,22 @@ describe('UserButton', () => { }); it('names no organization beneath the account in user mode', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); expect(within(trigger()).queryByText('Acme')).toBeNull(); }); it('still names the account in user mode where personal is hidden and none is active', async () => { - await renderUserButton({ mode: 'user', hidePersonal: true }, signedIn({ client: fapiClient([personalSession]) })); + await renderUserButton( + { combined: false, hidePersonal: true }, + signedIn({ client: fapiClient([personalSession]) }), + ); expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument(); }); it('renders the avatar alone when the label is off', async () => { - await renderUserButton({ mode: 'user', showTriggerLabel: false }); + await renderUserButton({ combined: false, showTriggerLabel: false }); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); expect(within(trigger()).queryByText('Alice Smith')).toBeNull(); @@ -1433,17 +1437,17 @@ describe('UserButton', () => { describe('UserButton custom trigger badges', () => { it.each([ - { mode: 'combined' as const, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' }, - { mode: 'user' as const, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' }, + { combined: true, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' }, + { combined: false, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' }, { - mode: 'combined' as const, + combined: true, seed: signedIn({ client: fapiClient([personalSession]) }), badge: 'user:user_1', name: 'Alice Smith', }, - ])('shows a badge for the displayed identity as $badge', async ({ mode, seed, badge, name }) => { + ])('shows a badge for the displayed identity as $badge', async ({ combined, seed, badge, name }) => { await renderUserButton( - { mode, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) }, + { combined, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) }, seed, ); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx index 16f93c880c7..f386e817faa 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -17,6 +17,7 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -98,11 +99,15 @@ const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; describe('UserButton with experimental.newProfiles', () => { const newProfiles = { newProfiles: true }; - async function renderWithNewProfiles(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { + async function renderWithNewProfiles( + props: UserButtonProps = {}, + seed: FakeFapiSeed = signedIn(), + profile?: MosaicProfile, + ) { await page.viewport(1280, 800); onTestFinished(() => page.viewport(414, 896)); serveFapi(seed); - return renderWithClerk(tree(props), undefined, undefined, newProfiles); + return renderWithClerk(tree(props), undefined, undefined, newProfiles, profile); } const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); @@ -158,7 +163,7 @@ describe('UserButton with experimental.newProfiles', () => { }); it('renders custom pages in their order, and leaves out links', async () => { - await renderWithNewProfiles({ + await renderWithNewProfiles({}, undefined, { userProfileProps: { customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }], pageOrder: ['terms', 'account'], @@ -174,7 +179,9 @@ describe('UserButton with experimental.newProfiles', () => { }); it("lists the organization's own pages in order in the combined profile", async () => { - await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + await renderWithNewProfiles({}, undefined, { + organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] }, + }); const user = await open(); await user.click(screen.getByRole('button', { name: 'Settings' })); @@ -192,8 +199,8 @@ describe('UserButton with experimental.newProfiles', () => { expect(navigate).toHaveBeenCalledWith('/org'); }); - it("leaves the organization's pages out in user mode", async () => { - await renderWithNewProfiles({ mode: 'user' }); + it("leaves the organization's pages out of a separate profile", async () => { + await renderWithNewProfiles({ combined: false }, undefined, { combined: false }); const user = await open(); await user.click(screen.getByRole('button', { name: 'Settings' })); diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx index 5f6ab970961..5cf288dca80 100644 --- a/packages/mosaic/src/features/user-button/user-button.tsx +++ b/packages/mosaic/src/features/user-button/user-button.tsx @@ -4,35 +4,29 @@ import type { ReactElement } from 'react'; import type { SwitcherProps } from '../switcher/switcher'; import { Switcher } from '../switcher/switcher'; -import type { SwitcherMode } from '../switcher/switcher.types'; import type { SwitcherTriggerVariant } from '../switcher/switcher.view'; -/** - * Which switchers the surface carries. `combined` is both; `user` is an account switcher that never - * shows an organization, even when one is active. - */ -export type UserButtonMode = Exclude; - -/** Which switchers the surface carries. */ -export interface UserButtonModeProps { +/** What the menu lists, and how much its trigger carries. */ +export interface UserButtonMenuProps { /** - * Which switchers the popup carries: both, or accounts alone. + * Whether the menu lists organizations beside accounts. `false` lists accounts alone, and never shows + * an organization, even when one is active. The profile's shape is set by `MosaicProvider`'s `profile`. * - * @default 'combined' + * @default true */ - mode?: UserButtonMode; + combined?: boolean; /** - * How much the labelled trigger carries in `combined` mode: `full` badges the active organization - * with the account at a larger size, `condensed` shows the lead's avatar alone. `user` mode is - * always `condensed`. + * How much the labelled trigger carries when `combined`: `full` badges the active organization with + * the account at a larger size, `condensed` shows the lead's avatar alone. Otherwise it is always + * `condensed`. * * @default 'full' */ triggerVariant?: SwitcherTriggerVariant; } -/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profiles it opens. */ -export type UserButtonProps = SwitcherProps & UserButtonModeProps; +/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ +export type UserButtonProps = SwitcherProps & UserButtonMenuProps; /** * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account, @@ -50,9 +44,9 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * ``` * * @example - * `mode` narrows the menu to the account switcher. + * `combined={false}` narrows the menu to the account switcher. * ```tsx - * + * * ``` * * @example @@ -82,18 +76,10 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * ``` * * @example - * `customPages` adds your own pages to either profile this button opens; `customMenuItems` adds your - * own rows beside Settings, each one either an `onClick` action or an `href` link. + * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an + * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`. * ```tsx * , content: }], - * pageOrder: ['account', 'usage', 'security'], - * }} - * organizationProfileProps={{ - * customPages: [{ path: 'audit', label: 'Audit log', icon: , content: }], - * pageOrder: ['general', 'members', 'audit'], - * }} * customMenuItems={[ * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, @@ -102,8 +88,7 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * /> * ``` */ -export function UserButton({ mode, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement { - const combined = mode !== 'user'; +export function UserButton({ combined = true, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement { return ( { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); }); - it('lists only the account pages in user mode', async () => { + it('lists only the account pages when not combined', async () => { serveWithOrganization(); - await renderWithClerk(); + await renderWithClerk(); expect(tabNames()).toEqual(['Account', 'Security']); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts index 73ea32c6e4e..c1c8d1e51b0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts @@ -3,7 +3,7 @@ import type { UserProfileProps } from './user-profile'; export interface UserProfileModalOptions extends Pick< UserProfileProps, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' | 'organizationProfileProps' + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined' | 'organizationProfileProps' > { page?: UserProfileProps['defaultPage']; } diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index d352d11380c..6e138e9b768 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -8,8 +8,8 @@ import { organizationProfilePanels } from '../organization-profile/organization- import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; -import { useUserProfilePages } from '../switcher/switcher.pages'; -import type { SwitcherMode } from '../switcher/switcher.types'; +import { useMosaicProfile } from '../profile-pages/use-mosaic-profile'; +import { pagesOnly, useUserProfilePages } from '../switcher/switcher.pages'; import { userProfilePageIds } from './user-profile.layout'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; @@ -20,12 +20,6 @@ import { UserProfileProfilePanel } from './user-profile-profile-panel'; import { UserProfileSecurityPanel } from './user-profile-security-panel'; import { useUserProfileUserModel } from './user-profile-user.model'; -/** - * Which profiles the surface carries. `combined` adds the active organization's pages after the - * account's; `user` shows the account's alone, even when an organization is active. - */ -export type UserProfileMode = Exclude; - /** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */ export interface UserProfileProps extends Pick, Pick { @@ -47,14 +41,14 @@ export interface UserProfileProps */ apiKeysProps?: APIKeysPageProps; /** - * Which profiles the surface carries: the account's and the active organization's, or the - * account's alone. + * Whether the surface adds the active organization's pages after the account's. `false` shows the + * account's alone, even when an organization is active. * - * @default 'combined' + * @default `MosaicProvider`'s `profile.combined`, which is `true` unless set */ - mode?: UserProfileMode; + combined?: boolean; /** - * Options for the organization's pages in `combined` mode: your own pages and their order, the API + * Options for the organization's pages when `combined`: your own pages and their order, the API * keys page, and where leaving the organization lands. `pageOrder` names the organization's pages * without the `organization/` prefix, as in `['members', 'general']`. */ @@ -89,9 +83,9 @@ export interface UserProfileProps * ``` * * @example - * `mode='user'` leaves the active organization's pages out. + * `combined={false}` leaves the active organization's pages out. * ```tsx - * + * * ``` * * @example @@ -121,9 +115,29 @@ export interface UserProfileProps * ``` */ export function UserProfile(props: UserProfileProps) { + const { combined, userProfileProps, organizationProfileProps } = useMosaicProfile(); + const { + combined: ownCombined = combined, + customPages = pagesOnly(userProfileProps?.customPages), + pageOrder = userProfileProps?.pageOrder, + additionalOAuthScopes = userProfileProps?.additionalOAuthScopes, + apiKeysProps = userProfileProps?.apiKeysProps, + organizationProfileProps: ownOrganizationProfileProps = { + customPages: pagesOnly(organizationProfileProps?.customPages), + pageOrder: organizationProfileProps?.pageOrder, + }, + ...rest + } = props; + return ( ); @@ -131,7 +145,7 @@ export function UserProfile(props: UserProfileProps) { export function UserProfileSurface({ presentation, - mode = 'combined', + combined = true, organizationProfileProps, customPages, pageOrder, @@ -168,13 +182,13 @@ export function UserProfileSurface({ additionalOAuthScopes={additionalOAuthScopes} mode={presentation} > - {mode === 'user' ? ( - - ) : ( + {combined ? ( + ) : ( + )} ); diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index fb3e2beacba..a64f3111ea2 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -8,8 +8,9 @@ export type { OrganizationSwitcherProps } from './features/organization-switcher export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; export type { OrganizationProfileProps } from './features/organization-profile/organization-profile'; export { OrganizationProfile } from './features/organization-profile/organization-profile'; -export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; +export type { UserProfileProps } from './features/user-profile/user-profile'; export { UserProfile } from './features/user-profile/user-profile'; +export type { MosaicProfile } from './features/profile-pages/use-mosaic-profile'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 7f90a73d2c0..9bc8b271cfa 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; +import type { MosaicProfile } from './features/profile-pages/use-mosaic-profile'; +import { MosaicProfileProvider } from './features/profile-pages/use-mosaic-profile'; import { userProfileModal } from './features/user-profile/user-profile.modal'; import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return'; import type { MosaicExperimental } from './hooks/use-mosaic-experimental'; @@ -26,6 +28,8 @@ export interface MosaicProviderProps { localization?: MosaicLocalization; /** Features still being tried out. Their names and behavior can change in any release. */ experimental?: MosaicExperimental; + /** The profile `UserButton`, `OrganizationSwitcher` and `UserProfile` share: its shape, and the options it takes. */ + profile?: MosaicProfile; } // Exported Mosaic components annotate a React return type on purpose. The package sets @@ -37,6 +41,7 @@ export function MosaicProvider({ icons, localization, experimental, + profile = defaultProfile, }: MosaicProviderProps): React.ReactElement { const iconsValue = React.useMemo(() => icons ?? {}, [icons]); const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); @@ -50,12 +55,14 @@ export function MosaicProvider({ - - - - {children} - - + + + + + {children} + + + @@ -64,4 +71,5 @@ export function MosaicProvider({ ); } +const defaultProfile: MosaicProfile = {}; const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal]; diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx index 37670fef95f..b3321f42f96 100644 --- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx @@ -30,7 +30,7 @@ export default function UserButtonLivePage() { ) : null}
- +
diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx index d75687b7002..9949de22919 100644 --- a/packages/swingset/src/stories/profile-permutations.mdx +++ b/packages/swingset/src/stories/profile-permutations.mdx @@ -2,30 +2,37 @@ import * as Stories from './profile-permutations.stories'; # Profile permutations -Exploration page: every pairing of switchers and profile shape, running on fixtures. Each example shares -one account and organization across its triggers, so switching in one updates the other and the profile. - -Today `mode` on `UserButton` sets both the menu and the profile. `OrganizationSwitcher` always opens the -standalone organization profile. - -The snippets show the proposed API, which is not implemented. `combined` defaults to `true` in two places: -on `UserButton` it decides what the menu lists, and on `MosaicProvider`'s `profile` it decides what the -profile shows. - -| # | Switchers | Profile | With the modal today | Proposed config | -| --- | ---------------------------------------------- | -------- | --------------------------------------------- | ---------------------- | -| 1 | `UserButton` combined | Combined | Yes (the default) | None | -| 2 | `UserButton` combined | Separate | No | Profile opt-out | -| 3 | `UserButton` user | Account | Yes | Menu + profile opt-out | -| 4 | `UserButton` user | Combined | No | Menu opt-out | -| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Yes | Menu + profile opt-out | -| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | No | Menu opt-out | -| 7 | `OrganizationSwitcher` | Org | Yes | Profile opt-out | -| 8 | `OrganizationSwitcher` | Combined | No | None | -| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | No: the switcher opens the standalone profile | None | - -The `No` rows can be approximated by routing with `userProfileUrl` and `organizationProfileUrl` to pages -that mount ``. +Every pairing of switchers and profile shape, running on fixtures. Each example shares one account and +organization across its triggers, so switching in one updates the other and the profile. + +`combined` defaults to `true` in two places: on `UserButton` it decides what the menu lists, and on +`MosaicProvider`'s `profile` it decides what the profile shows. `UserProfile` follows the provider unless +it sets `combined` itself. Custom pages and page order go on the provider too: + +```tsx + + + + +``` + +| # | Switchers | Profile | Config | +| --- | ---------------------------------------------- | -------- | ---------------------- | +| 1 | `UserButton` combined | Combined | None | +| 2 | `UserButton` combined | Separate | Profile opt-out | +| 3 | `UserButton` user | Account | Menu + profile opt-out | +| 4 | `UserButton` user | Combined | Menu opt-out | +| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Menu + profile opt-out | +| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | Menu opt-out | +| 7 | `OrganizationSwitcher` | Org | Profile opt-out | +| 8 | `OrganizationSwitcher` | Combined | None | +| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | None | ## 1. Combined button, combined profile diff --git a/packages/swingset/src/stories/profile-permutations.stories.tsx b/packages/swingset/src/stories/profile-permutations.stories.tsx index 2a09f1ce129..2e0075e5d31 100644 --- a/packages/swingset/src/stories/profile-permutations.stories.tsx +++ b/packages/swingset/src/stories/profile-permutations.stories.tsx @@ -5,7 +5,6 @@ import type { import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; import type { SwitcherViewProps } from '@clerk/mosaic/features/switcher/switcher.view'; import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view'; -import type { UserButtonMode } from '@clerk/mosaic/features/user-button/user-button'; import type { UserProfileModalContentProps, UserProfileModalOptions, @@ -116,7 +115,7 @@ export function CombinedButtonAndSwitcher(_args: Record) { } interface PermutationProps { - userButton?: UserButtonMode; + userButton?: 'combined' | 'user'; organizationSwitcher?: boolean; profile: 'combined' | 'separate'; } @@ -134,16 +133,16 @@ function Triggers({ userButton, organizationSwitcher = false, profile }: Permuta const [openSurface, setOpenSurface] = useState<'userButton' | 'organizationSwitcher'>('userButton'); const userProfile = useModal(fixtureUserProfileModal); const organizationProfile = useModal(fixtureOrganizationProfileModal); - const combined = profile === 'combined' && prototype.activeOrganization ? 'combined' : 'user'; + const combined = profile === 'combined' && Boolean(prototype.activeOrganization); const manageAccount = () => { prototype.onManageAccount?.(); - userProfile.open({ mode: combined }); + userProfile.open({ combined }); }; const manageOrganization = () => { prototype.onManageOrganization?.(); if (profile === 'combined') { - userProfile.open({ mode: combined, page: 'organization/general' }); + userProfile.open({ combined, page: 'organization/general' }); } else { organizationProfile.open(); } @@ -202,7 +201,7 @@ function FixtureUserProfile({ options }: UserProfileModalContentProps) { ); diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index b6267038785..da4761bf497 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -301,7 +301,7 @@ function FixtureUserProfile({ options }: UserProfileModalContentProps) { pages={pages} customPages={options?.customPages} pageOrder={options?.pageOrder} - organization={options?.mode === 'user' ? undefined : { pages: organization.pages }} + organization={options?.combined === false ? undefined : { pages: organization.pages }} onPageChange={setActivePage} /> ); @@ -364,7 +364,7 @@ function ProfileModalTriggers() {