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
2 changes: 2 additions & 0 deletions .changeset/mosaic-machine-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
14 changes: 14 additions & 0 deletions packages/ui/src/mosaic/block/destructive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface DestructiveProps {
resourceName: string;
onDelete: () => void | Promise<void>;
isDeleting: boolean;
error?: string | null;
}

export function Destructive({
Expand All @@ -29,6 +30,7 @@ export function Destructive({
resourceName,
onDelete,
isDeleting,
error,
}: DestructiveProps) {
const [confirmValue, setConfirmValue] = useState('');
const canSubmit = confirmValue === resourceName && !isDeleting;
Expand All @@ -52,6 +54,18 @@ export function Destructive({
<>
<Dialog.Title render={p => <Heading {...p} />}>{title}</Dialog.Title>
<Dialog.Description render={p => <Text {...p} />}>{description}</Dialog.Description>
{error && (
<Box
render={p => <p {...p} />}
sx={t => ({
...t.text('sm'),
color: t.color.destructive,
marginBlockStart: t.spacing(2),
})}
>
{error}
</Box>
)}
<form onSubmit={handleSubmit}>
<Box
render={p => <label {...p} />}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>;

export function createDeleteOrgMachine(destroyOrg: DestroyOrg): StateMachine<DeleteOrgContext, DeleteOrgEvent> {
return createMachine<DeleteOrgContext, DeleteOrgEvent>({
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<DeleteOrgContext, DeleteOrgEvent>((_, 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<DeleteOrgContext, DeleteOrgEvent>(() => ({ confirmValue: '', error: null })),
},
},
},
deleting: {
invoke: {
src: destroyOrg,
onDone: 'deleted',
onError: {
target: 'confirming',
actions: assign<DeleteOrgContext, ErrorInvokeEvent>((_, 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<string>): StateMachine<LoaderContext, LoaderEvent> {
return createMachine<LoaderContext, LoaderEvent>({
initial: 'idle',
context: { data: null, error: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
invoke: {
src: fetcher,
onDone: {
target: 'success',
actions: assign<LoaderContext, DoneInvokeEvent<string>>((_, event) => ({ data: event.output })),
},
onError: {
target: 'failure',
actions: assign<LoaderContext, ErrorInvokeEvent>((_, event) => ({ error: String(event.error) })),
},
},
},
success: { type: 'final' },
failure: { on: { FETCH: 'loading' } },
},
});
}
45 changes: 45 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization-machine.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
error: string | null;
}

export type DeleteOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' };

export const deleteOrgMachine = createMachine<DeleteOrgContext, DeleteOrgEvent>({
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<DeleteOrgContext, DeleteOrgEvent>(() => ({ error: null })),
},
},
},
deleting: {
invoke: {
src: ctx => ctx.destroyFn(),
onDone: 'deleted',
onError: {
target: 'confirming',
actions: assign<DeleteOrgContext, ErrorInvokeEvent>((_, event) => ({
error: String(event.error),
})),
},
},
},
deleted: { type: 'final' },
},
});
31 changes: 13 additions & 18 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line number Diff line number Diff line change
@@ -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 <SectionSkeleton />;
}
if (!isLoaded || !organization) return <SectionSkeleton />;
return <DeleteOrganizationReady organization={organization} />;
}

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 (
<Box
Expand Down Expand Up @@ -75,14 +69,15 @@ export function DeleteOrganization() {
Delete organization
</Button>
)}
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}
/>
</Box>
</Box>
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/mosaic/sections/leave-organization-machine.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
error: string | null;
}

export type LeaveOrgEvent = { type: 'OPEN' } | { type: 'CONFIRM' } | { type: 'CANCEL' };

export const leaveOrgMachine = createMachine<LeaveOrgContext, LeaveOrgEvent>({
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<LeaveOrgContext, LeaveOrgEvent>(() => ({ error: null })),
},
},
},
leaving: {
invoke: {
src: ctx => ctx.leaveFn(),
onDone: 'left',
onError: {
target: 'confirming',
actions: assign<LeaveOrgContext, ErrorInvokeEvent>((_, event) => ({
error: String(event.error),
})),
},
},
},
left: { type: 'final' },
},
});
Loading
Loading