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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')),
'organization-profile-profile-section': dynamic(
() => import('../stories/organization-profile-profile-section.mdx'),
),
'organization-profile-leave-section': dynamic(() => import('../stories/organization-profile-leave-section.mdx')),
'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')),
},
Expand Down
9 changes: 9 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ import {
Default as OrganizationProfileLeaveSectionDefault,
meta as organizationProfileLeaveSectionMeta,
} from '../stories/organization-profile-leave-section.stories';
import {
Default as OrganizationProfileProfileSectionDefault,
meta as organizationProfileProfileSectionMeta,
} from '../stories/organization-profile-profile-section.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
Expand All @@ -72,6 +76,10 @@ const organizationProfileDeleteSectionModule: StoryModule = {
meta: organizationProfileDeleteSectionMeta,
Default: OrganizationProfileDeleteSectionDefault,
};
const organizationProfileProfileSectionModule: StoryModule = {
meta: organizationProfileProfileSectionMeta,
Default: OrganizationProfileProfileSectionDefault,
};
const organizationProfileModule: StoryModule = { meta: organizationProfileMeta, Default: OrganizationProfileDefault };
const organizationProfileGeneralPanelModule: StoryModule = {
meta: organizationProfileGeneralPanelMeta,
Expand Down Expand Up @@ -124,6 +132,7 @@ export const registry: StoryModule[] = [
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
organizationProfileProfileSectionModule,
organizationProfileLeaveSectionModule,
organizationProfileDeleteSectionModule,
// Blocks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ The General tab panel of the Organization Profile — composes the organization-
name='Default'
storyModule={OrganizationProfileGeneralPanelStories}
composition={[
{
name: 'OrganizationProfileProfileSection',
href: '/organization/organization-profile-profile-section',
layer: 'Organization',
},
{
name: 'OrganizationProfileLeaveSection',
href: '/organization/organization-profile-leave-section',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { StoryMeta } from '@/lib/types';

import { Default as OrganizationProfileDeleteSectionDemo } from './organization-profile-delete-section.stories';
import { Default as OrganizationProfileLeaveSectionDemo } from './organization-profile-leave-section.stories';
import { Default as OrganizationProfileProfileSectionDemo } from './organization-profile-profile-section.stories';

export const meta: StoryMeta = {
group: 'Organization',
Expand All @@ -15,6 +16,7 @@ export const meta: StoryMeta = {
export function Default() {
return (
<OrganizationProfileGeneralPanelView
profile={<OrganizationProfileProfileSectionDemo />}
leaveOrganization={<OrganizationProfileLeaveSectionDemo />}
deleteOrganization={<OrganizationProfileDeleteSectionDemo />}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as OrganizationProfileProfileSectionStories from './organization-profile-profile-section.stories';

# Organization Profile Profile Section

A section that owns the open/editing/saving state for an organization's name and slug, wiring a `Dialog` around the edit form. Edits live as machine-owned drafts that fall through to the committed organization values, so the form seeds itself and closes on a successful save without a syncing effect.

<Story
name='Default'
storyModule={OrganizationProfileProfileSectionStories}
composition={[
{ name: 'Dialog', href: '/components/dialog', layer: 'Components' },
{ name: 'Button', href: '/components/button', layer: 'Components' },
{ name: 'Input', href: '/components/input', layer: 'Components' },
]}
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** @jsxImportSource @emotion/react */
import { useMachine } from '@clerk/ui/mosaic/machine/useMachine';
import { OrganizationProfileProfileSectionView } from '@clerk/ui/mosaic/organization/organization-profile-profile-section.view';
import { organizationProfileProfileSectionDetailsMachine } from '@clerk/ui/mosaic/organization/organization-profile-profile-section-details.machine';

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

export const meta: StoryMeta = {
group: 'Organization',
title: 'OrganizationProfileProfileSection',
source: 'packages/ui/src/mosaic/organization/organization-profile-profile-section.tsx',
};

export function Default() {
const [snapshot, send, actor] = useMachine(organizationProfileProfileSectionDetailsMachine, {
context: {
committedName: 'Acme Inc',
committedSlug: 'acme',
slugEnabled: true,
updateOrganization: () => new Promise<void>(resolve => setTimeout(resolve, 800)),
},
});

return (
<OrganizationProfileProfileSectionView
snapshot={snapshot}
send={send}
canSubmit={actor.can({ type: 'SUBMIT' })}
/>
);
}
12 changes: 7 additions & 5 deletions packages/ui/src/experimental/mosaic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
* The profile's parts are exposed two ways:
*
* - As flat top-level exports (`OrganizationProfileGeneralPanel`,
* `OrganizationProfileLeaveSection`, `OrganizationProfileDeleteSection`). Each is its
* own named export, so React treats it as an individual client reference and a React
* Server Component can render it directly — no `'use client'` needed in the consumer.
* - As a compound namespace (`OrganizationProfile.GeneralPanel`, `.LeaveSection`,
* `.DeleteSection`), which is more ergonomic but only works inside a client component:
* `OrganizationProfileProfileSection`, `OrganizationProfileLeaveSection`,
* `OrganizationProfileDeleteSection`). Each is its own named export, so React treats it
* as an individual client reference and a React Server Component can render it directly —
* no `'use client'` needed in the consumer.
* - As a compound namespace (`OrganizationProfile.GeneralPanel`, `.ProfileSection`,
* `.LeaveSection`, `.DeleteSection`), which is more ergonomic but only works inside a client component:
* property access on a client reference is not possible across the RSC boundary, so
* `OrganizationProfile.GeneralPanel` reads as `undefined` in a server component. Server
* components must use the flat exports.
Expand All @@ -37,4 +38,5 @@ export { OrganizationProfile } from '../mosaic/organization/organization-profile
export { OrganizationProfileGeneralPanel } from '../mosaic/organization/organization-profile-general-panel';
export { OrganizationProfileDeleteSection } from '../mosaic/organization/organization-profile-delete-section';
export { OrganizationProfileLeaveSection } from '../mosaic/organization/organization-profile-leave-section';
export { OrganizationProfileProfileSection } from '../mosaic/organization/organization-profile-profile-section';
export type { MosaicAppearance } from '../mosaic/appearance';
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from 'vitest';

import { createActor } from '../../machine/createActor';
import type { OrganizationProfileProfileSectionDetailsContext } from '../organization-profile-profile-section-details.machine';
import { organizationProfileProfileSectionDetailsMachine } from '../organization-profile-profile-section-details.machine';

const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0));

function start(context: Partial<OrganizationProfileProfileSectionDetailsContext> = {}) {
const updateOrganization = vi.fn(() => Promise.resolve());
const actor = createActor(organizationProfileProfileSectionDetailsMachine, {
context: {
committedName: 'Acme Inc',
committedSlug: 'acme',
slugEnabled: true,
updateOrganization,
...context,
},
});
actor.start();
actor.send({ type: 'OPEN' });
return { actor, updateOrganization };
}

describe('organizationProfileProfileSectionDetailsMachine', () => {
it('cannot submit while the draft still matches the committed values', () => {
const { actor } = start();

expect(actor.can({ type: 'SUBMIT' })).toBe(false);
});

it('cannot submit when the effective name is empty', () => {
const { actor } = start();

actor.send({ type: 'TYPE_NAME', value: ' ' });

expect(actor.can({ type: 'SUBMIT' })).toBe(false);
});

it('can submit once the name diverges from the committed value', () => {
const { actor } = start();

actor.send({ type: 'TYPE_NAME', value: 'New Name' });

expect(actor.can({ type: 'SUBMIT' })).toBe(true);
});

it('saves the effective name and slug, then clears the drafts', async () => {
const { actor, updateOrganization } = start();

actor.send({ type: 'TYPE_NAME', value: 'New Name' });
actor.send({ type: 'SUBMIT' });

expect(actor.getSnapshot().value).toBe('saving');
expect(updateOrganization).toHaveBeenCalledWith({ name: 'New Name', slug: 'acme' });

await tick();

expect(actor.getSnapshot().value).toBe('closed');
expect(actor.getSnapshot().context.draftName).toBeNull();
expect(actor.getSnapshot().context.draftSlug).toBeNull();
});

it('omits the slug when the slug field is disabled', async () => {
const { actor, updateOrganization } = start({ slugEnabled: false });

actor.send({ type: 'TYPE_NAME', value: 'New Name' });
actor.send({ type: 'SUBMIT' });

await tick();

expect(updateOrganization).toHaveBeenCalledWith({ name: 'New Name' });
});

it('returns to editing with an error when the update rejects', async () => {
const updateOrganization = vi.fn(() => Promise.reject(new Error('nope')));
const actor = createActor(organizationProfileProfileSectionDetailsMachine, {
context: { committedName: 'Acme Inc', committedSlug: 'acme', slugEnabled: true, updateOrganization },
});
actor.start();
actor.send({ type: 'OPEN' });

actor.send({ type: 'TYPE_NAME', value: 'New Name' });
actor.send({ type: 'SUBMIT' });

await tick();

expect(actor.getSnapshot().value).toBe('editing');
expect(actor.getSnapshot().context.error).toBe('nope');
});

it('discards drafts and closes on CANCEL', () => {
const { actor } = start();

actor.send({ type: 'TYPE_NAME', value: 'New Name' });
actor.send({ type: 'TYPE_SLUG', value: 'new-slug' });
actor.send({ type: 'CANCEL' });

expect(actor.getSnapshot().value).toBe('closed');
expect(actor.getSnapshot().context.draftName).toBeNull();
expect(actor.getSnapshot().context.draftSlug).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';

import { createActor } from '../../machine/createActor';
import { organizationProfileProfileSectionLogoMachine } from '../organization-profile-profile-section-logo.machine';

const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0));

const file = new File(['logo'], 'logo.png', { type: 'image/png' });

describe('organizationProfileProfileSectionLogoMachine', () => {
it('uploads the selected file, then returns to idle', async () => {
const setLogo = vi.fn(() => Promise.resolve());
const actor = createActor(organizationProfileProfileSectionLogoMachine, { context: { setLogo } });
actor.start();

actor.send({ type: 'UPLOAD', file });

expect(actor.getSnapshot().value).toBe('submitting');
expect(setLogo).toHaveBeenCalledWith(file);

await tick();

expect(actor.getSnapshot().value).toBe('idle');
});

it('removes the logo by submitting a null file', async () => {
const setLogo = vi.fn(() => Promise.resolve());
const actor = createActor(organizationProfileProfileSectionLogoMachine, { context: { setLogo } });
actor.start();

actor.send({ type: 'REMOVE' });

expect(actor.getSnapshot().value).toBe('submitting');
expect(setLogo).toHaveBeenCalledWith(null);

await tick();

expect(actor.getSnapshot().value).toBe('idle');
});

it('returns to idle with an error when the mutation rejects', async () => {
const setLogo = vi.fn(() => Promise.reject(new Error('too big')));
const actor = createActor(organizationProfileProfileSectionLogoMachine, { context: { setLogo } });
actor.start();

actor.send({ type: 'UPLOAD', file });

await tick();

expect(actor.getSnapshot().value).toBe('idle');
expect(actor.getSnapshot().context.error).toBe('too big');
});
});
Loading
Loading