From 73e4bbb0fa60fbada7103e817bae770538f5b759 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:11:06 -0400 Subject: [PATCH 1/3] refactor(mosaic): render self-contained sections in the organization general panel --- .../mosaic-org-general-panel-sections.md | 2 + .../organization-profile.layout.test.tsx | 2 +- .../organization-profile.view.test.tsx | 2 +- ...ion-profile-general-panel.feature.test.tsx | 40 +++++++++++++++++-- .../organization-profile-general-panel.tsx | 39 +++++++++--------- ...rganization-profile-general-panel.view.tsx | 32 ++------------- ...nization-profile-profile-section.model.ts} | 14 +++---- .../organization-profile-profile-section.tsx | 27 +++++++++++++ .../stories/fixtures/organization-profile.tsx | 22 ++++++---- .../organization-profile-general-panel.mdx | 23 ++++++++++- ...nization-profile-general-panel.stories.tsx | 17 ++++---- ...zation-profile-profile-section.stories.tsx | 36 ++++++++--------- 12 files changed, 161 insertions(+), 95 deletions(-) create mode 100644 .changeset/mosaic-org-general-panel-sections.md rename packages/mosaic/src/features/organization-profile/{organization-profile-general-panel.model.ts => organization-profile-profile-section/organization-profile-profile-section.model.ts} (77%) create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.tsx diff --git a/.changeset/mosaic-org-general-panel-sections.md b/.changeset/mosaic-org-general-panel-sections.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-org-general-panel-sections.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx index b9a99f16665..a82af4dfba5 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { getAvailableOrganizationProfilePages } from '../organization-profile.layout'; import type { OrganizationProfilePages } from '../organization-profile.types'; -const general: OrganizationProfilePages['general'] = { name: 'Acme', slug: 'acme', memberCount: 1 }; +const general: OrganizationProfilePages['general'] = {}; describe('getAvailableOrganizationProfilePages', () => { it('lists api keys given content', () => { diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx index d26c79c8c8d..5c50d715f39 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx @@ -11,7 +11,7 @@ it('keeps the Members placeholder until a table is configured', () => { activePage='members' onPageChange={vi.fn()} pages={{ - general: { name: 'Acme', slug: 'acme', memberCount: 1 }, + general: {}, members: {}, }} /> diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx index 868d34526f2..db60148b232 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx @@ -15,7 +15,9 @@ import { } from '../../__tests__/feature/fapi'; import { renderWithClerk } from '../../__tests__/feature/render'; import { MosaicProvider } from '../../mosaic-provider'; +import { OrganizationProfileDangerSection } from './organization-profile-danger-section/organization-profile-danger-section'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; +import { OrganizationProfileProfileSection } from './organization-profile-profile-section/organization-profile-profile-section'; const acme = fapiOrganization({ id: 'org_1', name: 'Acme', slug: 'acme' }); @@ -47,9 +49,41 @@ async function renderPanel(seed: FakeFapiSeed = signedIn()) { describe('OrganizationProfileGeneralPanel', () => { describe('availability', () => { - it('hides without an active organization', async () => { - const { container } = await renderPanel(signedIn({ activeOrganizationId: null })); - expect(container).toBeEmptyDOMElement(); + it('keeps the title and hides every section without an active organization', async () => { + await renderPanel(signedIn({ activeOrganizationId: null })); + expect(await screen.findByRole('heading', { name: 'General', level: 2 })).toBeVisible(); + expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0); + }); + + it('renders the given sections in order instead of the defaults', async () => { + serveFapi(signedIn()); + await renderWithClerk( + + + + , + ); + await screen.findByRole('heading', { name: 'Organization details' }); + expect(screen.getAllByRole('heading', { level: 3 }).map(heading => heading.textContent)).toEqual([ + 'Danger zone', + 'Organization details', + ]); + }); + + it('shows the section fallback while the organization loads', async () => { + serveFapi(signedIn()); + const loading = renderWithClerk( + + Loading details

} /> +
, + ); + try { + expect(screen.getByText('Loading details')).toBeInTheDocument(); + } finally { + await loading; + } + expect(await screen.findByRole('heading', { name: 'Organization details' })).toBeVisible(); + expect(screen.queryByText('Loading details')).toBeNull(); }); it('shows details and danger but omits mutation controls without manage permission', async () => { diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx index cbc6ffc1d91..a9d4a821641 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx @@ -1,29 +1,26 @@ +import type { ReactNode } from 'react'; + import { OrganizationProfileDangerSection } from './organization-profile-danger-section/organization-profile-danger-section'; -import { useOrganizationProfileGeneralPanelModel } from './organization-profile-general-panel.model'; import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel.view'; +import { OrganizationProfileProfileSection } from './organization-profile-profile-section/organization-profile-profile-section'; -export function OrganizationProfileGeneralPanel({ - fallback, - afterLeaveOrganizationUrl, -}: { - fallback?: React.ReactNode; +export interface OrganizationProfileGeneralPanelProps { + children?: ReactNode; afterLeaveOrganizationUrl?: string; -}) { - const model = useOrganizationProfileGeneralPanelModel(); - - if (model.status === 'loading') { - return fallback ?? null; - } - if (model.status === 'hidden') { - return null; - } +} - const { status, organizationId, ...general } = model; +export function OrganizationProfileGeneralPanel({ + children, + afterLeaveOrganizationUrl, +}: OrganizationProfileGeneralPanelProps) { return ( - } - /> + + {children ?? ( + <> + + + + )} + ); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx index e201a7037a3..a2a9e5d110c 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.view.tsx @@ -3,44 +3,20 @@ import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; import { useMessages } from '../../localization'; import { themeProps } from '../../props'; -import type { OrganizationProfileProfileSectionViewProps } from './organization-profile-profile-section/organization-profile-profile-section.view'; -import { OrganizationProfileProfileSectionView } from './organization-profile-profile-section/organization-profile-profile-section.view'; -export interface OrganizationProfileGeneralPanelViewProps extends OrganizationProfileProfileSectionViewProps { - dangerSlot?: ReactNode; +export interface OrganizationProfileGeneralPanelViewProps { + children?: ReactNode; } export function OrganizationProfileGeneralPanelView({ - name, - slug, - imageUrl, - hasImage, - onLogoChange, - onLogoReject, - onRemoveLogo, - onSubmitName, - onSubmitSlug, - dangerSlot, + children, }: OrganizationProfileGeneralPanelViewProps): ReactElement { const m = useMessages('organizationProfile'); return ( }> {m.pages.general} - - - {dangerSlot} - + {children} ); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts similarity index 77% rename from packages/mosaic/src/features/organization-profile/organization-profile-general-panel.model.ts rename to packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts index c75bf9d847e..76dbbf1ce2a 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts @@ -1,18 +1,16 @@ import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/shared/types'; -import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; -import { save, SaveError, UNEXPECTED_ERROR } from '../../utils/errors'; -import type { OrganizationProfileGeneralPanelViewProps } from './organization-profile-general-panel.view'; +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/errors'; +import type { OrganizationProfileProfileSectionViewProps } from './organization-profile-profile-section.view'; -type GeneralData = Omit; - -type OrganizationProfileGeneralPanelModel = +type OrganizationProfileProfileSectionModel = | { status: 'loading' } | { status: 'hidden' } - | (GeneralData & { status: 'ready'; organizationId: string }); + | (OrganizationProfileProfileSectionViewProps & { status: 'ready'; organizationId: string }); -export function useOrganizationProfileGeneralPanelModel(): OrganizationProfileGeneralPanelModel { +export function useOrganizationProfileProfileSectionModel(): OrganizationProfileProfileSectionModel { const { isLoaded, organization, membership } = useOrganization(); const { isLoaded: isSessionLoaded, session } = useSession(); const clerk = useClerk(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.tsx new file mode 100644 index 00000000000..48ee7502d6e --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +import { useOrganizationProfileProfileSectionModel } from './organization-profile-profile-section.model'; +import { OrganizationProfileProfileSectionView } from './organization-profile-profile-section.view'; + +export interface OrganizationProfileProfileSectionProps { + fallback?: ReactNode; +} + +export function OrganizationProfileProfileSection({ fallback }: OrganizationProfileProfileSectionProps) { + const model = useOrganizationProfileProfileSectionModel(); + + if (model.status === 'loading') { + return fallback ?? null; + } + if (model.status === 'hidden') { + return null; + } + + const { status, organizationId, ...profile } = model; + return ( + + ); +} diff --git a/packages/swingset/src/stories/fixtures/organization-profile.tsx b/packages/swingset/src/stories/fixtures/organization-profile.tsx index 38c0c4eaeaf..f41e850d29f 100644 --- a/packages/swingset/src/stories/fixtures/organization-profile.tsx +++ b/packages/swingset/src/stories/fixtures/organization-profile.tsx @@ -1,6 +1,8 @@ import { useDestructiveController } from '@clerk/mosaic/blocks/destructive/destructive.controller'; import type { OrganizationProfileViewProps } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; import { OrganizationProfileDangerSectionView } from '@clerk/mosaic/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view'; +import type { OrganizationProfileProfileSectionViewProps } from '@clerk/mosaic/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view'; +import { OrganizationProfileProfileSectionView } from '@clerk/mosaic/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view'; import { SaveError } from '@clerk/mosaic/utils/errors'; import { useState } from 'react'; @@ -61,7 +63,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF apply(); }; - const general: OrganizationProfileViewProps['pages']['general'] = { + const profile: OrganizationProfileProfileSectionViewProps = { name, slug, imageUrl, @@ -70,11 +72,17 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF onRemoveLogo: clearImage, onSubmitName: async next => save('name', () => setName(next)), onSubmitSlug: async next => save('slug', () => setSlug(next)), - dangerSlot: ( - + }; + + const general: OrganizationProfileViewProps['pages']['general'] = { + children: ( + <> + + + ), }; @@ -86,5 +94,5 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF apiKeys: , }; - return { activePage, setActivePage, pages, general, name }; + return { activePage, setActivePage, pages, general, profile, name }; } diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx index cb7ec17a2ec..3484037b66e 100644 --- a/packages/swingset/src/stories/organization-profile-general-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -13,9 +13,30 @@ The organization's General page: its details, and the danger zone under them. ]} /> +## Usage + +`OrganizationProfileGeneralPanel` renders the General title with the organization details and danger zone sections. Each section shows itself only when there is an active organization. `afterLeaveOrganizationUrl` is passed to the default danger zone section. + +```tsx +import { OrganizationProfileGeneralPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel'; + +; +``` + +Pass sections as children to choose which ones render and in what order. + +```tsx +import { OrganizationProfileGeneralPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel'; +import { OrganizationProfileProfileSection } from '@clerk/mosaic/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section'; + + + +; +``` + ## Without the danger zone -The danger zone renders from `dangerSlot`. Without it, the panel shows only the organization details. +The panel shows only the organization details, read-only. + + + ); } diff --git a/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx b/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx index 31a9f54eb46..44cb61aed6c 100644 --- a/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx +++ b/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx @@ -18,33 +18,33 @@ export const meta: StoryMeta = { }; export function Default() { - const { general } = useOrganizationProfileFixture(); + const { profile } = useOrganizationProfileFixture(); return ( ); } export function SaveFails() { - const { general } = useOrganizationProfileFixture({ failWith: { slug: 'That slug is already taken.' } }); + const { profile } = useOrganizationProfileFixture({ failWith: { slug: 'That slug is already taken.' } }); return ( ); } From 42557b4899ef2f650f3aa068bf3d363861ae8c93 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:16:14 -0400 Subject: [PATCH 2/3] feat(swingset): add live organization details section --- .../live/organization-details/page.tsx | 40 +++++++++++++++++++ packages/swingset/src/lib/live-navigation.ts | 1 + 2 files changed, 41 insertions(+) create mode 100644 packages/swingset/src/app/(clerk)/live/organization-details/page.tsx diff --git a/packages/swingset/src/app/(clerk)/live/organization-details/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-details/page.tsx new file mode 100644 index 00000000000..5cd80e0b7d7 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/organization-details/page.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { OrganizationProfileProfileSection } from '@clerk/mosaic/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useOrganization, useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function OrganizationDetailsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + const { organization } = useOrganization(); + + return ( + +
+
+

Organization details

+

+ The active organization's logo, name, and slug. Editing needs the manage profile permission. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + and pick an active organization. +

+ ) : null} + {isLoaded && isSignedIn && !organization ? ( +

Pick an active organization to see its details.

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index d75b60268e2..ff450aed57f 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -25,6 +25,7 @@ const liveRoutes = new Map([ ['/organization-profile/organization-profile-general-panel', '/live/organization-general'], ['/organization-profile/organization-profile-members-panel', '/live/organization-members'], ['/organization-profile/organization-profile-api-keys-panel', '/live/organization-api-keys'], + ['/organization-profile/organization-profile-profile-section', '/live/organization-details'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], ['/user-profile/user-profile-web3wallets-section', '/live/web3-wallets'], From a1094fff9020462a521adb1e7dee9e28660ba50f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:23:07 -0400 Subject: [PATCH 3/3] refactor(mosaic): drop afterLeaveOrganizationUrl from the organization general panel --- .../organization-profile-general-panel.tsx | 8 ++------ .../src/app/(clerk)/live/organization-general/page.tsx | 2 +- .../src/stories/organization-profile-general-panel.mdx | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx index a9d4a821641..7b0833e2340 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.tsx @@ -6,19 +6,15 @@ import { OrganizationProfileProfileSection } from './organization-profile-profil export interface OrganizationProfileGeneralPanelProps { children?: ReactNode; - afterLeaveOrganizationUrl?: string; } -export function OrganizationProfileGeneralPanel({ - children, - afterLeaveOrganizationUrl, -}: OrganizationProfileGeneralPanelProps) { +export function OrganizationProfileGeneralPanel({ children }: OrganizationProfileGeneralPanelProps) { return ( {children ?? ( <> - + )} diff --git a/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx index 52fc3208d95..435c51857fb 100644 --- a/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/organization-general/page.tsx @@ -27,7 +27,7 @@ export default function OrganizationGeneralLivePage() { {isLoaded && isSignedIn && !organization ? (

Pick an active organization to see its general panel.

) : null} - {isLoaded && isSignedIn ? : null} + {isLoaded && isSignedIn ? : null} ); diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx index 3484037b66e..d556647a7ab 100644 --- a/packages/swingset/src/stories/organization-profile-general-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -15,12 +15,12 @@ The organization's General page: its details, and the danger zone under them. ## Usage -`OrganizationProfileGeneralPanel` renders the General title with the organization details and danger zone sections. Each section shows itself only when there is an active organization. `afterLeaveOrganizationUrl` is passed to the default danger zone section. +`OrganizationProfileGeneralPanel` renders the General title with the organization details and danger zone sections. Each section shows itself only when there is an active organization. ```tsx import { OrganizationProfileGeneralPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel'; -; +; ``` Pass sections as children to choose which ones render and in what order.