diff --git a/webapp/javascript/components/Modals/ConfirmDelete/index.tsx b/webapp/javascript/components/Modals/ConfirmDelete/index.tsx index 4e6a225abf..f8b9ecf4f9 100644 --- a/webapp/javascript/components/Modals/ConfirmDelete/index.tsx +++ b/webapp/javascript/components/Modals/ConfirmDelete/index.tsx @@ -1,11 +1,40 @@ -import ShowModal from '@webapp/ui/Modals'; +import ShowModal, { ShowModalParams } from '@webapp/ui/Modals'; -function confirmDelete(object: string, onConfirm: () => void) { +interface ConfirmDeleteProps { + objectType: string; + objectName: string; + onConfirm: () => void; + warningMsg?: string; + withConfirmationInput?: boolean; +} + +function confirmDelete({ + objectName, + objectType, + onConfirm, + withConfirmationInput, + warningMsg, +}: ConfirmDeleteProps) { + const confirmationInputProps: Partial = withConfirmationInput + ? { + input: 'text' as ShowModalParams['input'], + inputLabel: `To confirm deletion enter ${objectType} name below.`, + inputPlaceholder: objectName, + inputValidator: (value) => + value === objectName ? null : 'Name does not match', + } + : {}; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises ShowModal({ - title: `Are you sure you want to delete ${object}?`, + title: `Delete ${objectType}`, + html: `Are you sure you want to delete
${objectName} ?${ + warningMsg ? `

${warningMsg}` : '' + }`, confirmButtonText: 'Delete', type: 'danger', onConfirm, + ...confirmationInputProps, }); } diff --git a/webapp/javascript/components/Settings/APIKeys/index.tsx b/webapp/javascript/components/Settings/APIKeys/index.tsx index bde7445aab..b6b0a63b36 100644 --- a/webapp/javascript/components/Settings/APIKeys/index.tsx +++ b/webapp/javascript/components/Settings/APIKeys/index.tsx @@ -24,8 +24,10 @@ const getBodyRows = ( const now = new Date(); const handleDeleteClick = (key: APIKey) => { - confirmDelete('this key', () => { - onDelete(key); + confirmDelete({ + objectType: 'key', + objectName: key.name, + onConfirm: () => onDelete(key), }); }; diff --git a/webapp/javascript/components/Settings/Apps/AppTableItem.module.css b/webapp/javascript/components/Settings/Apps/AppTableItem.module.css new file mode 100644 index 0000000000..52588664e8 --- /dev/null +++ b/webapp/javascript/components/Settings/Apps/AppTableItem.module.css @@ -0,0 +1,9 @@ +.actions { + display: flex; + flex-direction: row; + justify-content: right; +} + +.loadingIcon { + margin-right: 8px; +} diff --git a/webapp/javascript/components/Settings/Apps/Apps.module.css b/webapp/javascript/components/Settings/Apps/Apps.module.css new file mode 100644 index 0000000000..6496643173 --- /dev/null +++ b/webapp/javascript/components/Settings/Apps/Apps.module.css @@ -0,0 +1,24 @@ +.searchContainer { + display: flex; + flex-direction: column; +} + +.tabNameContrainer { + display: flex; + align-items: center; + gap: 10px; +} + +.searchContainer button { + padding: 5px 20px; +} + +.appsTable { + margin: 20px 0; + width: 100%; +} + +.appsTableEmptyMessage { + text-align: center; + color: var(--ps-ui-foreground-text); +} diff --git a/webapp/javascript/components/Settings/Apps/getAppTableRows.tsx b/webapp/javascript/components/Settings/Apps/getAppTableRows.tsx new file mode 100644 index 0000000000..a4bb504994 --- /dev/null +++ b/webapp/javascript/components/Settings/Apps/getAppTableRows.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; + +import Button from '@webapp/ui/Button'; +import Icon from '@webapp/ui/Icon'; +import { App, Apps } from '@webapp/models/app'; +import type { BodyRow } from '@webapp/ui/Table'; +import confirmDelete from '@webapp/components/Modals/ConfirmDelete'; +import LoadingSpinner from '@webapp/ui/LoadingSpinner'; + +import styles from './AppTableItem.module.css'; + +interface DeleteButtorProps { + onDelete: (app: App) => void; + isLoading: boolean; + app: App; +} + +function DeleteButton(props: DeleteButtorProps) { + const { onDelete, app, isLoading } = props; + + const handleDeleteClick = () => { + confirmDelete({ + objectName: app.name, + objectType: 'app', + withConfirmationInput: true, + warningMsg: `Note: This action can take up to ~15 minutes depending on the size of your application and wont' be reflected in the UI until it is complete.`, + onConfirm: () => onDelete(app), + }); + }; + + return isLoading ? ( + + ) : ( + + ); +} + +export function getAppTableRows( + displayApps: Apps, + appsInProcessing: string[], + handleDeleteApp: (app: App) => void +): BodyRow[] { + const bodyRows = displayApps.reduce((acc, app) => { + const { name } = app; + + const row = { + cells: [ + { value: name }, + { + value: ( +
+ +
+ ), + align: 'center', + }, + ], + }; + + acc.push(row); + return acc; + }, [] as BodyRow[]); + + return bodyRows; +} diff --git a/webapp/javascript/components/Settings/Apps/index.tsx b/webapp/javascript/components/Settings/Apps/index.tsx new file mode 100644 index 0000000000..cebeec32e1 --- /dev/null +++ b/webapp/javascript/components/Settings/Apps/index.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; +import cl from 'classnames'; +import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; +import { + selectApps, + reloadApps, + deleteApp, + selectIsLoadingApps, +} from '@webapp/redux/reducers/settings'; +import { addNotification } from '@webapp/redux/reducers/notifications'; +import { type App } from '@webapp/models/app'; +import Input from '@webapp/ui/Input'; +import TableUI from '@webapp/ui/Table'; +import LoadingSpinner from '@webapp/ui/LoadingSpinner'; +import { getAppTableRows } from './getAppTableRows'; + +import appsStyles from './Apps.module.css'; +import tableStyles from '../SettingsTable.module.scss'; + +const headRow = [ + { name: '', label: 'Name', sortable: 0 }, + { name: '', label: '', sortable: 0 }, +]; + +function Apps() { + const dispatch = useAppDispatch(); + const apps = useAppSelector(selectApps); + const isLoading = useAppSelector(selectIsLoadingApps); + const [search, setSearchField] = useState(''); + const [appsInProcessing, setAppsInProcessing] = useState([]); + const [deletedApps, setDeletedApps] = useState([]); + + useEffect(() => { + dispatch(reloadApps()); + }, []); + + const displayApps = + (apps && + apps.filter( + (x) => + x.name.toLowerCase().indexOf(search.toLowerCase()) !== -1 && + !deletedApps.includes(x.name) + )) || + []; + + const handleDeleteApp = (app: App) => { + setAppsInProcessing([...appsInProcessing, app.name]); + dispatch(deleteApp(app)) + .unwrap() + .then(() => { + setAppsInProcessing(appsInProcessing.filter((x) => x !== app.name)); + setDeletedApps([...deletedApps, app.name]); + dispatch( + addNotification({ + type: 'success', + title: 'App has been deleted', + message: `App ${app.name} has been successfully deleted`, + }) + ); + }) + .catch(() => { + setDeletedApps(deletedApps.filter((x) => x !== app.name)); + setAppsInProcessing(appsInProcessing.filter((x) => x !== app.name)); + }); + }; + + const tableBodyProps = + displayApps.length > 0 + ? { + bodyRows: getAppTableRows( + displayApps, + appsInProcessing, + handleDeleteApp + ), + type: 'filled' as const, + } + : { + type: 'not-filled' as const, + value: 'The list is empty', + bodyClassName: appsStyles.appsTableEmptyMessage, + }; + + return ( + <> +

+ Apps + {isLoading && !!apps ? : null} +

+
+ setSearchField(v.target.value)} + name="Search app input" + /> +
+ + + ); +} + +export default Apps; diff --git a/webapp/javascript/components/Settings/Users/getUserTableRows.tsx b/webapp/javascript/components/Settings/Users/getUserTableRows.tsx index 3dbb4c5c1a..4d35e6a192 100644 --- a/webapp/javascript/components/Settings/Users/getUserTableRows.tsx +++ b/webapp/javascript/components/Settings/Users/getUserTableRows.tsx @@ -56,8 +56,10 @@ function DeleteButton(props: { onDelete: (user: User) => void; user: User }) { const { onDelete, user } = props; const handleDeleteClick = () => { - confirmDelete('this user', () => { - onDelete(user); + confirmDelete({ + objectName: user.name, + objectType: 'user', + onConfirm: () => onDelete(user), }); }; diff --git a/webapp/javascript/components/Settings/index.tsx b/webapp/javascript/components/Settings/index.tsx index 8ddf97bfb1..d453ab8965 100644 --- a/webapp/javascript/components/Settings/index.tsx +++ b/webapp/javascript/components/Settings/index.tsx @@ -8,6 +8,7 @@ import { faKey } from '@fortawesome/free-solid-svg-icons/faKey'; import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'; import { faSlidersH } from '@fortawesome/free-solid-svg-icons/faSlidersH'; import { faUserAlt } from '@fortawesome/free-solid-svg-icons/faUserAlt'; +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons/faNetworkWired'; import cx from 'classnames'; import { useAppSelector } from '@webapp/redux/hooks'; import { selectCurrentUser } from '@webapp/redux/reducers/user'; @@ -16,6 +17,7 @@ import PageTitle from '@webapp/components/PageTitle'; import Preferences from './Preferences'; import Security from './Security'; import Users from './Users'; +import Apps from './Apps'; import ApiKeys from './APIKeys'; import styles from './Settings.module.css'; @@ -93,6 +95,20 @@ function Settings() { API keys +
  • + + cx({ + [styles.navLink]: true, + [styles.navLinkActive]: isActive, + }) + } + data-testid="settings-appstab" + > + Apps + +
  • ) : null} @@ -136,6 +152,12 @@ function Settings() { + + <> + + + + diff --git a/webapp/javascript/models/app.ts b/webapp/javascript/models/app.ts new file mode 100644 index 0000000000..66296b4987 --- /dev/null +++ b/webapp/javascript/models/app.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const appModel = z.object({ + name: z.string(), +}); + +export const appsModel = z.array(appModel); + +export type Apps = z.infer; +export type App = z.infer; diff --git a/webapp/javascript/redux/reducers/settings.ts b/webapp/javascript/redux/reducers/settings.ts index 614bc5a710..a01f050545 100644 --- a/webapp/javascript/redux/reducers/settings.ts +++ b/webapp/javascript/redux/reducers/settings.ts @@ -1,6 +1,7 @@ import { createSlice, combineReducers } from '@reduxjs/toolkit'; import { Users, type User } from '@webapp/models/users'; import { APIKey, APIKeys } from '@webapp/models/apikeys'; +import { Apps, type App } from '@webapp/models/app'; import { fetchUsers, @@ -15,25 +16,33 @@ import { createAPIKey as createAPIKeyAPI, deleteAPIKey as deleteAPIKeyAPI, } from '@webapp/services/apiKeys'; +import { fetchApps, deleteApp as deleteAppAPI } from '@webapp/services/apps'; import type { RootState } from '@webapp/redux/store'; import { addNotification } from './notifications'; import { createAsyncThunk } from '../async-thunk'; -type UsersState = { - type: 'pristine' | 'loading' | 'loaded' | 'failed'; - data?: Users; +enum FetchStatus { + pristine = 'pristine', + loading = 'loading', + loaded = 'loaded', + failed = 'failed', +} +type DataWithStatus = { type: FetchStatus; data?: T }; + +const usersInitialState: DataWithStatus = { + type: FetchStatus.pristine, + data: undefined, }; -const usersInitialState: UsersState = { - type: 'pristine', +const apiKeysInitialState: DataWithStatus = { + type: FetchStatus.pristine, data: undefined, }; -type ApiKeysState = { - type: 'pristine' | 'loading' | 'loaded' | 'failed'; - data?: APIKeys; +const appsInitialState: DataWithStatus = { + type: FetchStatus.pristine, + data: undefined, }; -const apiKeysInitialState: ApiKeysState = { type: 'pristine', data: undefined }; export const reloadApiKeys = createAsyncThunk( 'newRoot/reloadAPIKeys', @@ -76,6 +85,28 @@ export const reloadUsers = createAsyncThunk( } ); +export const reloadApps = createAsyncThunk( + 'newRoot/reloadApps', + async (_, thunkAPI) => { + const res = await fetchApps(); + + if (res.isOk) { + return Promise.resolve(res.value); + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to load apps', + message: res.error.message, + }) + ); + + return Promise.reject(res.error); + } +); + export const enableUser = createAsyncThunk( 'newRoot/enableUser', async (user: User, thunkAPI) => { @@ -231,20 +262,44 @@ export const deleteAPIKey = createAsyncThunk( } ); +export const deleteApp = createAsyncThunk( + 'newRoot/deleteApp', + async (app: App, thunkAPI) => { + const res = await deleteAppAPI({ name: app.name }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + thunkAPI.dispatch(reloadApps()); + + if (res.isOk) { + return Promise.resolve(true); + } + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to delete app', + message: res.error.message, + }) + ); + return Promise.reject(res.error); + } +); + export const usersSlice = createSlice({ name: 'users', initialState: usersInitialState, reducers: {}, extraReducers: (builder) => { builder.addCase(reloadUsers.fulfilled, (state, action) => { - return { type: 'loaded', data: action.payload }; + return { type: FetchStatus.loaded, data: action.payload }; }); builder.addCase(reloadUsers.pending, (state) => { - return { type: 'loading', data: state.data }; + return { type: FetchStatus.loading, data: state.data }; }); builder.addCase(reloadUsers.rejected, (state) => { - return { type: 'failed', data: state.data }; + return { type: FetchStatus.failed, data: state.data }; }); }, }); @@ -255,13 +310,30 @@ export const apiKeysSlice = createSlice({ reducers: {}, extraReducers: (builder) => { builder.addCase(reloadApiKeys.fulfilled, (_, action) => { - return { type: 'loaded', data: action.payload }; + return { type: FetchStatus.loaded, data: action.payload }; }); builder.addCase(reloadApiKeys.pending, (state) => { - return { type: 'loading', data: state.data }; + return { type: FetchStatus.loading, data: state.data }; }); builder.addCase(reloadApiKeys.rejected, (state) => { - return { type: 'failed', data: state.data }; + return { type: FetchStatus.failed, data: state.data }; + }); + }, +}); + +export const appsSlice = createSlice({ + name: 'apps', + initialState: appsInitialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(reloadApps.fulfilled, (_, action) => { + return { type: FetchStatus.loaded, data: action.payload }; + }); + builder.addCase(reloadApps.pending, (state) => { + return { type: FetchStatus.loading, data: state.data }; + }); + builder.addCase(reloadApps.rejected, (state) => { + return { type: FetchStatus.failed, data: state.data }; }); }, }); @@ -274,7 +346,14 @@ export const selectUsers = (state: RootState) => state.settings.users.data; export const apiKeysState = (state: RootState) => state.settings.apiKeys; export const selectAPIKeys = (state: RootState) => state.settings.apiKeys.data; +export const appsState = (state: RootState) => state.settings.apps; +export const selectApps = (state: RootState) => state.settings.apps.data; +export const selectIsLoadingApps = (state: RootState) => { + return state.settings.apps.type === FetchStatus.loading; +}; + export default combineReducers({ users: usersSlice.reducer, apiKeys: apiKeysSlice.reducer, + apps: appsSlice.reducer, }); diff --git a/webapp/javascript/services/apps.ts b/webapp/javascript/services/apps.ts new file mode 100644 index 0000000000..c58a86afed --- /dev/null +++ b/webapp/javascript/services/apps.ts @@ -0,0 +1,37 @@ +import { Apps, appsModel } from '@webapp/models/app'; +import { Result } from '@webapp/util/fp'; +import type { ZodError } from 'zod'; +import type { RequestError } from './base'; +import { parseResponse, request } from './base'; + +export interface FetchAppsError { + message?: string; +} + +export async function fetchApps(): Promise< + Result +> { + const response = await request('/api/apps'); + + if (response.isOk) { + return parseResponse(response, appsModel); + } + + return Result.err(response.error); +} + +export async function deleteApp(data: { + name: string; +}): Promise> { + const { name } = data; + const response = await request(`/api/apps`, { + method: 'DELETE', + body: JSON.stringify({ name }), + }); + + if (response.isOk) { + return Result.ok(true); + } + + return Result.err(response.error); +} diff --git a/webapp/javascript/ui/LoadingSpinner.tsx b/webapp/javascript/ui/LoadingSpinner.tsx index 16593d92eb..24f049ad71 100644 --- a/webapp/javascript/ui/LoadingSpinner.tsx +++ b/webapp/javascript/ui/LoadingSpinner.tsx @@ -3,10 +3,14 @@ import React from 'react'; // @ts-ignore import Spinner from 'react-svg-spinner'; -export default function LoadingSpinner() { +interface LoadingSpinnerProps { + className?: string; +} + +export default function LoadingSpinner({ className }: LoadingSpinnerProps) { // TODO ditch the library and create ourselves return ( - + ); diff --git a/webapp/javascript/ui/Modals/Modal.module.css b/webapp/javascript/ui/Modals/Modal.module.css index 8422f460ea..f3b5398b97 100644 --- a/webapp/javascript/ui/Modals/Modal.module.css +++ b/webapp/javascript/ui/Modals/Modal.module.css @@ -2,10 +2,6 @@ background-color: var(--ps-ui-foreground); } -.popup label { - color: var(--ps-neutral-2); -} - .popup button.button:focus { box-shadow: none; } diff --git a/webapp/javascript/ui/Modals/index.ts b/webapp/javascript/ui/Modals/index.ts index eb54f2d031..9f6754a298 100644 --- a/webapp/javascript/ui/Modals/index.ts +++ b/webapp/javascript/ui/Modals/index.ts @@ -23,6 +23,7 @@ const defaultParams: Partial = { export type ShowModalParams = { title: string; + html?: string; confirmButtonText: string; type: 'danger' | 'normal'; onConfirm?: ShamefulAny; @@ -31,10 +32,12 @@ export type ShowModalParams = { inputLabel?: string; inputPlaceholder?: string; validationMessage?: string; + inputValidator?: (value: string) => string | null; }; const ShowModal = async ({ title, + html, confirmButtonText, type, onConfirm, @@ -43,15 +46,18 @@ const ShowModal = async ({ inputLabel, inputPlaceholder, validationMessage, + inputValidator, }: ShowModalParams) => { const { isConfirmed, value } = await Swal.fire({ title, + html, confirmButtonText, input, inputLabel, inputPlaceholder, inputValue, validationMessage, + inputValidator, confirmButtonColor: getButtonStyleFromType(type), ...defaultParams, }); diff --git a/webapp/javascript/ui/Table.module.scss b/webapp/javascript/ui/Table.module.scss index af632d6f21..3f36340a32 100644 --- a/webapp/javascript/ui/Table.module.scss +++ b/webapp/javascript/ui/Table.module.scss @@ -69,3 +69,8 @@ } } } + +.loadingSpinner { + text-align: center; + margin-top: 50px; +} diff --git a/webapp/javascript/ui/Table.tsx b/webapp/javascript/ui/Table.tsx index f9c5a58838..df093e9d7b 100644 --- a/webapp/javascript/ui/Table.tsx +++ b/webapp/javascript/ui/Table.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; // eslint-disable-next-line css-modules/no-unused-class import styles from './Table.module.scss'; +import LoadingSpinner from './LoadingSpinner'; interface CustomProp { [k: string]: string | CSSProperties | ReactNode | number | undefined; @@ -82,6 +83,7 @@ interface TableProps { table: Table; tableBodyRef?: RefObject; className?: string; + isLoading?: boolean; } function Table({ @@ -91,9 +93,14 @@ function Table({ table, tableBodyRef, className, + isLoading, }: TableProps) { const hasSort = sortByDirection && sortBy && updateSortParams; - return ( + return isLoading ? ( +
    + +
    + ) : (