@@ -7,9 +7,12 @@ import { SelectField, TextAreaField, TextField } from '@sim/emcn/field'
77import { ConfirmDialog } from '@sim/emcn/modal/confirm-dialog'
88import { MultiSelect } from '@sim/emcn/picker/multi-select'
99import { SettingsRow , SettingsSection } from '@sim/emcn/settings-section'
10- import { SaveActions } from '@sim/emcn/settings-section/save-actions'
1110import { InlineNotice , StatusTag } from '@sim/emcn/status'
11+ import { getErrorMessage } from '@sim/utils/errors'
12+ import { toast } from '@/lib/toast/sink'
13+ import { SettingsSaveBar } from '@/app/(product)/_components/settings-parts/save-bar'
1214import {
15+ ActionRow ,
1316 DetailHeader ,
1417 FieldStack ,
1518 FormFieldset ,
@@ -19,14 +22,23 @@ import {
1922} from '@/app/(product)/_components/settings-parts/settings-parts'
2023import {
2124 CLI_TOOL_OPTIONS ,
25+ canRetrySandboxBuild ,
2226 DEPENDENCY_PLACEHOLDERS ,
27+ draftFromSandbox ,
28+ emptyDraft ,
29+ extractIssues ,
2330 LANGUAGE_OPTIONS ,
2431 type Sandbox ,
2532 type SandboxCliToolId ,
2633 type SandboxDependencyIssue ,
2734 type SandboxDraft ,
2835 SYSTEM_PACKAGE_PLACEHOLDER ,
36+ toSubmittedLines ,
37+ useCreateSandbox ,
38+ useDeleteSandbox ,
39+ useUpdateSandbox ,
2940} from '@/platform/lib/data/sandboxes'
41+ import { requestSettingsLeave , useSettingsUnsavedGuard } from '@/platform/lib/data/settings-guard'
3042import { plural } from '@/platform/shared/plural'
3143
3244/** One line per rejected row, the first rejection of each line only. */
@@ -44,10 +56,10 @@ const STATUS = {
4456 pending : { label : 'Queued' , intent : 'info' } ,
4557} as const
4658
47- function contentsSummary ( sandbox : Sandbox ) : string {
48- const dependencies = sandbox . dependencies . length
59+ /** What a sandbox installs: `3 packages · 1 system package · 2 managed CLIs`. */
60+ export function contentsSummary ( sandbox : Sandbox ) : string {
4961 return [
50- ` ${ dependencies } ${ dependencies === 1 ? 'dependency' : 'dependencies' } ` ,
62+ plural ( sandbox . dependencies . length , 'package' ) ,
5163 plural ( sandbox . systemPackages . length , 'system package' ) ,
5264 plural ( sandbox . cliTools . length , 'managed CLI' ) ,
5365 ] . join ( ' · ' )
@@ -73,8 +85,8 @@ function SandboxStatus({
7385 return (
7486 < FieldStack >
7587 < Muted >
76- Dependencies, system packages, and managed CLI tools install at run time on this
77- deployment, adding startup time per execution . Prebuilt sandboxes require E2B.
88+ Packages and managed CLI tools install at run time on this deployment, adding startup time
89+ to each run . Prebuilt sandboxes need E2B
7890 </ Muted >
7991 </ FieldStack >
8092 )
@@ -86,8 +98,8 @@ function SandboxStatus({
8698 return (
8799 < FieldStack >
88100 < Muted >
89- No dependencies, system packages, or managed CLI tools yet. Add one above to build this
90- sandbox; until then it runs on the Function base.
101+ Nothing to install yet. Add a package or managed CLI tool above to build this sandbox;
102+ until then it runs on the Function base
91103 </ Muted >
92104 </ FieldStack >
93105 )
@@ -123,53 +135,105 @@ function SandboxStatus({
123135}
124136
125137interface SandboxEditorProps {
126- /** The saved sandbox, absent while creating one. */
138+ workspaceId : string
139+ /** The saved sandbox, absent while creating one. The list keys the editor by it. */
127140 sandbox : Sandbox | null
128141 strategy : 'prebuilt' | 'runtime'
129- draft : SandboxDraft
130- onChange : ( draft : SandboxDraft ) => void
131- dependencyIssues : SandboxDependencyIssue [ ]
132- systemPackageIssues : SandboxDependencyIssue [ ]
133142 canAdmin : boolean
134- dirty : boolean
135- saving : boolean
136- deleting : boolean
137- retrying : boolean
138- canRetry : boolean
139- onBack : ( ) => void
140- onSave : ( ) => void
141- onDiscard : ( ) => void
142- onRetry : ( ) => void
143- onDelete : ( ) => Promise < void >
143+ /** Back to the list, after a save, a delete or the back action. */
144+ onClose : ( ) => void
144145}
145146
146- /** A sandbox's packages, managed CLI tools and build, or a new one's form. */
147+ /**
148+ * A sandbox's packages, managed CLI tools and build, or a new one's form. It holds its own draft,
149+ * seeded from the sandbox it was keyed by, so opening another sandbox never carries edits over.
150+ */
147151export function SandboxEditor ( {
152+ workspaceId,
148153 sandbox,
149154 strategy,
150- draft,
151- onChange,
152- dependencyIssues,
153- systemPackageIssues,
154155 canAdmin,
155- dirty,
156- saving,
157- deleting,
158- retrying,
159- canRetry,
160- onBack,
161- onSave,
162- onDiscard,
163- onRetry,
164- onDelete,
156+ onClose,
165157} : SandboxEditorProps ) {
158+ const createSandbox = useCreateSandbox ( )
159+ const updateSandbox = useUpdateSandbox ( )
160+ const deleteSandbox = useDeleteSandbox ( )
161+ const [ original ] = useState ( ( ) => ( sandbox ? draftFromSandbox ( sandbox ) : emptyDraft ( ) ) )
162+ const [ draft , setDraft ] = useState < SandboxDraft > ( original )
163+ const [ dependencyIssues , setDependencyIssues ] = useState < SandboxDependencyIssue [ ] > ( [ ] )
164+ const [ systemPackageIssues , setSystemPackageIssues ] = useState < SandboxDependencyIssue [ ] > ( [ ] )
166165 const [ confirmingDelete , setConfirmingDelete ] = useState ( false )
166+
167+ const dirty =
168+ draft . name . trim ( ) !== original . name . trim ( ) ||
169+ draft . language !== original . language ||
170+ draft . dependencies !== original . dependencies ||
171+ draft . systemPackages !== original . systemPackages ||
172+ draft . cliTools . join ( ',' ) !== original . cliTools . join ( ',' )
173+ const saving = createSandbox . isPending || updateSandbox . isPending
167174 const disabled = ! canAdmin || saving
175+ const canRetry = canRetrySandboxBuild ( { canAdmin, isDirty : dirty , saving } )
176+
177+ const discard = ( ) => {
178+ setDraft ( original )
179+ setDependencyIssues ( [ ] )
180+ setSystemPackageIssues ( [ ] )
181+ }
182+
183+ /** The save bar registers an edit's draft; the create form, which has none, registers here. */
184+ useSettingsUnsavedGuard ( {
185+ isDirty : ! sandbox && dirty ,
186+ navigationBlocked : deleteSandbox . isPending || ( ! sandbox && saving ) ,
187+ onDiscard : sandbox ? undefined : discard ,
188+ } )
189+
190+ async function save ( ) {
191+ setDependencyIssues ( [ ] )
192+ setSystemPackageIssues ( [ ] )
193+ const body = {
194+ name : draft . name . trim ( ) ,
195+ language : draft . language ,
196+ dependencies : toSubmittedLines ( draft . dependencies ) ,
197+ systemPackages : toSubmittedLines ( draft . systemPackages ) ,
198+ cliTools : draft . cliTools ,
199+ }
200+ try {
201+ if ( sandbox ) await updateSandbox . mutateAsync ( { workspaceId, sandboxId : sandbox . id , ...body } )
202+ else await createSandbox . mutateAsync ( { workspaceId, ...body } )
203+ onClose ( )
204+ } catch ( caught ) {
205+ const lineIssues = extractIssues ( caught )
206+ if ( lineIssues ) {
207+ setDependencyIssues ( lineIssues . field === 'dependencies' ? lineIssues . issues : [ ] )
208+ setSystemPackageIssues ( lineIssues . field === 'systemPackages' ? lineIssues . issues : [ ] )
209+ return
210+ }
211+ toast . error ( getErrorMessage ( caught , 'Failed to save sandbox' ) )
212+ }
213+ }
214+
215+ async function retry ( ) {
216+ if ( ! sandbox || ! canRetry ) return
217+ try {
218+ await updateSandbox . mutateAsync ( {
219+ workspaceId,
220+ sandboxId : sandbox . id ,
221+ language : sandbox . language ,
222+ dependencies : sandbox . dependencies ,
223+ systemPackages : sandbox . systemPackages ,
224+ cliTools : sandbox . cliTools ,
225+ } )
226+ toast . success ( 'Sandbox build queued' )
227+ } catch ( caught ) {
228+ toast . error ( getErrorMessage ( caught , 'Failed to retry sandbox build' ) )
229+ }
230+ }
231+
168232 return (
169233 < SectionStack >
170234 < DetailHeader
171235 backLabel = 'Sandboxes'
172- onBack = { onBack }
236+ onBack = { ( ) => requestSettingsLeave ( onClose ) }
173237 title = { sandbox ? sandbox . name : 'New sandbox' }
174238 description = 'Dependencies, system packages and managed CLI tools your Function blocks can use'
175239 />
@@ -179,7 +243,7 @@ export function SandboxEditor({
179243 < TextField
180244 label = 'Name'
181245 value = { draft . name }
182- onChange = { ( name ) => onChange ( { ...draft , name } ) }
246+ onChange = { ( name ) => setDraft ( { ...draft , name } ) }
183247 placeholder = 'bigquery-etl'
184248 maxLength = { 64 }
185249 autoComplete = 'off'
@@ -188,7 +252,7 @@ export function SandboxEditor({
188252 < SelectField
189253 label = 'Language'
190254 value = { draft . language }
191- onValueChange = { ( language ) => onChange ( { ...draft , language } ) }
255+ onValueChange = { ( language ) => setDraft ( { ...draft , language } ) }
192256 options = { LANGUAGE_OPTIONS }
193257 disabled = { disabled }
194258 />
@@ -199,7 +263,7 @@ export function SandboxEditor({
199263 < TextAreaField
200264 label = 'Dependencies'
201265 value = { draft . dependencies }
202- onChange = { ( dependencies ) => onChange ( { ...draft , dependencies } ) }
266+ onChange = { ( dependencies ) => setDraft ( { ...draft , dependencies } ) }
203267 placeholder = { DEPENDENCY_PLACEHOLDERS [ draft . language ] }
204268 defaultSize = 'expanded'
205269 spellCheck = { false }
@@ -212,7 +276,7 @@ export function SandboxEditor({
212276 options = { CLI_TOOL_OPTIONS }
213277 value = { draft . cliTools }
214278 onValueChange = { ( cliTools ) =>
215- onChange ( { ...draft , cliTools : cliTools as SandboxCliToolId [ ] } )
279+ setDraft ( { ...draft , cliTools : cliTools as SandboxCliToolId [ ] } )
216280 }
217281 placeholder = 'Select managed CLI tools'
218282 searchLabel = 'Search managed CLI tools'
@@ -221,7 +285,7 @@ export function SandboxEditor({
221285 < TextAreaField
222286 label = 'System packages'
223287 value = { draft . systemPackages }
224- onChange = { ( systemPackages ) => onChange ( { ...draft , systemPackages } ) }
288+ onChange = { ( systemPackages ) => setDraft ( { ...draft , systemPackages } ) }
225289 placeholder = { SYSTEM_PACKAGE_PLACEHOLDER }
226290 defaultSize = 'expanded'
227291 spellCheck = { false }
@@ -237,29 +301,42 @@ export function SandboxEditor({
237301 < SandboxStatus
238302 sandbox = { sandbox }
239303 strategy = { strategy }
240- onRetry = { canAdmin ? onRetry : undefined }
241- retrying = { retrying }
304+ onRetry = { canAdmin ? ( ) => void retry ( ) : undefined }
305+ retrying = { updateSandbox . isPending }
242306 retryDisabled = { ! canRetry }
243307 />
244308 </ SettingsSection >
245309 ) }
246- < SaveActions
247- dirty = { dirty }
248- saving = { saving }
249- blocked = { ! canAdmin || draft . name . trim ( ) . length === 0 }
250- onSave = { onSave }
251- onDiscard = { onDiscard }
252- />
310+ { canAdmin &&
311+ ( sandbox ? (
312+ < SettingsSaveBar
313+ dirty = { dirty }
314+ saving = { saving }
315+ blocked = { draft . name . trim ( ) . length === 0 }
316+ onSave = { ( ) => void save ( ) }
317+ onDiscard = { discard }
318+ />
319+ ) : (
320+ < ActionRow >
321+ < Button
322+ variant = 'primary'
323+ disabled = { saving || draft . name . trim ( ) . length === 0 }
324+ onClick = { ( ) => void save ( ) }
325+ >
326+ { saving ? 'Creating…' : 'Create sandbox' }
327+ </ Button >
328+ </ ActionRow >
329+ ) ) }
253330 { sandbox && canAdmin && (
254331 < SettingsSection title = 'Manage sandbox' >
255332 < SettingsRow label = 'Delete sandbox' description = 'Remove this sandbox from the workspace' >
256333 < Button
257334 variant = 'quiet'
258335 destructive
259- disabled = { deleting }
336+ disabled = { deleteSandbox . isPending }
260337 onClick = { ( ) => setConfirmingDelete ( true ) }
261338 >
262- { deleting ? 'Deleting…' : ' Delete' }
339+ Delete
263340 </ Button >
264341 </ SettingsRow >
265342 </ SettingsSection >
@@ -269,10 +346,13 @@ export function SandboxEditor({
269346 target = { confirmingDelete }
270347 onClose = { ( ) => setConfirmingDelete ( false ) }
271348 title = 'Delete sandbox'
272- description = { `This will permanently delete ${ sandbox . name } and remove it from any Function blocks that are using it. This action cannot be undone.` }
349+ description = { `${ sandbox . name } is removed from every Function block that uses it. This action cannot be undone.` }
273350 confirmLabel = 'Delete'
274351 pendingLabel = 'Deleting…'
275- onConfirm = { onDelete }
352+ onConfirm = { async ( ) => {
353+ await deleteSandbox . mutateAsync ( { workspaceId, sandboxId : sandbox . id } )
354+ onClose ( )
355+ } }
276356 />
277357 ) }
278358 </ SectionStack >
0 commit comments