Skip to content

Commit

Permalink
Abstract confirm-delete into more general confirm-action (#1927)
Browse files Browse the repository at this point in the history
* abstract confirm-delete into more general confirm-action

* fix double error toast on instance delete
  • Loading branch information
david-crespo authored Jan 31, 2024
1 parent 695d367 commit 4bfadc0
Show file tree
Hide file tree
Showing 7 changed files with 73 additions and 50 deletions.
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} />}
</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,
}),
}),
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',
},
})
}

0 comments on commit 4bfadc0

Please sign in to comment.