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..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 @@ -1,29 +1,22 @@ +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; - afterLeaveOrganizationUrl?: string; -}) { - const model = useOrganizationProfileGeneralPanelModel(); - - if (model.status === 'loading') { - return fallback ?? null; - } - if (model.status === 'hidden') { - return null; - } +export interface OrganizationProfileGeneralPanelProps { + children?: ReactNode; +} - const { status, organizationId, ...general } = model; +export function OrganizationProfileGeneralPanel({ children }: 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/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/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/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'], 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..d556647a7ab 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. + +```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 ( ); }