From 1339805ae837d7cb30d3a172d9f6655b790860d2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 1 Jul 2026 16:15:59 -0400 Subject: [PATCH 1/2] feat(ui): add OrganizationProfileProfileSection --- .../swingset/src/components/DocsViewer.tsx | 3 + packages/swingset/src/lib/registry.ts | 9 + .../organization-profile-general-panel.mdx | 5 + ...nization-profile-general-panel.stories.tsx | 2 + .../organization-profile-profile-section.mdx | 15 ++ ...zation-profile-profile-section.stories.tsx | 31 ++++ ...le-profile-section-details.machine.test.ts | 103 +++++++++++ ...ofile-profile-section-logo.machine.test.ts | 53 ++++++ ...rofile-profile-section.controller.test.tsx | 145 +++++++++++++++ ...rganization-profile-general-panel-view.tsx | 25 ++- .../organization-profile-general-panel.tsx | 2 + ...profile-profile-section-details.machine.ts | 103 +++++++++++ ...on-profile-profile-section-logo.machine.ts | 50 +++++ ...ion-profile-profile-section.controller.tsx | 88 +++++++++ .../organization-profile-profile-section.tsx | 18 ++ ...anization-profile-profile-section.view.tsx | 171 ++++++++++++++++++ 16 files changed, 815 insertions(+), 8 deletions(-) create mode 100644 packages/swingset/src/stories/organization-profile-profile-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-profile-section.stories.tsx create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-details.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-logo.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section.controller.test.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-profile-section-details.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-profile-section-logo.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-profile-section.controller.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-profile-section.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index ddf1d779db0..212e112791d 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -13,6 +13,9 @@ const docModules: Record> = { 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')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0c97e0d7ec8..124619aaf48 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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'; @@ -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, @@ -124,6 +132,7 @@ export const registry: StoryModule[] = [ // Organization organizationProfileModule, organizationProfileGeneralPanelModule, + organizationProfileProfileSectionModule, organizationProfileLeaveSectionModule, organizationProfileDeleteSectionModule, // Blocks diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx index 6cb03d83629..a5f5e040aa8 100644 --- a/packages/swingset/src/stories/organization-profile-general-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -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', diff --git a/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx index 0d592a68f75..8d613a6fe38 100644 --- a/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx +++ b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx @@ -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', @@ -15,6 +16,7 @@ export const meta: StoryMeta = { export function Default() { return ( } leaveOrganization={} deleteOrganization={} /> diff --git a/packages/swingset/src/stories/organization-profile-profile-section.mdx b/packages/swingset/src/stories/organization-profile-profile-section.mdx new file mode 100644 index 00000000000..5bba1e87ce1 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-profile-section.mdx @@ -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. + + diff --git a/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx b/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx new file mode 100644 index 00000000000..5ea047435f3 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-profile-section.stories.tsx @@ -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(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); +} diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-details.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-details.machine.test.ts new file mode 100644 index 00000000000..53940b2b544 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-details.machine.test.ts @@ -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(resolve => setTimeout(resolve, 0)); + +function start(context: Partial = {}) { + 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(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-logo.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-logo.machine.test.ts new file mode 100644 index 00000000000..df566a036f1 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section-logo.machine.test.ts @@ -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(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'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section.controller.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section.controller.test.tsx new file mode 100644 index 00000000000..f4426a9ace3 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-profile-section.controller.test.tsx @@ -0,0 +1,145 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOrganizationProfileProfileSectionController } from '../organization-profile-profile-section.controller'; + +const ORG_NAME = 'Acme Inc'; +const CUSTOM_LOGO = 'https://img.clerk.com/logo.png'; +const DEFAULT_LOGO = 'https://example.com/avatar_placeholder.png'; + +let update: ReturnType; +let setLogo: ReturnType; +let checkAuthorization: ReturnType; +let isLoaded: boolean; +let isSessionLoaded: boolean; +let slugDisabled: boolean; +let organization: { + id: string; + name: string; + slug: string; + imageUrl: string; + update: (params: { name: string; slug?: string }) => Promise; + setLogo: (params: { file: File | null }) => Promise; +} | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded, organization }), + useSession: () => ({ + isLoaded: isSessionLoaded, + session: isSessionLoaded ? { id: 'sess_1', checkAuthorization } : undefined, + }), + useClerk: () => ({ + __internal_environment: { organizationSettings: { slug: { disabled: slugDisabled } } }, + }), + }; +}); + +beforeEach(() => { + update = vi.fn().mockResolvedValue(undefined); + setLogo = vi.fn().mockResolvedValue(undefined); + checkAuthorization = vi.fn().mockReturnValue(true); + isLoaded = true; + isSessionLoaded = true; + slugDisabled = false; + organization = { id: 'org_1', name: ORG_NAME, slug: 'acme', imageUrl: CUSTOM_LOGO, update, setLogo }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useOrganizationProfileProfileSectionController(); + if (controller.status !== 'ready') { + return {controller.status}; + } + return ( +
+ {controller.details.snapshot.value} + {String(controller.details.canSubmit)} + {String(controller.logo.canRemove)} + + + +
+ ); +} + +describe('useOrganizationProfileProfileSectionController', () => { + it('is loading until the organization is loaded', () => { + isLoaded = false; + + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is loading until the session is loaded', () => { + isSessionLoaded = false; + + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + expect(checkAuthorization).not.toHaveBeenCalled(); + }); + + it('is hidden when the user lacks the manage permission', () => { + checkAuthorization.mockReturnValue(false); + + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_profile:manage' }); + }); + + it('is hidden when there is no active organization', () => { + organization = null; + + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is ready and enables submit once the name diverges from the organization', () => { + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('closed'); + + fireEvent.click(screen.getByText('Open')); + + expect(screen.getByTestId('state')).toHaveTextContent('editing'); + expect(screen.getByTestId('canSubmit')).toHaveTextContent('false'); + + fireEvent.click(screen.getByText('Type')); + + expect(screen.getByTestId('canSubmit')).toHaveTextContent('true'); + }); + + it('omits the slug from the update when slugs are disabled', async () => { + slugDisabled = true; + + render(); + fireEvent.click(screen.getByText('Open')); + fireEvent.click(screen.getByText('Type')); + + await act(async () => { + fireEvent.click(screen.getByText('Submit')); + }); + + expect(update).toHaveBeenCalledWith({ name: 'New Name' }); + }); + + it('allows removing a custom logo but not a default one', () => { + const { unmount } = render(); + expect(screen.getByTestId('canRemove')).toHaveTextContent('true'); + unmount(); + + organization = { id: 'org_1', name: ORG_NAME, slug: 'acme', imageUrl: DEFAULT_LOGO, update, setLogo }; + + render(); + expect(screen.getByTestId('canRemove')).toHaveTextContent('false'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-general-panel-view.tsx b/packages/ui/src/mosaic/organization/organization-profile-general-panel-view.tsx index 28ddd2a6470..401a794e63e 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-general-panel-view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-general-panel-view.tsx @@ -4,16 +4,29 @@ import { Box } from '../components/box'; import { alpha } from '../utils'; interface OrganizationProfileGeneralPanelViewProps { - /** The organization-profile-leave-section section, rendered above the divider. */ + /** The organization-profile-profile-section (name/slug), rendered at the top. */ + profile: ReactNode; + /** The organization-profile-leave-section section. */ leaveOrganization: ReactNode; /** The organization-profile-delete-section section, rendered below the divider. */ deleteOrganization: ReactNode; } export function OrganizationProfileGeneralPanelView({ + profile, leaveOrganization, deleteOrganization, }: OrganizationProfileGeneralPanelViewProps) { + const divider = ( + ({ + height: '1px', + background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, + marginBlock: t.spacing(4), + })} + /> + ); + return ( + {profile} + {divider} {leaveOrganization} - ({ - height: '1px', - background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, - marginBlock: t.spacing(4), - })} - /> + {divider} {deleteOrganization} ); diff --git a/packages/ui/src/mosaic/organization/organization-profile-general-panel.tsx b/packages/ui/src/mosaic/organization/organization-profile-general-panel.tsx index ddae2c3672f..ebecec00c29 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-general-panel.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-general-panel.tsx @@ -3,10 +3,12 @@ import type { ReactElement } from 'react'; import { OrganizationProfileDeleteSection } from './organization-profile-delete-section'; import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel-view'; import { OrganizationProfileLeaveSection } from './organization-profile-leave-section'; +import { OrganizationProfileProfileSection } from './organization-profile-profile-section'; export function OrganizationProfileGeneralPanel(): ReactElement { return ( } leaveOrganization={} deleteOrganization={} /> diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section-details.machine.ts b/packages/ui/src/mosaic/organization/organization-profile-profile-section-details.machine.ts new file mode 100644 index 00000000000..7837db23228 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section-details.machine.ts @@ -0,0 +1,103 @@ +import { setup } from '../machine/setup'; + +export interface OrganizationProfileProfileSectionDetailsContext { + /** The organization's current name, injected fresh each render. */ + committedName: string; + /** The organization's current slug, injected fresh each render. */ + committedSlug: string; + /** Whether the slug field is editable (mirrors `!organizationSettings.slug.disabled`). */ + slugEnabled: boolean; + /** The user's edited name, or `null` while untouched (falls through to `committedName`). */ + draftName: string | null; + /** The user's edited slug, or `null` while untouched (falls through to `committedSlug`). */ + draftSlug: string | null; + error: string | null; + updateOrganization: (params: { name: string; slug?: string }) => Promise; +} + +export type OrganizationProfileProfileSectionDetailsEvent = + | { type: 'OPEN' } + | { type: 'TYPE_NAME'; value: string } + | { type: 'TYPE_SLUG'; value: string } + | { type: 'SUBMIT' } + | { type: 'CANCEL' }; + +// The draft holds the user's edits; a `null` draft transparently falls through to the committed +// value the controller re-injects each render. This is what lets the form seed itself from a +// late-loading organization without a syncing effect, and stop the committed value from clobbering +// edits once the user has typed. +const effectiveName = (context: OrganizationProfileProfileSectionDetailsContext): string => + context.draftName ?? context.committedName; +const effectiveSlug = (context: OrganizationProfileProfileSectionDetailsContext): string => + context.draftSlug ?? context.committedSlug; + +const dataChanged = (context: OrganizationProfileProfileSectionDetailsContext): boolean => + effectiveName(context) !== context.committedName || + (context.slugEnabled && effectiveSlug(context) !== context.committedSlug); + +const canSave = (context: OrganizationProfileProfileSectionDetailsContext): boolean => + dataChanged(context) && effectiveName(context).trim() !== ''; + +const { createMachine, assign, fromPromise } = setup< + OrganizationProfileProfileSectionDetailsContext, + OrganizationProfileProfileSectionDetailsEvent +>(); + +export const organizationProfileProfileSectionDetailsMachine = createMachine({ + id: 'organizationProfileDetails', + initial: 'closed', + context: { + committedName: '', + committedSlug: '', + slugEnabled: true, + draftName: null, + draftSlug: null, + error: null, + updateOrganization: async () => {}, + }, + states: { + closed: { on: { OPEN: 'editing' } }, + editing: { + on: { + TYPE_NAME: { + actions: assign((_, event) => ({ draftName: event.value, error: null })), + }, + TYPE_SLUG: { + actions: assign((_, event) => ({ draftSlug: event.value, error: null })), + }, + SUBMIT: { + target: 'saving', + guard: canSave, + }, + // Discard the edits and close: the null drafts fall back through to the committed values. + CANCEL: { + target: 'closed', + actions: assign(() => ({ draftName: null, draftSlug: null, error: null })), + }, + }, + }, + saving: { + invoke: fromPromise( + context => + context.updateOrganization({ + name: effectiveName(context), + ...(context.slugEnabled ? { slug: effectiveSlug(context) } : {}), + }), + { + // Close and drop the drafts back to `null` on success: the organization resource is now + // mutated, so the freshly re-injected committed values are the source of truth again. + onDone: { + target: 'closed', + actions: assign(() => ({ draftName: null, draftSlug: null, error: null })), + }, + onError: { + target: 'editing', + actions: assign((_, event) => ({ + error: event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', + })), + }, + }, + ), + }, + }, +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section-logo.machine.ts b/packages/ui/src/mosaic/organization/organization-profile-profile-section-logo.machine.ts new file mode 100644 index 00000000000..09d89234f07 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section-logo.machine.ts @@ -0,0 +1,50 @@ +import { setup } from '../machine/setup'; + +export interface OrganizationProfileProfileSectionLogoContext { + /** The file to upload, or `null` to remove the current logo. */ + file: File | null; + setLogo: (file: File | null) => Promise; + error: string | null; +} + +export type OrganizationProfileProfileSectionLogoEvent = { type: 'UPLOAD'; file: File } | { type: 'REMOVE' }; + +const { createMachine, assign, fromPromise } = setup< + OrganizationProfileProfileSectionLogoContext, + OrganizationProfileProfileSectionLogoEvent +>(); + +export const organizationProfileProfileSectionLogoMachine = createMachine({ + id: 'organizationProfileLogo', + initial: 'idle', + context: { + file: null, + setLogo: async () => {}, + error: null, + }, + states: { + idle: { + on: { + UPLOAD: { + target: 'submitting', + actions: assign((_, event) => ({ file: event.file, error: null })), + }, + REMOVE: { + target: 'submitting', + actions: assign(() => ({ file: null, error: null })), + }, + }, + }, + submitting: { + invoke: fromPromise(context => context.setLogo(context.file), { + onDone: 'idle', + onError: { + target: 'idle', + actions: assign((_, event) => ({ + error: event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', + })), + }, + }), + }, + }, +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section.controller.tsx b/packages/ui/src/mosaic/organization/organization-profile-profile-section.controller.tsx new file mode 100644 index 00000000000..4ba1f377542 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section.controller.tsx @@ -0,0 +1,88 @@ +import { useOrganization, useSession } from '@clerk/shared/react'; + +import { isDefaultImage } from '../../utils/image'; +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import type { Snapshot } from '../machine/types'; +import { useMachine } from '../machine/useMachine'; +import type { + OrganizationProfileProfileSectionDetailsContext, + OrganizationProfileProfileSectionDetailsEvent, +} from './organization-profile-profile-section-details.machine'; +import { organizationProfileProfileSectionDetailsMachine } from './organization-profile-profile-section-details.machine'; +import type { + OrganizationProfileProfileSectionLogoContext, + OrganizationProfileProfileSectionLogoEvent, +} from './organization-profile-profile-section-logo.machine'; +import { organizationProfileProfileSectionLogoMachine } from './organization-profile-profile-section-logo.machine'; + +interface OrganizationProfileDetailsFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileProfileSectionDetailsEvent) => void; + canSubmit: boolean; +} + +interface OrganizationProfileLogoFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileProfileSectionLogoEvent) => void; + canRemove: boolean; +} + +type OrganizationProfileProfileSectionController = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + details: OrganizationProfileDetailsFlow; + logo: OrganizationProfileLogoFlow; + }; + +export function useOrganizationProfileProfileSectionController(): OrganizationProfileProfileSectionController { + const { isLoaded: isOrganizationLoaded, organization } = useOrganization(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const slugEnabled = !(useMosaicEnvironment()?.organizationSettings?.slug?.disabled ?? false); + + const [detailsSnapshot, sendDetails, detailsActor] = useMachine(organizationProfileProfileSectionDetailsMachine, { + context: { + committedName: organization?.name ?? '', + committedSlug: organization?.slug ?? '', + slugEnabled, + updateOrganization: async params => { + await organization?.update(params); + }, + }, + }); + + const [logoSnapshot, sendLogo] = useMachine(organizationProfileProfileSectionLogoMachine, { + context: { + setLogo: async file => { + await organization?.setLogo({ file }); + }, + }, + }); + + // The permission check needs both the organization and the session resolved. Treat either still + // loading as 'loading' so a not-yet-known session is never collapsed into a definitive 'hidden'. + if (!isOrganizationLoaded || !isSessionLoaded) { + return { status: 'loading' }; + } + + const canManage = session?.checkAuthorization({ permission: 'org:sys_profile:manage' }) ?? false; + if (!organization || !canManage) { + return { status: 'hidden' }; + } + + return { + status: 'ready', + details: { + snapshot: detailsSnapshot, + send: sendDetails, + canSubmit: detailsActor.can({ type: 'SUBMIT' }), + }, + logo: { + snapshot: logoSnapshot, + send: sendLogo, + // Mirror legacy: removing is only offered when the org has a non-default logo. + canRemove: !isDefaultImage(organization.imageUrl), + }, + }; +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section.tsx b/packages/ui/src/mosaic/organization/organization-profile-profile-section.tsx new file mode 100644 index 00000000000..944bebcb285 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section.tsx @@ -0,0 +1,18 @@ +import type { ReactElement } from 'react'; + +import { useOrganizationProfileProfileSectionController } from './organization-profile-profile-section.controller'; +import { OrganizationProfileProfileSectionView } from './organization-profile-profile-section.view'; + +export function OrganizationProfileProfileSection(): ReactElement | null { + const controller = useOrganizationProfileProfileSectionController(); + if (controller.status !== 'ready') { + return null; + } + return ( + + ); +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx new file mode 100644 index 00000000000..3609b0f01ba --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx @@ -0,0 +1,171 @@ +import type { FormEvent } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { Dialog } from '../components/dialog'; +import { Heading } from '../components/heading'; +import { Input } from '../components/input'; +import type { Snapshot } from '../machine/types'; +import type { + OrganizationProfileProfileSectionDetailsContext, + OrganizationProfileProfileSectionDetailsEvent, +} from './organization-profile-profile-section-details.machine'; + +interface OrganizationProfileProfileSectionViewProps { + snapshot: Snapshot; + send: (event: OrganizationProfileProfileSectionDetailsEvent) => void; + canSubmit: boolean; +} + +export function OrganizationProfileProfileSectionView({ + snapshot, + send, + canSubmit, +}: OrganizationProfileProfileSectionViewProps) { + const { committedName, committedSlug, draftName, draftSlug, slugEnabled, error } = snapshot.context; + const isSaving = snapshot.value === 'saving'; + const isOpen = snapshot.value !== 'closed'; + + // The draft holds the user's edits; a null draft falls through to the committed value. + const nameValue = draftName ?? committedName; + const slugValue = draftSlug ?? committedSlug; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSubmit) { + send({ type: 'SUBMIT' }); + } + }; + + return ( + + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, + })} + > + +

} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.semibold, + })} + > + {committedName} + + {slugEnabled && committedSlug && ( +

} + sx={t => ({ + ...t.text('sm'), + marginBlockStart: t.spacing(1), + color: t.color.mutedForeground, + })} + > + {committedSlug} + + )} + +

send({ type: open ? 'OPEN' : 'CANCEL' })} + trigger={props => ( + + )} + > + }>Update profile + {error && ( +

} + sx={t => ({ + ...t.text('sm'), + color: t.color.destructive, + })} + > + {error} + + )} +

+ + {slugEnabled && ( + + )} + ({ + marginBlockStart: t.spacing(4), + display: 'flex', + columnGap: t.spacing(2), + })} + > + + + +
+
+
+ + ); +} From 76c57109ac83605d75f41241e86daf0e871f37b9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 2 Jul 2026 10:33:37 -0400 Subject: [PATCH 2/2] export --- packages/ui/src/experimental/mosaic.ts | 12 +++++++----- .../src/mosaic/organization/organization-profile.tsx | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/experimental/mosaic.ts b/packages/ui/src/experimental/mosaic.ts index 6635a8242ba..244271ed06a 100644 --- a/packages/ui/src/experimental/mosaic.ts +++ b/packages/ui/src/experimental/mosaic.ts @@ -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. @@ -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'; diff --git a/packages/ui/src/mosaic/organization/organization-profile.tsx b/packages/ui/src/mosaic/organization/organization-profile.tsx index cf7403395d7..2aa2a7bdcd5 100644 --- a/packages/ui/src/mosaic/organization/organization-profile.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile.tsx @@ -4,6 +4,7 @@ import { useOrganizationProfileController } from './organization-profile.control import { OrganizationProfileDeleteSection } from './organization-profile-delete-section'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; import { OrganizationProfileLeaveSection } from './organization-profile-leave-section'; +import { OrganizationProfileProfileSection } from './organization-profile-profile-section'; import { OrganizationProfileView } from './organization-profile-view'; function OrganizationProfileRoot(): ReactElement | null { @@ -25,6 +26,7 @@ function OrganizationProfileRoot(): ReactElement | null { // them into the exported binding's initializer keeps them attached across that boundary. export const OrganizationProfile = Object.assign(OrganizationProfileRoot, { GeneralPanel: OrganizationProfileGeneralPanel, + ProfileSection: OrganizationProfileProfileSection, LeaveSection: OrganizationProfileLeaveSection, DeleteSection: OrganizationProfileDeleteSection, });