Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,45 @@
import { useState } from 'react'

import { type ApiError } from '@oxide/api'
import { Message, Modal } from '@oxide/ui'
import { classed } from '@oxide/util'
import { Modal } from '@oxide/ui'

import { clearConfirmDelete, useConfirmDelete } from 'app/stores/confirm-delete'
import { clearConfirmAction, useConfirmAction } from 'app/stores/confirm-action'
import { addToast } from 'app/stores/toast'

export const HL = classed.span`text-sans-semi-md text-default`

export function ConfirmDeleteModal() {
const deleteConfig = useConfirmDelete((state) => state.deleteConfig)
export function ConfirmActionModal() {
const actionConfig = useConfirmAction((state) => state.actionConfig)

// this is a bit sad -- ideally we would be able to use the loading state
// from the mutation directly, but that would require a lot of line changes
// and would require us to hook this up in a way that re-renders whenever the
// loading state changes
const [loading, setLoading] = useState(false)

if (!deleteConfig) return null

const { doDelete, warning, label } = deleteConfig
if (!actionConfig) return null

const displayLabel = typeof label === 'string' ? <HL>{label}</HL> : label
const { doAction, modalContent, errorTitle, modalTitle } = actionConfig

return (
<Modal isOpen onDismiss={clearConfirmDelete} title="Confirm delete">
<Modal.Section>
<p>Are you sure you want to delete {displayLabel}?</p>
{warning && <Message variant="error" content={warning} />}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turns out this warning bit was not used by any existing call site

</Modal.Section>
<Modal isOpen onDismiss={clearConfirmAction} title={modalTitle}>
<Modal.Section>{modalContent}</Modal.Section>
<Modal.Footer
onDismiss={clearConfirmDelete}
onDismiss={clearConfirmAction}
onAction={async () => {
setLoading(true)
try {
await doDelete()
await doAction()
} catch (error) {
addToast({
variant: 'error',
title: 'Could not delete resource',
title: errorTitle,
content: (error as ApiError).message,
})
}

setLoading(false) // do this regardless of success or error

// TODO: generic success toast?
clearConfirmDelete()
clearConfirmAction()
}}
cancelText="Cancel"
actionText="Confirm"
Expand Down
4 changes: 2 additions & 2 deletions app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { queryClient } from '@oxide/api'
import { SkipLink } from '@oxide/ui'

import { ConfirmDeleteModal } from './components/ConfirmDeleteModal'
import { ConfirmActionModal } from './components/ConfirmActionModal'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ReduceMotion } from './hooks'
// stripped out by rollup in production
Expand Down Expand Up @@ -45,7 +45,7 @@ function render() {
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<ConfirmDeleteModal />
<ConfirmActionModal />
<SkipLink id="skip-nav" />
<ReduceMotion />
<RouterProvider router={router} />
Expand Down
3 changes: 1 addition & 2 deletions app/pages/SiloAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@ import {
import { groupBy, isTruthy } from '@oxide/util'

import { AccessNameCell } from 'app/components/AccessNameCell'
import { HL } from 'app/components/ConfirmDeleteModal'
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
import {
SiloAccessAddUserSideModal,
SiloAccessEditUserSideModal,
} from 'app/forms/silo-access'
import { confirmDelete } from 'app/stores/confirm-delete'
import { confirmDelete, HL } from 'app/stores/confirm-delete'

const EmptyState = ({ onClick }: { onClick: () => void }) => (
<TableEmptyBox>
Expand Down
3 changes: 1 addition & 2 deletions app/pages/project/access/ProjectAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,13 @@ import {
import { groupBy, isTruthy } from '@oxide/util'

import { AccessNameCell } from 'app/components/AccessNameCell'
import { HL } from 'app/components/ConfirmDeleteModal'
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
import {
ProjectAccessAddUserSideModal,
ProjectAccessEditUserSideModal,
} from 'app/forms/project-access'
import { getProjectSelector, useProjectSelector } from 'app/hooks'
import { confirmDelete } from 'app/stores/confirm-delete'
import { confirmDelete, HL } from 'app/stores/confirm-delete'

const EmptyState = ({ onClick }: { onClick: () => void }) => (
<TableEmptyBox>
Expand Down
6 changes: 0 additions & 6 deletions app/pages/project/instances/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,6 @@ export const useMakeInstanceActions = (
options.onDelete?.()
addToast({ title: `Deleting instance '${instance.name}'` })
},
onError: (error) =>
addToast({
variant: 'error',
title: `Error deleting instance '${instance.name}'`,
content: error.message,
}),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug on main. confirmDelete shows an error toast, so this one is redundant.

}),
label: instance.name,
}),
Expand Down
33 changes: 15 additions & 18 deletions app/stores/confirm-delete.ts → app/stores/confirm-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,22 @@
import type { ReactNode } from 'react'
import { create } from 'zustand'

type DeleteConfig = {
type ActionConfig = {
/** Must be `mutateAsync`, otherwise we can't catch the error generically */
doDelete: () => Promise<unknown>
warning?: string
/**
* Label identifying the resource. Could be a name or something more elaborate
* "the Admin role for user Harry Styles". If a string, the modal will
* automatically give it a highlighted style. Otherwise it will be rendered
* directly.
*/
label: ReactNode
doAction: () => Promise<unknown>
/** e.g., Confirm delete, Confirm unlink */
modalTitle: string
modalContent: ReactNode
/** Title of error toast */
errorTitle: string
}

type ConfirmDeleteStore = {
deleteConfig: DeleteConfig | null
type ConfirmActionStore = {
actionConfig: ActionConfig | null
}

export const useConfirmDelete = create<ConfirmDeleteStore>(() => ({
deleteConfig: null,
export const useConfirmAction = create<ConfirmActionStore>(() => ({
actionConfig: null,
}))

// zustand docs say this pattern is equivalent to putting the actions on the
Expand All @@ -39,10 +36,10 @@ export const useConfirmDelete = create<ConfirmDeleteStore>(() => ({
/**
* Note that this returns a function so we can save a line in the calling code.
*/
export const confirmDelete = (deleteConfig: DeleteConfig) => () => {
useConfirmDelete.setState({ deleteConfig })
export const confirmAction = (actionConfig: ActionConfig) => () => {
useConfirmAction.setState({ actionConfig })
}

export function clearConfirmDelete() {
useConfirmDelete.setState({ deleteConfig: null })
export function clearConfirmAction() {
useConfirmAction.setState({ actionConfig: null })
}
42 changes: 42 additions & 0 deletions app/stores/confirm-delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 { classed } from '@oxide/util'

import { useConfirmAction } from './confirm-action'

// confirmAction was originally abstracted from confirmDelete. this preserves
// the existing confirmDelete API by constructing a confirmAction from it

type DeleteConfig = {
/** Must be `mutateAsync`, otherwise we can't catch the error generically */
doDelete: () => Promise<unknown>
/**
* Label identifying the resource. Could be a name or something more elaborate
* "the Admin role for user Harry Styles". If a string, the modal will
* automatically give it a highlighted style. Otherwise it will be rendered
* directly.
*/
label: React.ReactNode
}

export const HL = classed.span`text-sans-semi-md text-default`

export const confirmDelete =
({ doDelete, label }: DeleteConfig) =>
() => {
const displayLabel = typeof label === 'string' ? <HL>{label}</HL> : label
useConfirmAction.setState({
actionConfig: {
doAction: doDelete,
modalContent: <p>Are you sure you want to delete {displayLabel}?</p>,
errorTitle: 'Could not delete resource',
modalTitle: 'Confirm delete',
},
})
}