diff --git a/pkg/app/web/src/api/applications.ts b/pkg/app/web/src/api/applications.ts index b9a2d1469e..0f3ae330c7 100644 --- a/pkg/app/web/src/api/applications.ts +++ b/pkg/app/web/src/api/applications.ts @@ -16,6 +16,8 @@ import { EnableApplicationResponse, UpdateApplicationRequest, UpdateApplicationResponse, + DeleteApplicationRequest, + DeleteApplicationResponse, } from "pipe/pkg/app/web/api_client/service_pb"; import { ApplicationGitPath } from "pipe/pkg/app/web/model/common_pb"; import { ApplicationGitRepository } from "pipe/pkg/app/web/model/common_pb"; @@ -137,8 +139,6 @@ export const updateApplication = async ({ }: Required): Promise< UpdateApplicationResponse.AsObject > => { - console.log(UpdateApplicationRequest); - const req = new UpdateApplicationRequest(); req.setApplicationId(applicationId); req.setName(name); @@ -161,3 +161,13 @@ export const updateApplication = async ({ req.setGitPath(appGitPath); return apiRequest(req, apiClient.updateApplication); }; + +export const deleteApplication = async ({ + applicationId, +}: DeleteApplicationRequest.AsObject): Promise< + DeleteApplicationResponse.AsObject +> => { + const req = new DeleteApplicationRequest(); + req.setApplicationId(applicationId); + return apiRequest(req, apiClient.deleteApplication); +}; diff --git a/pkg/app/web/src/components/application-list.tsx b/pkg/app/web/src/components/application-list.tsx index ccb6c0ee28..8b0826bed3 100644 --- a/pkg/app/web/src/components/application-list.tsx +++ b/pkg/app/web/src/components/application-list.tsx @@ -40,6 +40,8 @@ import { SyncStatusIcon } from "./sync-status-icon"; import { SealedSecretDialog } from "./sealed-secret-dialog"; import { APPLICATION_KIND_TEXT } from "../constants/application-kind"; import { setUpdateTargetId } from "../modules/update-application"; +import { DeleteApplicationDialog } from "./delete-application-dialog"; +import { setDeletingAppId } from "../modules/delete-application"; const useStyles = makeStyles((theme) => ({ root: { @@ -148,6 +150,13 @@ export const ApplicationList: FC = memo(function ApplicationList() { closeMenu(); }, [dispatch, actionTarget]); + const handleDeleteClick = useCallback(() => { + if (actionTarget) { + dispatch(setDeletingAppId(actionTarget.id)); + } + closeMenu(); + }, [actionTarget, dispatch]); + return (
@@ -268,14 +277,15 @@ export const ApplicationList: FC = memo(function ApplicationList() { }} > Edit + + Encrypt Secret + {actionTarget && actionTarget.disabled ? ( Enable ) : ( Disable )} - - Encrypt Secret - + Delete + +
); }); diff --git a/pkg/app/web/src/components/delete-application-dialog.stories.tsx b/pkg/app/web/src/components/delete-application-dialog.stories.tsx new file mode 100644 index 0000000000..e0a6c96565 --- /dev/null +++ b/pkg/app/web/src/components/delete-application-dialog.stories.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { createDecoratorRedux } from "../../.storybook/redux-decorator"; +import { dummyApplication } from "../__fixtures__/dummy-application"; +import { DeleteApplicationDialog } from "./delete-application-dialog"; + +export default { + title: "DeleteApplicationDialog", + component: DeleteApplicationDialog, + decorators: [ + createDecoratorRedux({ + applications: { + entities: { + [dummyApplication.id]: dummyApplication, + }, + ids: [dummyApplication.id], + }, + deleteApplication: { + applicationId: dummyApplication.id, + deleting: false, + }, + }), + ], +}; + +export const overview: React.FC = () => ; diff --git a/pkg/app/web/src/components/delete-application-dialog.tsx b/pkg/app/web/src/components/delete-application-dialog.tsx new file mode 100644 index 0000000000..3a57c5e415 --- /dev/null +++ b/pkg/app/web/src/components/delete-application-dialog.tsx @@ -0,0 +1,123 @@ +import React, { FC, memo, useCallback } from "react"; +import { + makeStyles, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + CircularProgress, +} from "@material-ui/core"; +import { useSelector, useDispatch, shallowEqual } from "react-redux"; +import { + selectById, + Application, + fetchApplications, +} from "../modules/applications"; +import { + clearDeletingApp, + deleteApplication, +} from "../modules/delete-application"; +import { AppState } from "../modules"; +import Alert from "@material-ui/lab/Alert"; +import { AppDispatch } from "../store"; +import { red } from "@material-ui/core/colors"; +import { UI_TEXT_CANCEL, UI_TEXT_DELETE } from "../constants/ui-text"; +import { useStyles as useButtonStyles } from "../styles/button"; +import { Skeleton } from "@material-ui/lab"; +import { addToast } from "../modules/toasts"; +import { DELETE_APPLICATION_SUCCESS } from "../constants/toast-text"; + +const useStyles = makeStyles((theme) => ({ + applicationName: { + color: theme.palette.text.primary, + fontWeight: theme.typography.fontWeightMedium, + }, + description: { + marginBottom: theme.spacing(2), + }, + deleteButton: { + color: theme.palette.getContrastText(red[400]), + backgroundColor: red[700], + "&:hover": { + backgroundColor: red[700], + }, + }, +})); + +const TITLE = "Delete Application"; +const ALERT_TEXT = "Are you sure you want to delete the application?"; + +export const DeleteApplicationDialog: FC = memo( + function DeleteApplicationDialog() { + const classes = useStyles(); + const buttonClasses = useButtonStyles(); + const dispatch = useDispatch(); + + const [application, isDeleting] = useSelector< + AppState, + [Application | undefined, boolean] + >( + (state) => [ + state.deleteApplication.applicationId + ? selectById( + state.applications, + state.deleteApplication.applicationId + ) + : undefined, + state.deleteApplication.deleting, + ], + shallowEqual + ); + + const handleDelete = useCallback(() => { + dispatch(deleteApplication()).then(() => { + dispatch(fetchApplications()); + dispatch( + addToast({ severity: "success", message: DELETE_APPLICATION_SUCCESS }) + ); + }); + }, [dispatch]); + + const handleCancel = useCallback(() => { + dispatch(clearDeletingApp()); + }, [dispatch]); + + return ( + + {TITLE} + + + {ALERT_TEXT} + + Name + + {application ? ( + application.name + ) : ( + + )} + + + + + + + + ); + } +); diff --git a/pkg/app/web/src/components/toasts.tsx b/pkg/app/web/src/components/toasts.tsx index bd5797268e..407fe5f116 100644 --- a/pkg/app/web/src/components/toasts.tsx +++ b/pkg/app/web/src/components/toasts.tsx @@ -38,6 +38,7 @@ export const Toasts: FC = () => { ; + disabling: Record; + fetchApplicationError: SerializedError | null; +}>({ + adding: false, + loading: false, + syncing: {}, + disabling: {}, + fetchApplicationError: null, +}); + +export type ApplicationsState = typeof initialState; + export const applicationsSlice = createSlice({ name: "applications", - initialState: applicationsAdapter.getInitialState<{ - adding: boolean; - loading: boolean; - syncing: Record; - disabling: Record; - fetchApplicationError: SerializedError | null; - }>({ - adding: false, - loading: false, - syncing: {}, - disabling: {}, - fetchApplicationError: null, - }), + initialState, reducers: {}, extraReducers: (builder) => { builder diff --git a/pkg/app/web/src/modules/delete-application.test.ts b/pkg/app/web/src/modules/delete-application.test.ts new file mode 100644 index 0000000000..5b5e1b6255 --- /dev/null +++ b/pkg/app/web/src/modules/delete-application.test.ts @@ -0,0 +1,19 @@ +import { + deleteApplicationSlice, + DeleteApplicationState, +} from "./delete-application"; + +const initialState: DeleteApplicationState = { + applicationId: null, + deleting: false, +}; + +describe("deleteApplicationSlice reducer", () => { + it("should return the initial state", () => { + expect( + deleteApplicationSlice.reducer(undefined, { + type: "TEST_ACTION", + }) + ).toEqual(initialState); + }); +}); diff --git a/pkg/app/web/src/modules/delete-application.ts b/pkg/app/web/src/modules/delete-application.ts new file mode 100644 index 0000000000..a5d582e441 --- /dev/null +++ b/pkg/app/web/src/modules/delete-application.ts @@ -0,0 +1,60 @@ +import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { AppState } from "."; +import * as applicationsAPI from "../api/applications"; + +export interface DeleteApplicationState { + applicationId: string | null; + deleting: boolean; +} + +const initialState: DeleteApplicationState = { + applicationId: null, + deleting: false, +}; + +export const deleteApplication = createAsyncThunk< + void, + void, + { + state: AppState; + } +>("applications/delete", async (_, thunkAPI) => { + const state = thunkAPI.getState(); + + if (state.deleteApplication.applicationId) { + await applicationsAPI.deleteApplication({ + applicationId: state.deleteApplication.applicationId, + }); + } +}); + +export const deleteApplicationSlice = createSlice({ + name: "deleteApplication", + initialState, + reducers: { + setDeletingAppId(state, action: PayloadAction) { + state.applicationId = action.payload; + }, + clearDeletingApp(state) { + state.applicationId = null; + }, + }, + extraReducers: (builder) => { + builder + .addCase(deleteApplication.pending, (state) => { + state.deleting = true; + }) + .addCase(deleteApplication.rejected, (state) => { + state.deleting = false; + }) + .addCase(deleteApplication.fulfilled, (state) => { + state.deleting = false; + state.applicationId = null; + }); + }, +}); + +export const { + clearDeletingApp, + setDeletingAppId, +} = deleteApplicationSlice.actions; diff --git a/pkg/app/web/src/modules/index.ts b/pkg/app/web/src/modules/index.ts index 7eca4db21c..572c5018cc 100644 --- a/pkg/app/web/src/modules/index.ts +++ b/pkg/app/web/src/modules/index.ts @@ -20,6 +20,7 @@ import { apiKeysSlice } from "./api-keys"; import { updateApplicationSlice } from "./update-application"; import { insightSlice } from "./insight"; import { deploymentFrequencySlice } from "./deployment-frequency"; +import { deleteApplicationSlice } from "./delete-application"; export const reducers = combineReducers({ deployments: deploymentsSlice.reducer, @@ -28,6 +29,7 @@ export const reducers = combineReducers({ applications: applicationsSlice.reducer, applicationFilterOptions: applicationFilterOptionsSlice.reducer, updateApplication: updateApplicationSlice.reducer, + deleteApplication: deleteApplicationSlice.reducer, stageLogs: stageLogsSlice.reducer, activeStage: activeStageSlice.reducer, pipeds: pipedsSlice.reducer,