diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74ef293d4d5..aef8af95e81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -78,7 +78,6 @@ "react-scripts": "5.0.0", "react-window": "^1.8.7", "recharts": "^2.1.4", - "redux-saga": "^1.1.3", "semver": "^7.3.5", "spacetime": "^7.4.0", "stream-browserify": "^3.0.0", @@ -4237,53 +4236,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@redux-saga/core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", - "integrity": "sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==", - "dependencies": { - "@babel/runtime": "^7.6.3", - "@redux-saga/deferred": "^1.1.2", - "@redux-saga/delay-p": "^1.1.2", - "@redux-saga/is": "^1.1.2", - "@redux-saga/symbols": "^1.1.2", - "@redux-saga/types": "^1.1.0", - "redux": "^4.0.4", - "typescript-tuple": "^2.2.1" - } - }, - "node_modules/@redux-saga/deferred": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.1.2.tgz", - "integrity": "sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==" - }, - "node_modules/@redux-saga/delay-p": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.1.2.tgz", - "integrity": "sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==", - "dependencies": { - "@redux-saga/symbols": "^1.1.2" - } - }, - "node_modules/@redux-saga/is": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.2.tgz", - "integrity": "sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==", - "dependencies": { - "@redux-saga/symbols": "^1.1.2", - "@redux-saga/types": "^1.1.0" - } - }, - "node_modules/@redux-saga/symbols": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.2.tgz", - "integrity": "sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==" - }, - "node_modules/@redux-saga/types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", - "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" - }, "node_modules/@reduxjs/toolkit": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz", @@ -32495,14 +32447,6 @@ "@babel/runtime": "^7.9.2" } }, - "node_modules/redux-saga": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", - "integrity": "sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==", - "dependencies": { - "@redux-saga/core": "^1.1.3" - } - }, "node_modules/redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", @@ -36466,27 +36410,6 @@ "node": ">=4.2.0" } }, - "node_modules/typescript-compare": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", - "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", - "dependencies": { - "typescript-logic": "^0.0.0" - } - }, - "node_modules/typescript-logic": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" - }, - "node_modules/typescript-tuple": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", - "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", - "dependencies": { - "typescript-compare": "^0.0.2" - } - }, "node_modules/uglify-js": { "version": "3.15.5", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.5.tgz", @@ -41652,53 +41575,6 @@ "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==", "dev": true }, - "@redux-saga/core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", - "integrity": "sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==", - "requires": { - "@babel/runtime": "^7.6.3", - "@redux-saga/deferred": "^1.1.2", - "@redux-saga/delay-p": "^1.1.2", - "@redux-saga/is": "^1.1.2", - "@redux-saga/symbols": "^1.1.2", - "@redux-saga/types": "^1.1.0", - "redux": "^4.0.4", - "typescript-tuple": "^2.2.1" - } - }, - "@redux-saga/deferred": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.1.2.tgz", - "integrity": "sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==" - }, - "@redux-saga/delay-p": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.1.2.tgz", - "integrity": "sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==", - "requires": { - "@redux-saga/symbols": "^1.1.2" - } - }, - "@redux-saga/is": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.2.tgz", - "integrity": "sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==", - "requires": { - "@redux-saga/symbols": "^1.1.2", - "@redux-saga/types": "^1.1.0" - } - }, - "@redux-saga/symbols": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.2.tgz", - "integrity": "sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==" - }, - "@redux-saga/types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", - "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" - }, "@reduxjs/toolkit": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.3.tgz", @@ -63125,14 +63001,6 @@ "@babel/runtime": "^7.9.2" } }, - "redux-saga": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", - "integrity": "sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==", - "requires": { - "@redux-saga/core": "^1.1.3" - } - }, "redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", @@ -66231,27 +66099,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz", "integrity": "sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==" }, - "typescript-compare": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", - "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", - "requires": { - "typescript-logic": "^0.0.0" - } - }, - "typescript-logic": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" - }, - "typescript-tuple": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", - "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", - "requires": { - "typescript-compare": "^0.0.2" - } - }, "uglify-js": { "version": "3.15.5", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index f15bd8b70d3..97481e2ab9a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,7 +73,6 @@ "react-scripts": "5.0.0", "react-window": "^1.8.7", "recharts": "^2.1.4", - "redux-saga": "^1.1.3", "semver": "^7.3.5", "spacetime": "^7.4.0", "stream-browserify": "^3.0.0", diff --git a/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx b/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx index 6b4826633e2..7022e8d793a 100644 --- a/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx +++ b/frontend/src/components/App/PluginSettings/PluginSettings.stories.tsx @@ -1,6 +1,4 @@ import { Meta, Story } from '@storybook/react/types-6-0'; -import React from 'react'; -import store from '../../../redux/stores/store'; import { TestContext } from '../../../test'; import { PluginSettingsPure, PluginSettingsPureProps } from './PluginSettings'; @@ -10,7 +8,7 @@ export default { } as Meta; const Template: Story = args => ( - + ); diff --git a/frontend/src/components/common/ActionsNotifier.tsx b/frontend/src/components/common/ActionsNotifier.tsx index 30db19ea823..7502606eca7 100644 --- a/frontend/src/components/common/ActionsNotifier.tsx +++ b/frontend/src/components/common/ActionsNotifier.tsx @@ -4,8 +4,7 @@ import { useSnackbar } from 'notistack'; import React from 'react'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { CLUSTER_ACTION_GRACE_PERIOD } from '../../lib/util'; -import { ClusterAction } from '../../redux/actions/actions'; +import { CLUSTER_ACTION_GRACE_PERIOD, ClusterAction } from '../../redux/clusterActionSlice'; import { useTypedSelector } from '../../redux/reducers/reducers'; export interface PureActionsNotifierProps { diff --git a/frontend/src/components/common/Resource/CreateButton.tsx b/frontend/src/components/common/Resource/CreateButton.tsx index c3dcffe8ab0..23eb6efe6d9 100644 --- a/frontend/src/components/common/Resource/CreateButton.tsx +++ b/frontend/src/components/common/Resource/CreateButton.tsx @@ -6,7 +6,7 @@ import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { apply } from '../../../lib/k8s/apiProxy'; import { KubeObjectInterface } from '../../../lib/k8s/cluster'; -import { clusterAction } from '../../../redux/actions/actions'; +import { clusterAction } from '../../../redux/clusterActionSlice'; import ActionButton from '../ActionButton'; import EditorDialog from './EditorDialog'; diff --git a/frontend/src/components/common/Resource/DeleteButton.tsx b/frontend/src/components/common/Resource/DeleteButton.tsx index d348424fb98..0e18ad7f00e 100644 --- a/frontend/src/components/common/Resource/DeleteButton.tsx +++ b/frontend/src/components/common/Resource/DeleteButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { KubeObject } from '../../../lib/k8s/cluster'; -import { CallbackActionOptions, clusterAction } from '../../../redux/actions/actions'; +import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice'; import ActionButton from '../ActionButton'; import { ConfirmDialog } from '../Dialog'; import AuthVisible from './AuthVisible'; diff --git a/frontend/src/components/common/Resource/EditButton.tsx b/frontend/src/components/common/Resource/EditButton.tsx index 3b5bcbb8941..f2dbed88c6c 100644 --- a/frontend/src/components/common/Resource/EditButton.tsx +++ b/frontend/src/components/common/Resource/EditButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { KubeObject, KubeObjectInterface } from '../../../lib/k8s/cluster'; -import { CallbackActionOptions, clusterAction } from '../../../redux/actions/actions'; +import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice'; import ActionButton from '../ActionButton'; import AuthVisible from './AuthVisible'; import EditorDialog from './EditorDialog'; diff --git a/frontend/src/components/common/Resource/RestartButton.tsx b/frontend/src/components/common/Resource/RestartButton.tsx index 8a17a366b73..2ba8b5db08b 100644 --- a/frontend/src/components/common/Resource/RestartButton.tsx +++ b/frontend/src/components/common/Resource/RestartButton.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { apply } from '../../../lib/k8s/apiProxy'; import { KubeObject } from '../../../lib/k8s/cluster'; -import { clusterAction } from '../../../redux/actions/actions'; +import { clusterAction } from '../../../redux/clusterActionSlice'; import AuthVisible from './AuthVisible'; interface RestartButtonProps { diff --git a/frontend/src/components/common/Resource/ScaleButton.tsx b/frontend/src/components/common/Resource/ScaleButton.tsx index b839d5cc5f3..4c53508d48c 100644 --- a/frontend/src/components/common/Resource/ScaleButton.tsx +++ b/frontend/src/components/common/Resource/ScaleButton.tsx @@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { KubeObject } from '../../../lib/k8s/cluster'; -import { CallbackActionOptions, clusterAction } from '../../../redux/actions/actions'; +import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice'; import { LightTooltip } from '../Tooltip'; import AuthVisible from './AuthVisible'; diff --git a/frontend/src/components/common/SimpleTable.stories.tsx b/frontend/src/components/common/SimpleTable.stories.tsx index 422f3f6ac24..96bf478a5d0 100644 --- a/frontend/src/components/common/SimpleTable.stories.tsx +++ b/frontend/src/components/common/SimpleTable.stories.tsx @@ -4,7 +4,6 @@ import { Meta, Story } from '@storybook/react/types-6-0'; import { useLocation } from 'react-router-dom'; import { KubeObjectInterface } from '../../lib/k8s/cluster'; import { useFilterFunc } from '../../lib/util'; -import store from '../../redux/stores/store'; import { TestContext, TestContextProps } from '../../test'; import SectionFilterHeader from './SectionFilterHeader'; import SimpleTable, { SimpleTableProps } from './SimpleTable'; @@ -33,7 +32,7 @@ function TestSimpleTable(props: SimpleTableProps) { } const Template: Story = args => ( - + ); diff --git a/frontend/src/components/cronjob/Details.tsx b/frontend/src/components/cronjob/Details.tsx index 31e9a5ed87f..a2ab36d242a 100644 --- a/frontend/src/components/cronjob/Details.tsx +++ b/frontend/src/components/cronjob/Details.tsx @@ -18,7 +18,7 @@ import { apply } from '../../lib/k8s/apiProxy'; import { KubeObjectInterface } from '../../lib/k8s/cluster'; import CronJob from '../../lib/k8s/cronJob'; import Job from '../../lib/k8s/job'; -import { clusterAction } from '../../redux/actions/actions'; +import { clusterAction } from '../../redux/clusterActionSlice'; import { ActionButton, ObjectEventList } from '../common'; import { MainInfoSection } from '../common/Resource'; import AuthVisible from '../common/Resource/AuthVisible'; diff --git a/frontend/src/components/node/Details.tsx b/frontend/src/components/node/Details.tsx index b2264e6fd71..1b0e107c79b 100644 --- a/frontend/src/components/node/Details.tsx +++ b/frontend/src/components/node/Details.tsx @@ -11,7 +11,7 @@ import { apply, drainNode, drainNodeStatus } from '../../lib/k8s/apiProxy'; import { KubeMetrics } from '../../lib/k8s/cluster'; import Node from '../../lib/k8s/node'; import { getCluster, timeAgo } from '../../lib/util'; -import { clusterAction } from '../../redux/actions/actions'; +import { clusterAction } from '../../redux/clusterActionSlice'; import { CpuCircularChart, MemoryCircularChart } from '../cluster/Charts'; import { ActionButton, ObjectEventList, StatusLabelProps } from '../common'; import { HeaderLabel, StatusLabel, ValueLabel } from '../common/Label'; diff --git a/frontend/src/redux/actions/actions.tsx b/frontend/src/redux/actions/actions.tsx index c5edd2de439..7921e5785ec 100644 --- a/frontend/src/redux/actions/actions.tsx +++ b/frontend/src/redux/actions/actions.tsx @@ -1,4 +1,3 @@ -import { OptionsObject as SnackbarProps } from 'notistack'; import { AppLogoType } from '../../components/App/AppLogo'; import { ClusterChooserType } from '../../components/cluster/ClusterChooser'; import { ResourceTableProps } from '../../components/common/Resource/ResourceTable'; @@ -11,9 +10,6 @@ import { UIState } from '../reducers/ui'; export const FILTER_RESET = 'FILTER_RESET'; export const FILTER_SET_NAMESPACE = 'FILTER_SET_NAMESPACE'; export const FILTER_SET_SEARCH = 'FILTER_SET_SEARCH'; -export const CLUSTER_ACTION = 'CLUSTER_ACTION'; -export const CLUSTER_ACTION_UPDATE = 'CLUSTER_ACTION_UPDATE'; -export const CLUSTER_ACTION_CANCEL = 'CLUSTER_ACTION_CANCEL'; export const UI_SIDEBAR_SET_SELECTED = 'UI_SIDEBAR_SET_SELECTED'; export const UI_SIDEBAR_SET_VISIBLE = 'UI_SIDEBAR_SET_VISIBLE'; export const UI_SIDEBAR_SET_ITEM = 'UI_SIDEBAR_SET_ITEM'; @@ -41,41 +37,6 @@ export interface BrandingProps { export const UI_SET_NOTIFICATIONS = 'UI_SET_NOTIFICATIONS'; export const UI_UPDATE_NOTIFICATION = 'UI_UPDATE_NOTIFICATION'; -export interface ClusterActionButton { - label: string; - actionToDispatch: string; -} - -export interface ClusterAction { - id: string; - key?: string; - message?: string; - url?: string; - buttons?: ClusterActionButton[]; - dismissSnackbar?: string; - snackbarProps?: SnackbarProps; -} - -export interface CallbackAction extends CallbackActionOptions { - callback: (...args: any[]) => void; -} - -export interface CallbackActionOptions { - startUrl?: string; - cancelUrl?: string; - errorUrl?: string; - successUrl?: string; - startMessage?: string; - cancelledMessage?: string; - errorMessage?: string; - successMessage?: string; - startOptions?: SnackbarProps; - cancelledOptions?: SnackbarProps; - successOptions?: SnackbarProps; - errorOptions?: SnackbarProps; - cancelCallback?: (...args: any[]) => void; -} - export interface Action { type: string; [propName: string]: any; @@ -110,17 +71,6 @@ export function resetFilter() { return { type: FILTER_RESET }; } -export function clusterAction( - callback: CallbackAction['callback'], - actionOptions: CallbackActionOptions = {} -) { - return { type: CLUSTER_ACTION, callback, ...actionOptions }; -} - -export function updateClusterAction(actionOptions: ClusterAction) { - return { type: CLUSTER_ACTION_UPDATE, ...actionOptions }; -} - export function setSidebarSelected(selected: string | null, sidebar: string | null = '') { return { type: UI_SIDEBAR_SET_SELECTED, selected: { item: selected, sidebar } }; } diff --git a/frontend/src/redux/clusterActionSlice.ts b/frontend/src/redux/clusterActionSlice.ts new file mode 100644 index 00000000000..841f6c7c252 --- /dev/null +++ b/frontend/src/redux/clusterActionSlice.ts @@ -0,0 +1,271 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import i18next from 'i18next'; +import { OptionsObject as SnackbarProps } from 'notistack'; + +/** + * See components/common/ActionsNotifier.tsx for a user of cluster actions. + */ + +/** + * A button to display on the action. + */ +interface ClusterActionButton { + /** + * The label to display on the button. + */ + label: string; + /** + * The action to dispatch when the button is clicked. + */ + actionToDispatch: string; +} + +export interface ClusterAction { + /** + * A unique id for the action. + */ + id: string; + /** + * A unique key for the action. + */ + key?: string; + /** + * The message to display on the action. + */ + message?: string; + /** + * The url to navigate to when the action is complete. + */ + url?: string; + /** + * The buttons to display on the action. + */ + buttons?: ClusterActionButton[]; + /** + * The id of the snackbar to dismiss. + */ + dismissSnackbar?: string; + /** + * The props to pass to the snackbar. Could be { variant: 'success' }. + */ + snackbarProps?: SnackbarProps; +} + +interface CallbackAction extends CallbackActionOptions { + callback: (...args: any[]) => void; +} + +export interface CallbackActionOptions { + startUrl?: string; + cancelUrl?: string; + errorUrl?: string; + successUrl?: string; + startMessage?: string; + cancelledMessage?: string; + errorMessage?: string; + successMessage?: string; + startOptions?: SnackbarProps; + cancelledOptions?: SnackbarProps; + successOptions?: SnackbarProps; + errorOptions?: SnackbarProps; + cancelCallback?: (...args: any[]) => void; +} + +/** + * A unique key for each action. + */ +interface ClusterState { + [id: string]: ClusterAction; +} + +export const initialState: ClusterState = {}; + +const controllers = new Map(); + +/** The amount of time to wait before allowing the action to be cancelled. */ +export const CLUSTER_ACTION_GRACE_PERIOD = 5000; + +/** + * Uses the callback to execute an action and dispatches actions + * to update the UI based on the result. + * + * Gives the user 5 seconds to cancel the action before executing it. + */ +export const executeClusterAction = createAsyncThunk( + 'clusterAction/execute', + async (action: CallbackAction, { dispatch, rejectWithValue }) => { + const actionKey = (new Date().getTime() + Math.random()).toString(); + + /** + * See the handler for clusterAction/cancel/ in extraReducers below. + */ + const uniqueCancelActionType = 'clusterAction/cancel/' + actionKey; + + const controller = new AbortController(); + controllers.set(actionKey, controller); + + const { + callback, + startUrl, + cancelUrl, + successUrl, + startMessage, + cancelledMessage, + errorMessage, + errorUrl, + successMessage, + cancelCallback, + startOptions = {}, + cancelledOptions = {}, + successOptions = { variant: 'success' }, + errorOptions = { variant: 'error' }, + } = action; + + // Dispatch actions for all the states. + + function dispatchStart() { + dispatch( + updateClusterAction({ + id: actionKey, + message: startMessage, + url: startUrl, + buttons: [ + { + label: i18next.t('frequent|Cancel'), + actionToDispatch: uniqueCancelActionType, + }, + ], + snackbarProps: startOptions, + }) + ); + } + function dispatchSuccess() { + dispatch( + updateClusterAction({ + buttons: undefined, + dismissSnackbar: actionKey, + id: actionKey, + message: successMessage, + snackbarProps: successOptions, + url: successUrl, + }) + ); + } + function dispatchCancelled() { + dispatch( + updateClusterAction({ + buttons: undefined, + id: actionKey, + message: cancelledMessage, + dismissSnackbar: actionKey, + url: cancelUrl, + snackbarProps: cancelledOptions, + }) + ); + } + function dispatchError() { + dispatch( + updateClusterAction({ + buttons: undefined, + dismissSnackbar: actionKey, + id: actionKey, + message: errorMessage, + snackbarProps: errorOptions, + url: errorUrl, + }) + ); + } + function dispatchClose() { + dispatch( + updateClusterAction({ + id: actionKey, + }) + ); + } + + async function cancellableActionLogic() { + dispatchStart(); + try { + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(resolve, CLUSTER_ACTION_GRACE_PERIOD); + controller.signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject('Action cancelled'); + }); + }); + + if (controller.signal.aborted) { + return rejectWithValue('Action cancelled'); + } + callback(); + dispatchSuccess(); + } catch (err) { + if ((err as Error).message === 'Action cancelled' || controller.signal.aborted) { + dispatchCancelled(); + if (cancelCallback) { + try { + cancelCallback(); + } catch (err) { + console.error(err); + } + } + } else { + dispatchError(); + } + } finally { + controllers.delete(actionKey); + setTimeout(dispatchClose, 3000); + } + } + + await cancellableActionLogic(); + return actionKey; + } +); + +const clusterActionSlice = createSlice({ + name: 'clusterAction', + initialState, + reducers: { + updateClusterAction: ( + state, + action: PayloadAction & { id: string }> + ) => { + const { id, ...actionOptions } = action.payload; + if (Object.keys(actionOptions).length === 0) { + delete state[id]; + } else { + const { snackbarProps, ...otherActionOptions } = actionOptions; + state[id] = { ...state[id], ...otherActionOptions }; + state[id].snackbarProps = snackbarProps as any; // any because snackbarProps is problematic to ts + } + }, + }, + + extraReducers: builder => { + builder.addMatcher( + action => action.type.startsWith('clusterAction/cancel/'), + (state, action) => { + const actionKey = action.type.split('clusterAction/cancel/')[1]; + const controller = controllers.get(actionKey); + + if (controller) { + controller.abort(); + controllers.delete(actionKey); + } + delete state[actionKey]; + } + ); + }, +}); + +export const { updateClusterAction } = clusterActionSlice.actions; + +export function clusterAction( + callback: CallbackAction['callback'], + actionOptions: CallbackActionOptions = {} +) { + return executeClusterAction({ callback, ...actionOptions }); +} + +export default clusterActionSlice.reducer; diff --git a/frontend/src/redux/reducers/clusterAction.tsx b/frontend/src/redux/reducers/clusterAction.tsx deleted file mode 100644 index 39251aaa828..00000000000 --- a/frontend/src/redux/reducers/clusterAction.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import _ from 'lodash'; -import { Action, CLUSTER_ACTION_UPDATE, ClusterAction } from '../actions/actions'; - -interface ClusterState { - [id: string]: ClusterAction; // todo: Complete the type. -} - -export const INITIAL_STATE: ClusterState = { - // id: { message, ... } . See the ActionsNotifier. -}; - -function cluster(clusterActions = _.cloneDeep(INITIAL_STATE), action: ClusterAction & Action) { - const { type, id, ...actionOptions } = action; - const newState = { ..._.cloneDeep(clusterActions) }; - switch (type) { - case CLUSTER_ACTION_UPDATE: - if (_.isEmpty(actionOptions)) { - delete newState[id]; - } else { - newState[id] = { ...(action as ClusterAction) }; - } - break; - - default: - break; - } - - return newState; -} - -export default cluster; diff --git a/frontend/src/redux/reducers/reducers.tsx b/frontend/src/redux/reducers/reducers.tsx index a779b668346..09f3f4d6acf 100644 --- a/frontend/src/redux/reducers/reducers.tsx +++ b/frontend/src/redux/reducers/reducers.tsx @@ -2,9 +2,9 @@ import { TypedUseSelectorHook, useSelector } from 'react-redux'; import { combineReducers } from 'redux'; import pluginsReducer from '../../plugin/pluginsSlice'; import actionButtons from '../actionButtonsSlice'; +import clusterAction from '../clusterActionSlice'; import configReducer from '../configSlice'; import detailsViewSectionsSlice from '../detailsViewSectionsSlice'; -import clusterAction from './clusterAction'; import filter from './filter'; import uiReducer from './ui'; diff --git a/frontend/src/redux/sagas/sagas.tsx b/frontend/src/redux/sagas/sagas.tsx deleted file mode 100644 index 767eaf60771..00000000000 --- a/frontend/src/redux/sagas/sagas.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import i18next from 'i18next'; -import { all, call, cancelled, delay, put, race, take, takeEvery } from 'redux-saga/effects'; -import { CLUSTER_ACTION_GRACE_PERIOD } from '../../lib/util'; -import { - Action, - CallbackAction, - CLUSTER_ACTION, - CLUSTER_ACTION_CANCEL, - ClusterAction, - updateClusterAction, -} from '../actions/actions'; - -function newActionKey() { - return (new Date().getTime() + Math.random()).toString(); -} - -function* watchClusterAction() { - yield takeEvery(CLUSTER_ACTION, clusterActionWithCancellation); -} - -function* clusterActionWithCancellation(action: Action & CallbackAction) { - const actionKey = newActionKey(); - // We create a unique action type so we're sure that the cancellation - // is done on the right actions. - const uniqueCancelAction = CLUSTER_ACTION_CANCEL + actionKey; - yield race({ - task: call(doClusterAction, action, actionKey, uniqueCancelAction), - cancel: take(uniqueCancelAction), - }); -} - -function* doClusterAction(action: CallbackAction, actionKey: string, uniqueCancelAction: string) { - const { - callback, - startUrl, - cancelUrl, - successUrl, - startMessage, - cancelledMessage, - errorMessage, - errorUrl, - successMessage, - cancelCallback, - startOptions = {}, - cancelledOptions = {}, - successOptions = { variant: 'success' }, - errorOptions = { variant: 'error' }, - } = action; - - try { - yield put( - updateClusterAction({ - key: actionKey, - id: actionKey, - message: startMessage, - url: startUrl, - buttons: [ - { - label: i18next.t('frequent|Cancel'), - actionToDispatch: uniqueCancelAction, - }, - ], - snackbarProps: startOptions, - }) - ); - - yield delay(CLUSTER_ACTION_GRACE_PERIOD); - } finally { - // Check if it's been cancelled. - // @ts-ignore: TS7057 - // @todo: Seems to be an bad-typing issue... this code is idiomatic and did check before. - if (yield cancelled()) { - yield put( - updateClusterAction({ - id: actionKey, - message: cancelledMessage, - dismissSnackbar: actionKey, - url: cancelUrl, - snackbarProps: cancelledOptions, - }) - ); - if (cancelCallback) { - yield call(cancelCallback); - } - } else { - // Actually perform the action. This part is no longer cancellable, - // so it's here instead of within the try block. - let success = false; - try { - yield call(callback); - success = true; - } catch (err) { - // @todo: It'd be interesting to make the errorMessage a callback and - // pass it the error when using the errorMessage below. - } - - let clusterAction: ClusterAction; - - if (success) { - clusterAction = { - id: actionKey, - url: successUrl, - dismissSnackbar: actionKey, - message: successMessage, - snackbarProps: successOptions, - }; - } else { - clusterAction = { - id: actionKey, - url: errorUrl, - dismissSnackbar: actionKey, - message: errorMessage, - snackbarProps: errorOptions, - }; - } - - yield put(updateClusterAction(clusterAction)); - } - - // Reset state if no other deletion happens - const { timeout } = yield race({ - newAction: take(CLUSTER_ACTION), - timeout: delay(3000), - }); - - // Reset the cluster action - if (timeout) { - yield put( - updateClusterAction({ - id: actionKey, - }) - ); - } - } -} - -export default function* rootSaga() { - yield all([watchClusterAction()]); -} diff --git a/frontend/src/redux/stores/store.tsx b/frontend/src/redux/stores/store.tsx index 5ce261b8d81..c0aff594c23 100644 --- a/frontend/src/redux/stores/store.tsx +++ b/frontend/src/redux/stores/store.tsx @@ -1,32 +1,26 @@ import { configureStore } from '@reduxjs/toolkit'; -import createSagaMiddleware from 'redux-saga'; +import { initialState as CLUSTER_ACTIONS_INITIAL_STATE } from '../clusterActionSlice'; import { initialState as CONFIG_INITIAL_STATE } from '../configSlice'; import { INITIAL_STATE as FILTER_INITIAL_STATE } from '../reducers/filter'; import reducers from '../reducers/reducers'; import { INITIAL_STATE as UI_INITIAL_STATE } from '../reducers/ui'; -import rootSaga from '../sagas/sagas'; - -const initialState = { - filter: FILTER_INITIAL_STATE, - ui: UI_INITIAL_STATE, - config: CONFIG_INITIAL_STATE, -}; - -const sagaMiddleware = createSagaMiddleware(); const store = configureStore({ reducer: reducers, - preloadedState: initialState, + preloadedState: { + filter: FILTER_INITIAL_STATE, + ui: UI_INITIAL_STATE, + config: CONFIG_INITIAL_STATE, + clusterAction: CLUSTER_ACTIONS_INITIAL_STATE, + }, middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false, - thunk: false, - }).concat(sagaMiddleware), + thunk: true, + }), }); export default store; -sagaMiddleware.run(rootSaga); - export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType;