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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-org-general-panel-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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(
<OrganizationProfileGeneralPanel>
<OrganizationProfileDangerSection />
<OrganizationProfileProfileSection />
</OrganizationProfileGeneralPanel>,
);
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(
<OrganizationProfileGeneralPanel>
<OrganizationProfileProfileSection fallback={<p>Loading details</p>} />
</OrganizationProfileGeneralPanel>,
);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<OrganizationProfileGeneralPanelView
key={organizationId}
{...general}
dangerSlot={<OrganizationProfileDangerSection afterLeaveOrganizationUrl={afterLeaveOrganizationUrl} />}
/>
<OrganizationProfileGeneralPanelView>
{children ?? (
<>
<OrganizationProfileProfileSection />
<OrganizationProfileDangerSection />
</>
)}
</OrganizationProfileGeneralPanelView>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Panel.Root render={<div {...themeProps('organization-profile-general-panel')} />}>
<Panel.Title>{m.pages.general}</Panel.Title>
<Panel.Sections>
<OrganizationProfileProfileSectionView
name={name}
slug={slug}
imageUrl={imageUrl}
hasImage={hasImage}
onLogoChange={onLogoChange}
onLogoReject={onLogoReject}
onRemoveLogo={onRemoveLogo}
onSubmitName={onSubmitName}
onSubmitSlug={onSubmitSlug}
/>
{dangerSlot}
</Panel.Sections>
<Panel.Sections>{children}</Panel.Sections>
</Panel.Root>
);
}
Original file line number Diff line number Diff line change
@@ -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<OrganizationProfileGeneralPanelViewProps, 'dangerSlot'>;

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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<OrganizationProfileProfileSectionView
key={organizationId}
{...profile}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<MosaicProvider>
<div className='mx-auto flex w-full max-w-3xl flex-col gap-6 p-3 sm:p-8'>
<div className='flex flex-col gap-1'>
<h1 className='text-xl font-semibold'>Organization details</h1>
<p className='text-muted-foreground text-sm'>
The active organization&apos;s logo, name, and slug. Editing needs the manage profile permission.
</p>
</div>
{!isLoaded ? <p className='text-muted-foreground text-sm'>Loading…</p> : null}
{isLoaded && !isSignedIn ? (
<p className='text-muted-foreground text-sm'>
<Link
href='/sign-in'
className='text-foreground underline underline-offset-4'
>
Sign in
</Link>{' '}
and pick an active organization.
</p>
) : null}
{isLoaded && isSignedIn && !organization ? (
<p className='text-muted-foreground text-sm'>Pick an active organization to see its details.</p>
) : null}
{isLoaded && isSignedIn ? <OrganizationProfileProfileSection /> : null}
</div>
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function OrganizationGeneralLivePage() {
{isLoaded && isSignedIn && !organization ? (
<p className='text-muted-foreground text-sm'>Pick an active organization to see its general panel.</p>
) : null}
{isLoaded && isSignedIn ? <OrganizationProfileGeneralPanel afterLeaveOrganizationUrl='/live' /> : null}
{isLoaded && isSignedIn ? <OrganizationProfileGeneralPanel /> : null}
</div>
</MosaicProvider>
);
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/lib/live-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
22 changes: 15 additions & 7 deletions packages/swingset/src/stories/fixtures/organization-profile.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -61,7 +63,7 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF
apply();
};

const general: OrganizationProfileViewProps['pages']['general'] = {
const profile: OrganizationProfileProfileSectionViewProps = {
name,
slug,
imageUrl,
Expand All @@ -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: (
<OrganizationProfileDangerPreview
name={name}
memberCount={organization.memberCount}
/>
};

const general: OrganizationProfileViewProps['pages']['general'] = {
children: (
<>
<OrganizationProfileProfileSectionView {...profile} />
<OrganizationProfileDangerPreview
name={name}
memberCount={organization.memberCount}
/>
</>
),
};

Expand All @@ -86,5 +94,5 @@ export function useOrganizationProfileFixture({ failWith }: OrganizationProfileF
apiKeys: <APIKeysPanelExample {...apiKeys} />,
};

return { activePage, setActivePage, pages, general, name };
return { activePage, setActivePage, pages, general, profile, name };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

<OrganizationProfileGeneralPanel />;
```

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';

<OrganizationProfileGeneralPanel>
<OrganizationProfileProfileSection />
</OrganizationProfileGeneralPanel>;
```

## 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.

<Story
name='ReadOnly'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OrganizationProfileGeneralPanelView } from '@clerk/mosaic/features/organization-profile/organization-profile-general-panel.view';
import { OrganizationProfileProfileSectionView } from '@clerk/mosaic/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.view';

import type { StoryMeta } from '@/lib/types';

Expand All @@ -21,13 +22,15 @@ export function Default() {
}

export function ReadOnly() {
const { general } = useOrganizationProfileFixture();
const { profile } = useOrganizationProfileFixture();
return (
<OrganizationProfileGeneralPanelView
name={general.name}
slug={general.slug}
imageUrl={general.imageUrl}
hasImage={general.hasImage}
/>
<OrganizationProfileGeneralPanelView>
<OrganizationProfileProfileSectionView
name={profile.name}
slug={profile.slug}
imageUrl={profile.imageUrl}
hasImage={profile.hasImage}
/>
</OrganizationProfileGeneralPanelView>
);
}
Loading
Loading