Skip to content

Commit 0d4758a

Browse files
committed
Let EMCN modals wait on async confirms and inputs take controlled values
A confirm that returns a promise holds the dialog open as pending, closes on success, and shows the error inline on failure. A synchronous confirm still closes in the same update. Every existing caller only sets state that commutes with closing, so running it before close changes nothing they render.
1 parent b442b78 commit 0d4758a

6 files changed

Lines changed: 96 additions & 14 deletions

File tree

‎apps/sim/platform/design-system/components/field/fields.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ export function FieldFrame({ id, label, optional, error, children }: FieldFrameP
5151
)
5252
}
5353

54-
interface TextFieldProps extends FieldProps {
54+
interface TextValueProps {
55+
value?: string
56+
onChange?: (value: string) => void
57+
}
58+
59+
interface TextFieldProps extends FieldProps, TextValueProps {
5560
required?: boolean
5661
pattern?: string
5762
placeholder?: string
@@ -64,6 +69,8 @@ export function TextField({
6469
disabled,
6570
name,
6671
defaultValue,
72+
value,
73+
onChange,
6774
placeholder,
6875
required,
6976
pattern,
@@ -78,6 +85,8 @@ export function TextField({
7885
label={label}
7986
name={name}
8087
defaultValue={defaultValue}
88+
value={value}
89+
onChange={onChange}
8190
placeholder={placeholder}
8291
disabled={disabled}
8392
invalid={Boolean(error)}
@@ -87,7 +96,7 @@ export function TextField({
8796
)
8897
}
8998

90-
interface TextAreaFieldProps extends FieldProps {
99+
interface TextAreaFieldProps extends FieldProps, TextValueProps {
91100
placeholder?: string
92101
}
93102

@@ -98,6 +107,8 @@ export function TextAreaField({
98107
disabled,
99108
name,
100109
defaultValue,
110+
value,
111+
onChange,
101112
placeholder,
102113
}: TextAreaFieldProps) {
103114
const id = useId()
@@ -108,6 +119,8 @@ export function TextAreaField({
108119
label={label}
109120
name={name}
110121
defaultValue={defaultValue}
122+
value={value}
123+
onChange={onChange}
111124
placeholder={placeholder}
112125
disabled={disabled}
113126
invalid={Boolean(error)}

‎apps/sim/platform/design-system/components/field/inputs.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ export interface ControlProps {
1313
describedBy?: string
1414
}
1515

16-
interface InputProps extends ControlProps {
16+
interface TextControlProps extends ControlProps {
17+
/** Controlled text. Leave it out and `defaultValue` keeps the draft in the DOM. */
18+
value?: string
19+
onChange?: (value: string) => void
20+
}
21+
22+
interface InputProps extends TextControlProps {
1723
required?: boolean
1824
pattern?: string
1925
placeholder?: string
@@ -24,6 +30,8 @@ export function Input({
2430
label,
2531
name,
2632
defaultValue,
33+
value,
34+
onChange,
2735
disabled,
2836
invalid,
2937
describedBy,
@@ -44,6 +52,8 @@ export function Input({
4452
data-type='T043'
4553
name={name}
4654
defaultValue={defaultValue}
55+
value={value}
56+
onChange={onChange && ((event) => onChange(event.target.value))}
4757
placeholder={placeholder}
4858
disabled={disabled}
4959
aria-invalid={invalid || undefined}
@@ -62,6 +72,8 @@ export function TextArea({
6272
label,
6373
name,
6474
defaultValue,
75+
value,
76+
onChange,
6577
disabled,
6678
invalid,
6779
describedBy,
@@ -82,6 +94,8 @@ export function TextArea({
8294
name={name}
8395
data-density={density}
8496
defaultValue={defaultValue}
97+
value={value}
98+
onChange={onChange && ((event) => onChange(event.target.value))}
8599
placeholder={placeholder}
86100
disabled={disabled}
87101
aria-invalid={invalid || undefined}

‎apps/sim/platform/design-system/components/modal/modal-parts.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface ModalFooterProps {
5454
previousStep?: boolean
5555
destructive?: boolean
5656
disabled?: boolean
57+
cancelDisabled?: boolean
5758
progress?: string
5859
}
5960

@@ -63,6 +64,7 @@ export function ModalFooter({
6364
previousStep,
6465
destructive,
6566
disabled,
67+
cancelDisabled,
6668
progress,
6769
}: ModalFooterProps) {
6870
return (
@@ -72,7 +74,7 @@ export function ModalFooter({
7274
{progress}
7375
</span>
7476
)}
75-
<Button variant='quiet' onClick={onPrevious}>
77+
<Button variant='quiet' disabled={cancelDisabled} onClick={onPrevious}>
7678
{previousStep ? 'Back' : 'Cancel'}
7779
</Button>
7880
<Button variant={destructive ? 'destructive' : 'primary'} submit disabled={disabled}>

‎apps/sim/platform/design-system/components/modal/modal.module.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,7 @@
111111
padding: 0;
112112
gap: 0;
113113
}
114+
115+
.error {
116+
color: var(--control-danger);
117+
}

‎apps/sim/platform/design-system/components/modal/modal.tsx‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use client'
22

33
import { type ReactNode, useState } from 'react'
4+
import { getErrorMessage } from '@sim/utils/errors'
45
import type { IconName } from '@/platform/design-system/catalog/icon-geometry'
56
import { Button } from '@/platform/design-system/components/button/button'
7+
import styles from '@/platform/design-system/components/modal/modal.module.css'
68
import { ModalBody, ModalFooter } from '@/platform/design-system/components/modal/modal-parts'
79
import { OverlayDialog } from '@/platform/design-system/components/modal/overlay-dialog'
810

@@ -11,11 +13,19 @@ interface ModalStep {
1113
content: ReactNode
1214
}
1315

16+
/**
17+
* A synchronous confirm closes the dialog in the same update. A returned promise holds it open as
18+
* pending, closes it on success, and on rejection keeps the draft and shows the error's message.
19+
*/
20+
type ConfirmHandler<Args extends unknown[]> =
21+
| ((...args: Args) => void)
22+
| ((...args: Args) => Promise<void>)
23+
1424
interface ModalActions {
1525
title: string
1626
confirmLabel: string
1727
confirmDisabled?: boolean
18-
onConfirm?: (values: FormData) => void
28+
onConfirm?: ConfirmHandler<[values: FormData]>
1929
}
2030
type ModalTrigger =
2131
| { triggerLabel: string; triggerIcon?: IconName; open?: never; onOpenChange?: never }
@@ -27,7 +37,7 @@ type ModalTrigger =
2737
}
2838

2939
interface MultiStepModalProps extends Omit<ModalActions, 'onConfirm'> {
30-
onConfirm?: () => void
40+
onConfirm?: ConfirmHandler<[]>
3141
triggerLabel: string
3242
steps: readonly [ModalStep, ...ModalStep[]]
3343
}
@@ -57,15 +67,42 @@ function ModalSurface({
5767
}: ModalSurfaceProps) {
5868
const [internal, setInternal] = useState(false)
5969
const [index, setIndex] = useState(0)
70+
const [pending, setPending] = useState(false)
71+
const [error, setError] = useState<string | null>(null)
6072
const open = controlled ?? internal
6173
const step = steps[index]
6274
const last = index === steps.length - 1
6375

76+
if (!open && error) setError(null)
77+
6478
function close() {
6579
setIndex(0)
6680
if (onOpenChange) onOpenChange(false)
6781
else setInternal(false)
6882
}
83+
84+
async function confirm(values: FormData) {
85+
setError(null)
86+
try {
87+
const result = onConfirm?.(values)
88+
if (!(result instanceof Promise)) {
89+
close()
90+
return
91+
}
92+
setPending(true)
93+
await result
94+
close()
95+
} catch (failure) {
96+
setError(getErrorMessage(failure, 'Something went wrong. Please try again.'))
97+
} finally {
98+
setPending(false)
99+
}
100+
}
101+
102+
/** The work is already underway and its outcome belongs here, so nothing dismisses it mid-flight. */
103+
function dismiss() {
104+
if (!pending) close()
105+
}
69106
return (
70107
<>
71108
{triggerLabel && (
@@ -79,29 +116,34 @@ function ModalSurface({
79116
description={step.description}
80117
step={index}
81118
presentation={presentation}
82-
onClose={close}
119+
busy={pending}
120+
onClose={dismiss}
83121
onSubmit={(event) => {
84122
event.preventDefault()
85-
if (last && confirmDisabled) return
123+
if (pending || (last && confirmDisabled)) return
86124
if (!last) {
87125
setIndex(index + 1)
88126
return
89127
}
90-
const values = new FormData(event.currentTarget)
91-
close()
92-
onConfirm?.(values)
128+
void confirm(new FormData(event.currentTarget))
93129
}}
94130
>
95131
{steps.map((entry, i) => (
96132
<ModalBody key={i} inactive={i !== index} label={entry.description || title}>
97133
{entry.content}
98134
</ModalBody>
99135
))}
136+
{error && (
137+
<p className={styles.error} data-type='T052' role='alert'>
138+
{error}
139+
</p>
140+
)}
100141
<ModalFooter
101142
confirmLabel={last ? confirmLabel : 'Continue'}
102-
onPrevious={() => (index === 0 ? close() : setIndex(index - 1))}
143+
onPrevious={() => (index === 0 ? dismiss() : setIndex(index - 1))}
103144
previousStep={index > 0}
104-
disabled={last && confirmDisabled}
145+
disabled={pending || (last && confirmDisabled)}
146+
cancelDisabled={pending}
105147
destructive={destructive}
106148
progress={steps.length > 1 ? `Step ${index + 1} of ${steps.length}` : undefined}
107149
/>

‎apps/sim/platform/design-system/components/modal/overlay-dialog.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface DialogSurfaceProps {
1010
onClose: () => void
1111
description?: string
1212
step?: number
13+
busy?: boolean
1314
onSubmit?: FormEventHandler<HTMLFormElement>
1415
children: ReactNode
1516
}
@@ -28,6 +29,7 @@ export function OverlayDialog({
2829
description,
2930
presentation,
3031
step,
32+
busy,
3133
onSubmit,
3234
children,
3335
}: OverlayDialogProps) {
@@ -101,7 +103,12 @@ export function OverlayDialog({
101103
>
102104
{open &&
103105
(onSubmit ? (
104-
<form ref={layout} className={styles.modalLayout} onSubmit={onSubmit}>
106+
<form
107+
ref={layout}
108+
className={styles.modalLayout}
109+
aria-busy={busy || undefined}
110+
onSubmit={onSubmit}
111+
>
105112
{content}
106113
</form>
107114
) : (

0 commit comments

Comments
 (0)