diff --git a/app/components/ConfirmModal.tsx b/app/components/ConfirmModal.tsx new file mode 100644 index 000000000..7dc18b125 --- /dev/null +++ b/app/components/ConfirmModal.tsx @@ -0,0 +1,122 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { Dialog as BaseDialog } from '@base-ui/react/dialog' +import * as m from 'motion/react-m' +import { useRef, type ReactNode } from 'react' + +import { Close12Icon } from '@oxide/design-system/icons/react' + +import { Modal } from '~/ui/lib/Modal' +import { ModalContext, useSideModalPopupRef } from '~/ui/lib/modal-context' + +type ConfirmModalProps = { + isOpen: boolean + onDismiss: () => void + onConfirm: () => void + /** Short question, sentence case. e.g. "Cancel upload?" */ + title: string + /** One or two short lines. State the consequence first. */ + children: ReactNode + /** Verb phrase matching the destructive action. e.g. "Cancel upload" */ + confirmText: string + /** Verb phrase meaning "stay where I am". e.g. "Keep uploading" */ + dismissText: string + /** @default 'danger' */ + actionType?: 'primary' | 'danger' +} + +/** + * A confirm dialog stacked over a SideModal (e.g. a nav guard on an edited + * form, or a cancel guard on an in-flight upload). Portals into the + * SideModal's popup rather than document body, so the scrim and dialog use + * the SideModal as their positioning context — auto-centered, no hard-coded + * widths. + * + * On open, focus lands on the destructive primary action so a user who got + * here by triggering a dismiss can press Enter once more to confirm. Esc and + * the × close only this dialog, leaving the SideModal open. + * + * Must be rendered inside a SideModal — relies on SideModalPopupRefContext. + */ +export function ConfirmModal({ + isOpen, + onDismiss, + onConfirm, + title, + children, + confirmText, + dismissText, + actionType = 'danger', +}: ConfirmModalProps) { + const actionRef = useRef(null) + const sideModalRef = useSideModalPopupRef() + if (!isOpen || !sideModalRef) return null + return ( + + { + // Ignore focus-out to prevent a dismiss loop when a native confirm() + // dialog steals and returns focus. Same trick as Modal. + if (!open && reason !== 'focus-out') onDismiss() + }} + > + {/* Portal into the SideModal so absolute children use the SideModal + as their positioning context — no hard-coded widths. */} + + {/* Scrim. absolute inset-0 fills the SideModal's popup, not the + viewport. forceRender so base-ui doesn't hide the nested backdrop. */} + + } + /> + + } + > + + + {title} + + {children} + + + + + + + + + + ) +} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index dc2ba135a..62e1e66ad 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -9,9 +9,9 @@ import { useEffect, useId, useState, type ReactNode } from 'react' import type { FieldValues, UseFormReturn } from 'react-hook-form' +import { ConfirmModal } from '~/components/ConfirmModal' import { useShouldAnimateModal } from '~/hooks/use-should-animate-modal' import { Button } from '~/ui/lib/Button' -import { Modal } from '~/ui/lib/Modal' import { SideModal } from '~/ui/lib/SideModal' type CreateFormProps = { @@ -128,28 +128,16 @@ export function SideModalForm({ )} - {showNavGuard && ( - setShowNavGuard(false)} - title="Confirm navigation" - width="narrow" - overlay={false} - > - - Are you sure you want to leave this form? -
- All progress will be lost. -
- setShowNavGuard(false)} - cancelText="Keep editing" - actionText="Leave form" - actionType="danger" - /> -
- )} + setShowNavGuard(false)} + onConfirm={onDismiss} + title="Leave form?" + confirmText="Leave form" + dismissText="Keep editing" + > + Any unsaved changes will be lost. + ) } diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index f47d8b673..a9920df1d 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -7,9 +7,10 @@ */ import { skipToken, useQuery } from '@tanstack/react-query' import cn from 'classnames' +import * as m from 'motion/react-m' import pMap from 'p-map' import pRetry from 'p-retry' -import { useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' @@ -29,26 +30,33 @@ import { Unauthorized12Icon, } from '@oxide/design-system/icons/react' +import { ConfirmModal } from '~/components/ConfirmModal' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { FileField } from '~/components/form/fields/FileField' import { NameField } from '~/components/form/fields/NameField' import { RadioField } from '~/components/form/fields/RadioField' import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' +import { useShouldAnimateModal } from '~/hooks/use-should-animate-modal' +import { addToast } from '~/stores/toast' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { Progress } from '~/ui/lib/Progress' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { SideModal } from '~/ui/lib/SideModal' import { Spinner } from '~/ui/lib/Spinner' +import { Truncate } from '~/ui/lib/Truncate' import { anySignal } from '~/util/abort' import { readBlobAsBase64 } from '~/util/file' import { invariant } from '~/util/invariant' import { docLinks, links } from '~/util/links' import { pb } from '~/util/path-builder' import { isAllZeros } from '~/util/str' -import { formatBytes, GiB, KiB } from '~/util/units' +import { bytesToGiB, formatBytes, GiB, KiB } from '~/util/units' // Padded because otherwise the numbers jump around a bit, e.g., when it goes // from 10.55 to 14.7 to 19.23 @@ -109,9 +117,12 @@ type StepProps = { label: string duration?: number className?: string + /** When true, sets aria-current="step" and accepts a ref for focus management */ + active?: boolean + stepRef?: React.Ref } -function Step({ children, state, label, className }: StepProps) { +function Step({ children, state, label, className, active, stepRef }: StepProps) { /* eslint-disable react/jsx-key */ const [status, icon] = state.isSuccess ? ['complete', ] @@ -122,15 +133,26 @@ function Step({ children, state, label, className }: StepProps) { : ['ready', ] /* eslint-enable react/jsx-key */ return ( - // data-status used only for e2e testing
{/* padding on icon to align it with text since everything is aligned to top */} -
{icon}
-
+
{icon}
+
{label}
{children}
@@ -170,51 +192,54 @@ const ABORT_ERROR = new Error('Upload canceled') */ const CHUNK_SIZE_BYTES = 512 * KiB -// States -// -// - Form -// - Clean or filled -// - Error -// - Checking that image name isn't taken (back to form if taken) -// - Upload in progress -// - Happy path -// - Create disk -// - Import start -// - Uploading -// - Import stop -// - Finalize disk + create snapshot -// - Create image from snapshot -// - Cleanup -// - Error -// - Show error, click here to try again -// - If we failed after upload complete, maybe try again from there? -// - Otherwise, restart everything -// - If image name got taken in the meantime, give chance to rename? -// -// Part of the problem is that I'm relying on RQ for the state of the upload -// steps, but there's slippage with what I actually want that to represent - -// TODO: make sure cleanup, cancelEverything, and resetMainFlow are called in -// the right places - export const handle = titleCrumb('Upload image') +type Phase = 'form' | 'progress' + +function SummaryHeader({ values }: { values: FormValues }) { + const file = values.imageFile + return ( + + {values.imageName} + + {values.os ? values.os : } + + + {values.version ? values.version : } + + + {values.blockSize} B + + {file && ( + + + + )} + {file && ( + + {bytesToGiB(file.size)} + GiB + + )} + + ) +} + /** - * Upload an image. Opens a second modal to show upload progress. + * Upload an image. Swaps between a form phase and a progress phase inside a + * single side modal — no second modal is mounted. */ export default function ImageCreate() { const navigate = useNavigate() const { project } = useProjectSelector() + const formId = useId() + const animate = useShouldAnimateModal() - // The state in this component is very complex because we are doing a bunch of - // requests in order, all of which can fail, plus the whole thing can be - // aborted. We have the usual form state, plus an additional validation step - // where we check the API to make sure the name is not taken. Then, while we - // are submitting, we rely on the RQ mutations themselves, plus a synthetic - // mutation state representing the many calls of the bulk upload step. + const [phase, setPhase] = useState('form') + const [showCancelGuard, setShowCancelGuard] = useState(false) + const [showNavGuard, setShowNavGuard] = useState(false) const [formError, setFormError] = useState<{ message: string } | null>(null) - const [modalOpen, setModalOpen] = useState(false) const [modalError, setModalError] = useState(null) // progress bar, 0-100 @@ -228,14 +253,20 @@ export default function ImageCreate() { // done with everything, ready to close the modal const [allDone, setAllDone] = useState(false) + // true while cleanup() is awaiting after a cancel/error, so the form footer + // can explain why the resubmit button is in a loading state + const [isCleaningUp, setIsCleaningUp] = useState(false) + + // for restoring focus when returning from progress to form + const previousFocusRef = useRef(null) + // first step row, focused on phase change to progress + const firstStepRef = useRef(null) + const createDisk = useApiMutation(api.diskCreate) const startImport = useApiMutation(api.diskBulkWriteImportStart) // gcTime: 0 prevents the mutation cache from holding onto all the chunks for - // 5 minutes. It can be a ton of memory. To be honest, I don't even understand - // why the mutation cache exists. It's not like the query cache, which dedupes - // identical queries made around the same time. - // https://tanstack.com/query/v5/docs/reference/MutationCache + // 5 minutes. It can be a ton of memory. const uploadChunk = useApiMutation(api.diskBulkWriteImport, { gcTime: 0 }) // synthetic state for upload step because it consists of multiple requests @@ -248,10 +279,6 @@ export default function ImageCreate() { const deleteDisk = useApiMutation(api.diskDelete) const deleteSnapshot = useApiMutation(api.snapshotDelete) - // TODO: Distinguish cleanup mutations being called after successful run vs. - // due to error. In the former case, they have their own steps to highlight as - // successful. In the latter, we do not want to highlight the steps. - const mainFlowMutations = [ createDisk, startImport, @@ -280,53 +307,14 @@ export default function ImageCreate() { }, }) - const cleanupMutations = [ - stopImportCleanup, - finalizeDiskCleanup, - deleteDiskCleanup, - deleteSnapshotCleanup, - ] - - const allMutations = [...mainFlowMutations, syntheticUploadState, ...cleanupMutations] - - // we don't want to be able to click submit while anything is running - const formLoading = allMutations.some((m) => m.isPending) - // the created snapshot and disk. presence used in cleanup to decide whether we need to // attempt to delete them const snapshot = useRef(null) const disk = useRef(null) - function closeModal() { - if (allDone) { - backToImages() - return - } - - // if we're still going, need to confirm cancellation. if we have an error, - // everything is already stopped - if (modalError || confirm('Are you sure? Closing the modal will cancel the upload.')) { - // Note we don't run cleanup() here -- cancelEverything triggers an - // abort, which gets caught by the try/catch in the onSubmit on the upload - // form, which does the cleanup. We used to call cleanup here and used - // error-prone state logic to avoid it running twice. - // - // Because we are working with a closed-over copy of allDone, there is - // a possibility that the upload finishes while the user is looking at - // the confirm modal, in which case cancelEverything simply won't do - // anything. The finally{} in onSubmit clears out the abortController so - // cancelEverything() is a noop. - cancelEverything() - resetMainFlow() - setModalOpen(false) - } - } - // Aborting works for steps other than file upload despite the // signal not being used directly in the former because we call - // `abortController.throwIfAborted()` after each step. We could technically - // plumb through the signal to the requests themselves, but they complete so - // quickly it's probably not necessary. + // `abortController.throwIfAborted()` after each step. function cancelEverything() { abortController.current?.abort(ABORT_ERROR) } @@ -334,6 +322,7 @@ export default function ImageCreate() { function resetMainFlow() { setModalError(null) setUploadProgress(0) + setAllDone(false) mainFlowMutations.forEach((m) => m.reset()) setSyntheticUploadState(initSyntheticState) } @@ -363,7 +352,7 @@ export default function ImageCreate() { } } - async function onSubmit({ + async function runUpload({ imageName, imageDescription, imageFile, @@ -373,15 +362,6 @@ export default function ImageCreate() { }: FormValues) { invariant(imageFile, 'imageFile must exist') // shouldn't be possible to fail bc file is a required field - // this is done up here instead of next to the upload step because after - // upload is canceled, a few outstanding bulk writes will complete, setting - // uploadProgress to non-zero values. if we do this reset down there instead - // of up here, cancel and retry will bring up a modal briefly showing the - // previous run's progress, and it resets only when bulk upload starts - resetMainFlow() - - setModalOpen(true) - // Create a disk in state import-ready const diskName = getTmpDiskName(imageName) disk.current = await createDisk.mutateAsync({ @@ -407,17 +387,11 @@ export default function ImageCreate() { abortController.current?.signal.throwIfAborted() // Post file to the API in chunks of size `maxChunkSize`. Browsers cap - // concurrent fetches at 6 per host. If we ran without a concurrency limit, - // we'd read way more chunks into memory than we're ready to POST, and we'd - // be sitting around waiting for the browser to let the fetches through. - // That sounds bad. So we use pMap to process at most 6 chunks at a time. - + // concurrent fetches at 6 per host. So we use pMap to process at most 6 + // chunks at a time. setSyntheticUploadState({ isPending: true, isSuccess: false, isError: false }) const nChunks = Math.ceil(imageFile.size / CHUNK_SIZE_BYTES) - - // TODO: try to warn user if they try to close the tab while this is going - let chunksProcessed = 0 const postChunk = async (i: number) => { @@ -488,9 +462,7 @@ export default function ImageCreate() { // TODO: we checked at the beginning that the image name was free, but it // could be taken during upload. If this fails with object already exists, - // don't delete the snapshot (could still delete the disk). Instead, link - // user to snapshot detail and tell them to go there and create the image - // from it. + // don't delete the snapshot (could still delete the disk). await createImage.mutateAsync({ query: { project }, body: { @@ -513,6 +485,30 @@ export default function ImageCreate() { setAllDone(true) } + /** Wraps runUpload with abort/cleanup/error handling. */ + async function runUploadGuarded(values: FormValues) { + abortController.current = new AbortController() + try { + await runUpload(values) + } catch (e) { + if (e !== ABORT_ERROR) { + console.error(e) + setModalError(getUploadErrorMessage(e)) + // abort anything in flight in case + cancelEverything() + } + // user canceled or error: clean up any partial state + setIsCleaningUp(true) + try { + await cleanup() + } finally { + setIsCleaningUp(false) + } + } finally { + abortController.current = null + } + } + const form = useForm({ defaultValues }) const file = form.watch('imageFile') const blockSize = form.watch('blockSize') @@ -522,182 +518,348 @@ export default function ImageCreate() { queryFn: file ? () => validateImage(file) : skipToken, }) + async function onSubmit(values: FormValues) { + setFormError(null) + + // check that image name isn't taken before starting the whole thing + const image = await queryClient + .fetchQuery( + q( + api.imageView, + { path: { image: values.imageName }, query: { project } }, + { + errorsExpected: { + explanation: 'the image name may not exist yet.', + statusCode: 404, + }, + } + ) + ) + .catch((e) => { + // eat a 404 since that's what we want. anything else should still blow up + if (e.statusCode === 404) return null + throw e + }) + if (image) { + setFormError({ message: 'Image name already exists' }) + return + } + + // stash currently focused element so we can restore it on cancel + previousFocusRef.current = document.activeElement as HTMLElement | null + + setPhase('progress') + await runUploadGuarded(values) + } + + function backToForm() { + // controller is still live → there's an in-flight upload to tear down. + // show "Cleaning up…" right away rather than waiting for the catch in + // runUploadGuarded, which only fires once the in-flight step throws. + if (abortController.current) setIsCleaningUp(true) + cancelEverything() + resetMainFlow() + setPhase('form') + setShowCancelGuard(false) + // defer focus restore until after re-render so the field is visible again + setTimeout(() => previousFocusRef.current?.focus(), 0) + } + + async function handleRetry() { + resetMainFlow() + await runUploadGuarded(form.getValues()) + } + + function handleDismiss() { + if (phase === 'form') { + if (form.formState.isDirty) { + setShowNavGuard(true) + } else { + backToImages() + } + return + } + // progress phase: error path means upload is already aborted; allow exit + if (modalError) { + backToImages() + return + } + // success: nothing to cancel; treat as Done (toast + close) + if (allDone) { + handleDone() + return + } + // running: confirm before aborting + setShowCancelGuard(true) + } + + // move focus to the first step on phase change to progress + useEffect(() => { + if (phase === 'progress') { + firstStepRef.current?.focus() + } + }, [phase]) + + // If the run reaches a terminal state (success or error) while the cancel + // guard is open, dismiss the guard so the outer modal can run its normal + // success/error path. The user no longer has anything to cancel. + useEffect(() => { + if (allDone || modalError) setShowCancelGuard(false) + }, [allDone, modalError]) + + function handleDone() { + const imageName = form.getValues('imageName') + addToast({ + // prettier-ignore + content: <>Image {imageName} uploaded, + cta: { + text: `View ${imageName}`, + link: pb.projectImageEdit({ project, image: imageName }), + }, + }) + backToImages() + } + + // determine which step is the active one for aria-current + const activeStepIndex = (() => { + if (createDisk.isPending) return 0 + if (startImport.isPending) return 1 + if (syntheticUploadState.isPending) return 2 + if (stopImport.isPending) return 3 + if (finalizeDisk.isPending) return 4 + if (createImage.isPending) return 5 + if (deleteDisk.isPending || deleteSnapshot.isPending) return 6 + return -1 + })() + + // once createImage starts there's no abort checkpoint left and the snapshot/disk + // deletes don't honor the signal, so cancellation would leave the image in place + // while the UI pretends nothing happened + const cancelDisabledReason = + createImage.isPending || createImage.isSuccess + ? 'Image has been created and can no longer be canceled' + : undefined + return ( - { - setFormError(null) - - // check that image name isn't taken before starting the whole thing - const image = await queryClient - .fetchQuery( - q( - api.imageView, - { path: { image: values.imageName }, query: { project } }, - { - errorsExpected: { - explanation: 'the image name may not exist yet.', - statusCode: 404, - }, - } - ) - ) - .catch((e) => { - // eat a 404 since that's what we want. anything else should still blow up - if (e.statusCode === 404) return null - throw e - }) - if (image) { - // TODO: set this error on the field instead of the whole form - // TODO: make setError available here somehow :( - setFormError({ message: 'Image name already exists' }) - return - } - - // every submit needs its own AbortController because they can't be - // reset - abortController.current = new AbortController() - - try { - await onSubmit(values) - } catch (e) { - if (e !== ABORT_ERROR) { - console.error(e) - setModalError(getUploadErrorMessage(e)) - // abort anything in flight in case - cancelEverything() - } - // user canceled - await cleanup() - // TODO: if we get here, show failure state in the upload modal - } finally { - // Clear the abort controller. This is aimed at the case where the - // user clicks cancel and then stares at the confirm modal without - // clicking for so long that the upload manages to finish, which means - // there's no longer anything to cancel. If abortController is gone, - // cancelEverything is a noop. - abortController.current = null - } - }} - loading={formLoading} - submitError={formError} - submitLabel={allDone ? 'Done' : 'Upload image'} + isOpen + onDismiss={handleDismiss} + animate={animate} + errors={phase === 'form' && formError ? [formError.message] : undefined} > - - - {/* TODO: are OS and Version supposed to be non-empty? I doubt the API cares, - * but it will be pretty for end users if they're empty - */} - - -
- parseInt(val, 10) as BlockSize} - items={[ - { label: '512', value: 512 }, - { label: '2048', value: 2048 }, - { label: '4096', value: 4096 }, - ]} - /> - {imageValidation && } -
-
- - {imageValidation && } -
- {file && modalOpen && ( - - - -
- {modalError && ( - - )} - - - -
-
-
{file.name}
- {/* cancel and/or pause buttons could go here */} -
-
-
-
- {fsize((uploadProgress / 100) * file.size)}{' '} - / {fsize(file.size)} -
-
{uploadProgress}%
+ {phase === 'progress' && } + + + {phase === 'progress' && ( +
+ {modalError && ( + + )} + + + + {file && ( +
+
+
{file.name}
+
+
+
+
+ {fsize((uploadProgress / 100) * file.size)}{' '} + / {fsize(file.size)}
- +
{uploadProgress}%
+
- - - - - - -
- - - - - )} - - +
+ )} + + + + + + +
+ )} + + + {phase === 'form' ? ( + <> + {isCleaningUp && ( + + + Cleaning up… + + )} + + + + ) : modalError ? ( + <> + + + + ) : ( + <> + + + + )} + + + setShowCancelGuard(false)} + onConfirm={backToForm} + title="Cancel upload?" + confirmText="Cancel upload" + dismissText="Keep uploading" + > + All progress will be lost. Your form values are kept. + + + setShowNavGuard(false)} + onConfirm={backToImages} + title="Leave form?" + confirmText="Leave form" + dismissText="Keep editing" + > + Any unsaved changes will be lost. + + ) } diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 15d3a8d3f..9c4c982dc 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -8,6 +8,7 @@ import { Dialog as BaseDialog } from '@base-ui/react/dialog' import cn from 'classnames' import * as m from 'motion/react-m' +import type { RefObject } from 'react' import type { MergeExclusive } from 'type-fest' import { Close12Icon } from '@oxide/design-system/icons/react' @@ -106,6 +107,8 @@ type FooterProps = { actionType?: 'primary' | 'danger' actionText: React.ReactNode actionLoading?: boolean + /** Forwarded to the action button so callers can focus it programmatically. */ + actionRef?: RefObject cancelText?: string disabled?: boolean disabledReason?: React.ReactNode @@ -119,6 +122,7 @@ Modal.Footer = ({ actionType = 'primary', actionText, actionLoading, + actionRef, cancelText, disabled, disabledReason, @@ -134,6 +138,7 @@ Modal.Footer = ({ )}