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/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} + + )}

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} + /> - + ); }