From 1656137dd2ba43ff5bef7bc2f1f40a3f6ba68aa9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 22 Jun 2026 14:32:30 -0400 Subject: [PATCH 1/2] feat(ui): migrate delete/leave-org sections to state machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the useState(open) + useState(isDeleting) pairs in DeleteOrganization and LeaveOrganization with machine definitions. Each section gets a co-located *-machine.ts that declares its three states (idle → confirming → deleting/leaving) and the four events that drive them (OPEN, CANCEL, CONFIRM, done/error). The .tsx files shrink to a single useMachine call; impossible state combinations (open while not confirming, deleting without confirming) become unrepresentable. --- .changeset/mosaic-machine-sections.md | 2 + .../__tests__/delete-organization-machine.ts | 111 +++++++++++++ .../sections/delete-organization-machine.ts | 45 ++++++ .../mosaic/sections/delete-organization.tsx | 31 ++-- .../sections/leave-organization-machine.ts | 44 ++++++ .../mosaic/sections/leave-organization.tsx | 148 +++++++++--------- 6 files changed, 291 insertions(+), 90 deletions(-) create mode 100644 .changeset/mosaic-machine-sections.md create mode 100644 packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts create mode 100644 packages/ui/src/mosaic/sections/delete-organization-machine.ts create mode 100644 packages/ui/src/mosaic/sections/leave-organization-machine.ts diff --git a/.changeset/mosaic-machine-sections.md b/.changeset/mosaic-machine-sections.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-sections.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts b/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts new file mode 100644 index 00000000000..39d5a1fb32c --- /dev/null +++ b/packages/ui/src/mosaic/machine/__tests__/delete-organization-machine.ts @@ -0,0 +1,111 @@ +import { assign } from '../assign'; +import { createMachine } from '../createMachine'; +import type { DoneInvokeEvent, ErrorInvokeEvent, StateMachine } from '../types'; + +/** + * Shared fixture: the delete-organization flow expressed as an explicit machine. + * + * Today this logic is smeared across four `useState` flags in + * `sections/delete-organization.tsx` + `block/destructive.tsx` + * (`open`, `isDeleting`, `confirmValue`, and the derived `canSubmit`). Modelling + * it as a machine — `idle → confirming → deleting → deleted`, guarded on the + * typed name matching, with an error path back to `confirming` — makes every + * state reachable and testable without rendering a single component. + * + * It is intentionally defined once and imported by both the runtime and the + * React tests so the two read against the same, real-world example. + */ + +export interface DeleteOrgContext { + /** The org name the user must type to confirm. */ + name: string; + /** What the user has typed into the confirm field so far. */ + confirmValue: string; + /** Populated when the destroy mutation rejects. */ + error: string | null; +} + +export type DeleteOrgEvent = + | { type: 'OPEN' } + | { type: 'TYPE'; value: string } + | { type: 'CONFIRM' } + | { type: 'CANCEL' }; + +/** The async work the `deleting` state invokes. */ +export type DestroyOrg = (context: DeleteOrgContext) => Promise; + +export function createDeleteOrgMachine(destroyOrg: DestroyOrg): StateMachine { + return createMachine({ + id: 'deleteOrg', + initial: 'idle', + context: { name: 'Acme Inc', confirmValue: '', error: null }, + states: { + idle: { + on: { OPEN: 'confirming' }, + }, + confirming: { + on: { + // Internal transition: runs an action, stays in `confirming`. + TYPE: { + actions: assign((_, event) => + event.type === 'TYPE' ? { confirmValue: event.value } : {}, + ), + }, + // Guarded: only proceeds once the typed name matches. + CONFIRM: { target: 'deleting', guard: context => context.confirmValue === context.name }, + CANCEL: { + target: 'idle', + actions: assign(() => ({ confirmValue: '', error: null })), + }, + }, + }, + deleting: { + invoke: { + src: destroyOrg, + onDone: 'deleted', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ error: String(event.error) })), + }, + }, + }, + deleted: { type: 'final' }, + }, + }); +} + +/** + * A tiny loader flow used to demonstrate `invoke` landing its resolved output in + * context. Parameterised by the fetcher so tests can resolve, reject, or hold it. + */ +export interface LoaderContext { + data: string | null; + error: string | null; +} + +export type LoaderEvent = { type: 'FETCH' }; + +export function createLoaderMachine(fetcher: () => Promise): StateMachine { + return createMachine({ + initial: 'idle', + context: { data: null, error: null }, + states: { + idle: { on: { FETCH: 'loading' } }, + loading: { + invoke: { + src: fetcher, + onDone: { + target: 'success', + actions: assign>((_, event) => ({ data: event.output })), + }, + onError: { + target: 'failure', + actions: assign((_, event) => ({ error: String(event.error) })), + }, + }, + }, + success: { type: 'final' }, + failure: { on: { FETCH: 'loading' } }, + }, + }); +} diff --git a/packages/ui/src/mosaic/sections/delete-organization-machine.ts b/packages/ui/src/mosaic/sections/delete-organization-machine.ts new file mode 100644 index 00000000000..889962453b2 --- /dev/null +++ b/packages/ui/src/mosaic/sections/delete-organization-machine.ts @@ -0,0 +1,45 @@ +import { assign } from '../machine/assign'; +import { createMachine } from '../machine/createMachine'; +import type { ErrorInvokeEvent } from '../machine/types'; + +export interface DeleteOrgContext { + destroyFn: () => Promise; + error: string | null; +} + +export type DeleteOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; + +export const deleteOrgMachine = createMachine({ + id: 'deleteOrg', + initial: 'idle', + context: { destroyFn: async () => {}, error: null }, + states: { + idle: { on: { OPEN: 'confirming' } }, + confirming: { + on: { + // The name-match guard lives in Destructive (canSubmit = confirmValue === resourceName). + // The machine receives CONFIRM only after the UI has already enforced the constraint, + // so no machine-level guard is needed here. The test fixture (delete-organization-machine.ts + // in __tests__) models the guard explicitly for unit-testing purposes. + CONFIRM: 'deleting', + CANCEL: { + target: 'idle', + actions: assign(() => ({ error: null })), + }, + }, + }, + deleting: { + invoke: { + src: ctx => ctx.destroyFn(), + onDone: 'deleted', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ + error: String(event.error), + })), + }, + }, + }, + deleted: { type: 'final' }, + }, +}); diff --git a/packages/ui/src/mosaic/sections/delete-organization.tsx b/packages/ui/src/mosaic/sections/delete-organization.tsx index d17ce81346f..f4106159065 100644 --- a/packages/ui/src/mosaic/sections/delete-organization.tsx +++ b/packages/ui/src/mosaic/sections/delete-organization.tsx @@ -1,26 +1,20 @@ -import { useState } from 'react'; - import { Box } from '../components/box'; import { Button } from '../components/button'; import { SectionSkeleton } from '../components/section-skeleton'; import { Destructive } from '../block/destructive'; import { useOrganization } from '../mock/use-organization'; +import { useMachine } from '../machine/useMachine'; +import type { MockOrganization } from '../mock/use-organization'; +import { deleteOrgMachine } from './delete-organization-machine'; export function DeleteOrganization() { const { isLoaded, organization } = useOrganization(); - const [open, setOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - if (!isLoaded || !organization) { - return ; - } + if (!isLoaded || !organization) return ; + return ; +} - const handleDelete = async () => { - setIsDeleting(true); - await organization.destroy(); - setIsDeleting(false); - setOpen(false); - }; +function DeleteOrganizationReady({ organization }: { organization: MockOrganization }) { + const [snapshot, send] = useMachine(deleteOrgMachine, { context: { destroyFn: () => organization.destroy() } }); return ( )} - open={open} - onOpenChange={setOpen} + open={snapshot.value === 'confirming' || snapshot.value === 'deleting'} + onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })} title='Delete organization' description='Are you sure you want to delete this organization?' resourceName={organization.name} primaryActionLabel='Delete organization' - onDelete={handleDelete} - isDeleting={isDeleting} + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'deleting'} + error={snapshot.context.error} /> diff --git a/packages/ui/src/mosaic/sections/leave-organization-machine.ts b/packages/ui/src/mosaic/sections/leave-organization-machine.ts new file mode 100644 index 00000000000..6a0e0bb0494 --- /dev/null +++ b/packages/ui/src/mosaic/sections/leave-organization-machine.ts @@ -0,0 +1,44 @@ +import { assign } from '../machine/assign'; +import { createMachine } from '../machine/createMachine'; +import type { ErrorInvokeEvent } from '../machine/types'; + +export interface LeaveOrgContext { + leaveFn: () => Promise; + error: string | null; +} + +export type LeaveOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' }; + +export const leaveOrgMachine = createMachine({ + id: 'leaveOrg', + initial: 'idle', + context: { leaveFn: async () => {}, error: null }, + states: { + idle: { on: { OPEN: 'confirming' } }, + confirming: { + on: { + // The name-match guard lives in Destructive (canSubmit = confirmValue === resourceName). + // The machine receives CONFIRM only after the UI has already enforced the constraint, + // so no machine-level guard is needed here. + CONFIRM: 'leaving', + CANCEL: { + target: 'idle', + actions: assign(() => ({ error: null })), + }, + }, + }, + leaving: { + invoke: { + src: ctx => ctx.leaveFn(), + onDone: 'left', + onError: { + target: 'confirming', + actions: assign((_, event) => ({ + error: String(event.error), + })), + }, + }, + }, + left: { type: 'final' }, + }, +}); diff --git a/packages/ui/src/mosaic/sections/leave-organization.tsx b/packages/ui/src/mosaic/sections/leave-organization.tsx index d78e0d6175e..041c80c9afa 100644 --- a/packages/ui/src/mosaic/sections/leave-organization.tsx +++ b/packages/ui/src/mosaic/sections/leave-organization.tsx @@ -1,92 +1,96 @@ -import { useState } from 'react'; - import { Box } from '../components/box'; import { Button } from '../components/button'; import { SectionSkeleton } from '../components/section-skeleton'; import { Destructive } from '../block/destructive'; import { useOrganization } from '../mock/use-organization'; +import { useMachine } from '../machine/useMachine'; +import type { MockMembership, MockOrganization } from '../mock/use-organization'; +import { leaveOrgMachine } from './leave-organization-machine'; export function LeaveOrganization() { const { isLoaded, organization, membership } = useOrganization(); - const [open, setOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - if (!isLoaded || !organization || !membership) { - return ; - } + if (!isLoaded || !organization || !membership) return ; + return ( + + ); +} - const handleLeave = async () => { - setIsDeleting(true); - await membership.destroy(); - setIsDeleting(false); - setOpen(false); - }; +function LeaveOrganizationReady({ + organization, + membership, +}: { + organization: MockOrganization; + membership: MockMembership; +}) { + const [snapshot, send] = useMachine(leaveOrgMachine, { context: { leaveFn: () => membership.destroy() } }); return ( - <> + ({ + width: '100%', + containerType: 'inline-size', + })} + > ({ - width: '100%', - containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, })} > - ({ - 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, - })} - > - Leave organization - -

} - sx={t => ({ - ...t.text('sm'), - textWrap: 'balance', - marginBlockStart: t.spacing(1), - color: t.color.mutedForeground, - })} - > - You will be removed from the organization and need to be invited back - + +

} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.semibold, + })} + > + Leave organization + +

} + sx={t => ({ + ...t.text('sm'), + textWrap: 'balance', + marginBlockStart: t.spacing(1), + color: t.color.mutedForeground, + })} + > + You will be removed from the organization and need to be invited back - ( - - )} - open={open} - onOpenChange={setOpen} - title='Leave organization' - description='Are you sure you want to leave this organization? You will lose access to this organization and its applications.' - primaryActionLabel='Leave organization' - resourceName={organization.name} - onDelete={handleLeave} - isDeleting={isDeleting} - /> + ( + + )} + open={snapshot.value === 'confirming' || snapshot.value === 'leaving'} + onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })} + title='Leave organization' + description='Are you sure you want to leave this organization? You will lose access to this organization and its applications.' + resourceName={organization.name} + primaryActionLabel='Leave organization' + onDelete={() => send({ type: 'CONFIRM' })} + isDeleting={snapshot.value === 'leaving'} + error={snapshot.context.error} + /> - + ); } From 531fbbe3f6e760d564f53316bdaaba9e18a2ceb2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 22 Jun 2026 14:40:56 -0400 Subject: [PATCH 2/2] feat(ui): add error prop to Destructive block component Adds optional error?: string | null to DestructiveProps so section machines can surface async failure messages inside the confirm dialog. --- packages/ui/src/mosaic/block/destructive.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/ui/src/mosaic/block/destructive.tsx b/packages/ui/src/mosaic/block/destructive.tsx index 0c74d35946e..289c9137213 100644 --- a/packages/ui/src/mosaic/block/destructive.tsx +++ b/packages/ui/src/mosaic/block/destructive.tsx @@ -17,6 +17,7 @@ interface DestructiveProps { resourceName: string; onDelete: () => void | Promise; isDeleting: boolean; + error?: string | null; } export function Destructive({ @@ -29,6 +30,7 @@ export function Destructive({ resourceName, onDelete, isDeleting, + error, }: DestructiveProps) { const [confirmValue, setConfirmValue] = useState(''); const canSubmit = confirmValue === resourceName && !isDeleting; @@ -52,6 +54,18 @@ export function Destructive({ <> }>{title} }>{description} + {error && ( +

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