This is an automated email from the ASF dual-hosted git repository.
likyh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new d703d6fd feat(config-ui): add new page projects (#3851)
d703d6fd is described below
commit d703d6fdcb50a4d8c46a6a2293719dff7a8d0c45
Author: 青湛 <[email protected]>
AuthorDate: Tue Dec 6 14:48:06 2022 +0800
feat(config-ui): add new page projects (#3851)
* fix(config-ui): request params error when the method equal get
* feat(config-ui): add new component toast2
* feat(config-ui): add new utils operator
* feat(config-ui): support parameter okLoading in dialog
* feat(config-ui): support parameter style and size in loading
* feat(config-ui): add new page projects
* feat(config-ui): add routes for projects
---
config-ui/src/App.js | 8 +-
config-ui/src/components/dialog/index.tsx | 3 +
config-ui/src/components/index.ts | 1 +
config-ui/src/components/loading/index.tsx | 10 +-
config-ui/src/components/loading/styled.ts | 7 +-
.../{index.d.ts => components/toast2/index.tsx} | 9 +-
config-ui/src/components/utils/request.ts | 8 +-
config-ui/src/images/no-data.svg | 22 +++
config-ui/src/index.d.ts | 5 +
config-ui/src/layouts/base/use-menu.ts | 6 +
config-ui/src/{index.d.ts => pages/index.ts} | 7 +-
.../index.tsx => pages/project/home/api.ts} | 33 +++--
config-ui/src/pages/project/home/index.tsx | 157 +++++++++++++++++++++
.../loading => pages/project/home}/styled.ts | 70 +++++----
config-ui/src/pages/project/home/use-project.ts | 86 +++++++++++
.../src/{index.d.ts => pages/project/index.ts} | 7 +-
config-ui/src/{index.d.ts => utils/index.ts} | 7 +-
config-ui/src/utils/operator.ts | 57 ++++++++
18 files changed, 430 insertions(+), 73 deletions(-)
diff --git a/config-ui/src/App.js b/config-ui/src/App.js
index 71a87c93..be2454cc 100644
--- a/config-ui/src/App.js
+++ b/config-ui/src/App.js
@@ -36,6 +36,7 @@ import '@fontsource/inter/variable-full.css'
import useDatabaseMigrations from '@/hooks/useDatabaseMigrations'
import { BaseLayout } from '@/layouts'
+import { ProjectHomePage } from '@/pages'
import ErrorBoundary from '@/components/ErrorBoundary'
import Integration from '@/pages/configure/integration/index'
import ManageIntegration from '@/pages/configure/integration/manage'
@@ -72,7 +73,12 @@ function App(props) {
<Route
path='/'
exact
- component={() => <Redirect to='/integrations' />}
+ component={() => <Redirect to='/projects' />}
+ />
+ <Route
+ exact
+ path='/projects'
+ component={() => <ProjectHomePage />}
/>
<Route
exact
diff --git a/config-ui/src/components/dialog/index.tsx
b/config-ui/src/components/dialog/index.tsx
index 10bd8640..5b50d5fc 100644
--- a/config-ui/src/components/dialog/index.tsx
+++ b/config-ui/src/components/dialog/index.tsx
@@ -28,6 +28,7 @@ interface Props {
cancelText?: string
okText?: string
okDisabled?: boolean
+ okLoading?: boolean
onCancel?: () => void
onOk?: () => void
}
@@ -39,6 +40,7 @@ export const Dialog = ({
cancelText = 'Cancel',
okText = 'OK',
okDisabled,
+ okLoading,
onCancel,
onOk
}: Props) => {
@@ -61,6 +63,7 @@ export const Dialog = ({
/>
<Button
disabled={okDisabled}
+ loading={okLoading}
intent={Intent.PRIMARY}
text={okText}
onClick={onOk}
diff --git a/config-ui/src/components/index.ts
b/config-ui/src/components/index.ts
index 6bd807bd..f14615ba 100644
--- a/config-ui/src/components/index.ts
+++ b/config-ui/src/components/index.ts
@@ -22,3 +22,4 @@ export * from './page-header'
export * from './selector'
export * from './dialog'
export * from './table'
+export * from './toast2'
diff --git a/config-ui/src/components/loading/index.tsx
b/config-ui/src/components/loading/index.tsx
index 203f668c..8b3f1847 100644
--- a/config-ui/src/components/loading/index.tsx
+++ b/config-ui/src/components/loading/index.tsx
@@ -21,14 +21,16 @@ import React from 'react'
import * as S from './styled'
interface Props {
+ size?: number
text?: string
+ style?: React.CSSProperties
}
-export const Loading = ({ text }: Props) => {
+export const Loading = ({ size = 24, text, style }: Props) => {
return (
- <S.Wrapper>
- <S.Spin />
- <S.Text>{text}</S.Text>
+ <S.Wrapper style={style}>
+ <S.Spin size={size} />
+ {text && <S.Text>{text}</S.Text>}
</S.Wrapper>
)
}
diff --git a/config-ui/src/components/loading/styled.ts
b/config-ui/src/components/loading/styled.ts
index c47b98e5..13616bf3 100644
--- a/config-ui/src/components/loading/styled.ts
+++ b/config-ui/src/components/loading/styled.ts
@@ -34,12 +34,13 @@ export const Wrapper = styled.div`
align-items: center;
`
-export const Spin = styled.div`
- width: 26px;
- height: 26px;
+export const Spin = styled.div<{ size: number }>`
+ width: ${({ size }) => size}px;
+ height: ${({ size }) => size}px;
border: 2px solid #7497f7;
border-radius: 50%;
border-right-color: transparent;
+ box-sizing: border-box;
animation-name: ${SpinKeyframes};
animation-duration: 1s;
animation-timing-function: linear;
diff --git a/config-ui/src/index.d.ts
b/config-ui/src/components/toast2/index.tsx
similarity index 87%
copy from config-ui/src/index.d.ts
copy to config-ui/src/components/toast2/index.tsx
index b24a60d7..00940776 100644
--- a/config-ui/src/index.d.ts
+++ b/config-ui/src/components/toast2/index.tsx
@@ -16,9 +16,8 @@
*
*/
-type ID = string | number
+import { Toaster, Position } from '@blueprintjs/core'
-declare module '*.svg' {
- const content: any
- export default content
-}
+export const Toast = Toaster.create({
+ position: Position.TOP
+})
diff --git a/config-ui/src/components/utils/request.ts
b/config-ui/src/components/utils/request.ts
index c47cb2d7..51e17db1 100644
--- a/config-ui/src/components/utils/request.ts
+++ b/config-ui/src/components/utils/request.ts
@@ -19,8 +19,10 @@
import type { AxiosRequestConfig } from 'axios'
import axios from 'axios'
+import { DEVLAKE_ENDPOINT } from '@/utils/config'
+
const instance = axios.create({
- baseURL: '/api'
+ baseURL: DEVLAKE_ENDPOINT
})
export type ReuqestConfig = {
@@ -32,7 +34,7 @@ export type ReuqestConfig = {
}
const request = (path: string, config?: ReuqestConfig) => {
- const { method = 'GET', data, timeout, headers, signal } = config || {}
+ const { method = 'get', data, timeout, headers, signal } = config || {}
const cancelTokenSource = axios.CancelToken.source()
const params: any = {
@@ -43,7 +45,7 @@ const request = (path: string, config?: ReuqestConfig) => {
cancelToken: cancelTokenSource?.token
}
- if (method === 'GET') {
+ if (['GET', 'get'].includes(method)) {
params.params = data
} else {
params.data = data
diff --git a/config-ui/src/images/no-data.svg b/config-ui/src/images/no-data.svg
new file mode 100644
index 00000000..a2150692
--- /dev/null
+++ b/config-ui/src/images/no-data.svg
@@ -0,0 +1,22 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<svg width="120" height="120" viewBox="0 0 120 120" fill="none"
xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M42.1688
52.1064H27.1313C25.4062 52.1064 24.0188 53.4939 24.0188 55.2189C24.0188 56.9439
25.4062 58.3314 27.1313 58.3314H42.1688C43.8937 58.3314 45.2812 56.9439 45.2812
55.2189C45.2812 53.5127 43.8937 52.1064 42.1688 52.1064ZM34.7625
68.2877H27.8062C28.3875 67.7252 28.7625 66.9377 28.7625 66.0564C28.7625 64.3314
27.375 62.9439 25.65 62.9439H10.6125C8.8875 62.9439 7.5 64.3314 7.5 66.0564C7.5
67.7814 8.8875 69.1689 10.6125 69.1689H17.5687C [...]
+ <path d="M34.4813 60.525H30.9563V65.6625H34.4813V60.525Z" fill="#70727F" />
+ <path d="M87.8812 28.125H38.7938C34.4625 28.125 30.9375 31.65 30.9375
35.9813V49.1625H34.4625V43.9688H92.2125V89.4H34.4625V77.0062H30.9375V92.9062H95.7188V35.9813C95.7375
31.65 92.2125 28.125 87.8812 28.125ZM73.9875 38.8687C72.7687 38.8687 71.7938
37.875 71.7938 36.675C71.7938 35.475 72.7875 34.4813 73.9875 34.4813C75.2062
34.4813 76.1813 35.475 76.1813 36.675C76.1813 37.875 75.2062 38.8687 73.9875
38.8687ZM81.0375 38.8687C79.8188 38.8687 78.8438 37.875 78.8438 36.675C78.8438
35.475 79 [...]
+</svg>
+
\ No newline at end of file
diff --git a/config-ui/src/index.d.ts b/config-ui/src/index.d.ts
index b24a60d7..a44ce29c 100644
--- a/config-ui/src/index.d.ts
+++ b/config-ui/src/index.d.ts
@@ -22,3 +22,8 @@ declare module '*.svg' {
const content: any
export default content
}
+
+declare module '*.png' {
+ const content: any
+ export default content
+}
diff --git a/config-ui/src/layouts/base/use-menu.ts
b/config-ui/src/layouts/base/use-menu.ts
index b997225e..540baf8a 100644
--- a/config-ui/src/layouts/base/use-menu.ts
+++ b/config-ui/src/layouts/base/use-menu.ts
@@ -44,6 +44,12 @@ export const useMenu = () => {
return useMemo(
() =>
[
+ {
+ key: 'project',
+ title: 'Projects',
+ icon: 'home',
+ path: '/projects'
+ },
{
key: 'connection',
title: 'Connections',
diff --git a/config-ui/src/index.d.ts b/config-ui/src/pages/index.ts
similarity index 88%
copy from config-ui/src/index.d.ts
copy to config-ui/src/pages/index.ts
index b24a60d7..327dbb15 100644
--- a/config-ui/src/index.d.ts
+++ b/config-ui/src/pages/index.ts
@@ -16,9 +16,4 @@
*
*/
-type ID = string | number
-
-declare module '*.svg' {
- const content: any
- export default content
-}
+export * from './project'
diff --git a/config-ui/src/components/loading/index.tsx
b/config-ui/src/pages/project/home/api.ts
similarity index 61%
copy from config-ui/src/components/loading/index.tsx
copy to config-ui/src/pages/project/home/api.ts
index 203f668c..11dc1365 100644
--- a/config-ui/src/components/loading/index.tsx
+++ b/config-ui/src/pages/project/home/api.ts
@@ -16,19 +16,28 @@
*
*/
-import React from 'react'
+import request from '@/components/utils/request'
-import * as S from './styled'
-
-interface Props {
- text?: string
+type GetProjectsParams = {
+ page: number
+ pageSize: number
}
-export const Loading = ({ text }: Props) => {
- return (
- <S.Wrapper>
- <S.Spin />
- <S.Text>{text}</S.Text>
- </S.Wrapper>
- )
+export const getProjects = (params: GetProjectsParams) =>
+ request('/projects', { data: params })
+
+type CreateProjectPayload = {
+ name: string
+ description: string
+ metrics: Array<{
+ pluginName: string
+ pluginOption: string
+ enable: boolean
+ }>
}
+
+export const createProject = (payload: CreateProjectPayload) =>
+ request('/projects', {
+ method: 'post',
+ data: payload
+ })
diff --git a/config-ui/src/pages/project/home/index.tsx
b/config-ui/src/pages/project/home/index.tsx
new file mode 100644
index 00000000..ec1683bb
--- /dev/null
+++ b/config-ui/src/pages/project/home/index.tsx
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React, { useMemo, useState } from 'react'
+import { Link, useHistory } from 'react-router-dom'
+import { Button, InputGroup, Checkbox, Intent } from '@blueprintjs/core'
+
+import NoData from '@/images/no-data.svg'
+import { PageHeader, Table, ColumnType, Dialog } from '@/components'
+
+import { useProject } from './use-project'
+import * as S from './styled'
+
+type ProjectItem = {
+ name: string
+}
+
+export const ProjectHomePage = () => {
+ const [isOpen, setIsOpen] = useState(false)
+ const [name, setName] = useState('')
+ const [enableDora, setEnableDora] = useState(true)
+
+ const history = useHistory()
+
+ const handleShowDialog = () => setIsOpen(true)
+ const handleHideDialog = () => setIsOpen(false)
+
+ const { loading, operating, projects, onSave } = useProject<ProjectItem>({
+ name,
+ enableDora,
+ onHideDialog: handleHideDialog
+ })
+
+ const columns = useMemo(
+ () =>
+ [
+ {
+ title: 'Project Name',
+ dataIndex: 'name',
+ key: 'name',
+ render: (name: string) => (
+ <Link to={`/projects/${name}`} style={{ color: '#292b3f' }}>
+ {name}
+ </Link>
+ )
+ },
+ {
+ title: '',
+ dataIndex: 'name' as const,
+ align: 'right' as const,
+ key: 'action',
+ render: (name: any) => (
+ <Button
+ outlined
+ intent={Intent.PRIMARY}
+ icon='cog'
+ onClick={() => history.push(`/projects/${name}`)}
+ />
+ )
+ }
+ ] as ColumnType<ProjectItem>,
+ []
+ )
+
+ return (
+ <PageHeader
+ breadcrumbs={[{ name: 'Projects', path: '/projects' }]}
+ extra={
+ projects.length ? (
+ <Button
+ intent={Intent.PRIMARY}
+ icon='plus'
+ text='New Project'
+ onClick={handleShowDialog}
+ />
+ ) : null
+ }
+ >
+ <S.Container>
+ {!projects.length ? (
+ <S.Inner>
+ <div className='logo'>
+ <img src={NoData} alt='' />
+ </div>
+ <div className='desc'>
+ <p>
+ Add new projects to see engineering metrics based on projects.
+ </p>
+ </div>
+ <div className='action'>
+ <Button
+ intent={Intent.PRIMARY}
+ icon='plus'
+ text='New Project'
+ onClick={handleShowDialog}
+ />
+ </div>
+ </S.Inner>
+ ) : (
+ <Table loading={loading} columns={columns} dataSource={projects} />
+ )}
+ <Dialog
+ isOpen={isOpen}
+ title='Create a New Project'
+ okText='Save'
+ okDisabled={!name}
+ okLoading={operating}
+ onCancel={handleHideDialog}
+ onOk={onSave}
+ >
+ <S.DialogWrapper>
+ <div className='block'>
+ <h3>Project Name *</h3>
+ <p>Give your project a unique name.</p>
+ <InputGroup
+ placeholder='Your Project Name'
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ />
+ </div>
+ <div className='block'>
+ <h3>Project Settings</h3>
+ <div className='checkbox'>
+ <Checkbox
+ label='Enable DORA Metrics'
+ checked={enableDora}
+ onChange={(e) =>
+ setEnableDora((e.target as HTMLInputElement).checked)
+ }
+ />
+ <p>
+ DORA metrics are four widely-adopted metrics for measuring
+ software delivery performance.
+ </p>
+ </div>
+ </div>
+ </S.DialogWrapper>
+ </Dialog>
+ </S.Container>
+ </PageHeader>
+ )
+}
diff --git a/config-ui/src/components/loading/styled.ts
b/config-ui/src/pages/project/home/styled.ts
similarity index 53%
copy from config-ui/src/components/loading/styled.ts
copy to config-ui/src/pages/project/home/styled.ts
index c47b98e5..5931d70c 100644
--- a/config-ui/src/components/loading/styled.ts
+++ b/config-ui/src/pages/project/home/styled.ts
@@ -16,36 +16,52 @@
*
*/
-import styled, { keyframes } from 'styled-components'
-
-const SpinKeyframes = keyframes({
- '0%': {
- transform: 'rotate(0deg)'
- },
- '100%': {
- transform: 'rotate(360deg)'
- }
-})
+import styled from '@emotion/styled'
-export const Wrapper = styled.div`
- display: inline-flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
+export const Container = styled.div`
+ background-color: #ffffff;
+ box-shadow: 0px 2.4px 4.8px -0.8px rgba(0, 0, 0, 0.1),
+ 0px 1.6px 8px rgba(0, 0, 0, 0.07);
+ border-radius: 4px;
`
-export const Spin = styled.div`
- width: 26px;
- height: 26px;
- border: 2px solid #7497f7;
- border-radius: 50%;
- border-right-color: transparent;
- animation-name: ${SpinKeyframes};
- animation-duration: 1s;
- animation-timing-function: linear;
- animation-iteration-count: infinite;
+export const Inner = styled.div`
+ padding: 24px;
+ text-align: center;
+
+ .logo {
+ img {
+ display: inline-block;
+ width: 120px;
+ height: 120px;
+ }
+ }
+
+ .desc {
+ margin: 20px 0;
+ }
`
-export const Text = styled.div`
- margin-top: 6px;
+export const DialogWrapper = styled.div`
+ .block + .block {
+ margin-top: 16px;
+ }
+
+ .bp3-input-group {
+ width: 386px;
+ }
+
+ .checkbox {
+ display: flex;
+ margin-top: 8px;
+
+ & > p {
+ margin: 0 0 0 16px;
+ }
+ }
+
+ h3 {
+ margin: 0;
+ padding: 0;
+ }
`
diff --git a/config-ui/src/pages/project/home/use-project.ts
b/config-ui/src/pages/project/home/use-project.ts
new file mode 100644
index 00000000..99b6f806
--- /dev/null
+++ b/config-ui/src/pages/project/home/use-project.ts
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useState, useEffect, useMemo } from 'react'
+
+import { operator } from '@/utils'
+
+import * as API from './api'
+
+interface Props {
+ name: string
+ enableDora: boolean
+ onHideDialog: () => void
+}
+
+export const useProject = <T>({ name, enableDora, onHideDialog }: Props) => {
+ const [loading, setLoading] = useState(false)
+ const [operating, setOperating] = useState(false)
+ const [projects, setProjects] = useState<T[]>([])
+
+ const getProjects = async () => {
+ setLoading(true)
+ try {
+ const res = await API.getProjects({ page: 1, pageSize: 100 })
+ setProjects(
+ res.projects.map((it: any) => ({
+ name: it.name
+ }))
+ )
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ getProjects()
+ }, [])
+
+ const handleSave = async () => {
+ const payload = {
+ name,
+ description: '',
+ metrics: [
+ {
+ pluginName: 'dora',
+ pluginOption: '',
+ enable: enableDora
+ }
+ ]
+ }
+
+ const [success] = await operator(() => API.createProject(payload), {
+ setOperating
+ })
+
+ if (success) {
+ onHideDialog()
+ getProjects()
+ }
+ }
+
+ return useMemo(
+ () => ({
+ loading,
+ operating,
+ projects,
+ onSave: handleSave
+ }),
+ [loading, operating, projects, name, enableDora]
+ )
+}
diff --git a/config-ui/src/index.d.ts b/config-ui/src/pages/project/index.ts
similarity index 88%
copy from config-ui/src/index.d.ts
copy to config-ui/src/pages/project/index.ts
index b24a60d7..761b787e 100644
--- a/config-ui/src/index.d.ts
+++ b/config-ui/src/pages/project/index.ts
@@ -16,9 +16,4 @@
*
*/
-type ID = string | number
-
-declare module '*.svg' {
- const content: any
- export default content
-}
+export * from './home'
diff --git a/config-ui/src/index.d.ts b/config-ui/src/utils/index.ts
similarity index 88%
copy from config-ui/src/index.d.ts
copy to config-ui/src/utils/index.ts
index b24a60d7..b48e27b5 100644
--- a/config-ui/src/index.d.ts
+++ b/config-ui/src/utils/index.ts
@@ -16,9 +16,4 @@
*
*/
-type ID = string | number
-
-declare module '*.svg' {
- const content: any
- export default content
-}
+export * from './operator'
diff --git a/config-ui/src/utils/operator.ts b/config-ui/src/utils/operator.ts
new file mode 100644
index 00000000..d1298b19
--- /dev/null
+++ b/config-ui/src/utils/operator.ts
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Intent } from '@blueprintjs/core'
+
+import { Toast } from '@/components'
+
+export type OperateConfig = {
+ setOperating?: (success: boolean) => void
+ formatReason?: (err: unknown) => string
+}
+
+export const operator = async (
+ request: () => Promise<unknown>,
+ config?: OperateConfig
+) => {
+ const { setOperating, formatReason } = config || {}
+
+ try {
+ setOperating?.(true)
+ const res = await request()
+ Toast.show({
+ intent: Intent.SUCCESS,
+ message: 'Operation successfully completed',
+ icon: 'endorsed'
+ })
+ return [true, res]
+ } catch (err) {
+ const reason = formatReason?.(err)
+ Toast.show({
+ intent: Intent.DANGER,
+ message: reason
+ ? `Operation failed. Reason: ${reason}`
+ : 'Operation failed.',
+ icon: 'error'
+ })
+
+ return [false]
+ } finally {
+ setOperating?.(false)
+ }
+}