Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(webapp): Add settings/apps page #1424

Merged
merged 9 commits into from
Aug 24, 2022
35 changes: 32 additions & 3 deletions webapp/javascript/components/Modals/ConfirmDelete/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ShowModalParams> = 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<br><strong>${objectName}</strong> ?${
warningMsg ? `<br><br>${warningMsg}` : ''
}`,
confirmButtonText: 'Delete',
type: 'danger',
onConfirm,
...confirmationInputProps,
});
}

Expand Down
6 changes: 4 additions & 2 deletions webapp/javascript/components/Settings/APIKeys/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.actions {
display: flex;
flex-direction: row;
justify-content: right;
}

.loadingIcon {
margin-right: 8px;
}
24 changes: 24 additions & 0 deletions webapp/javascript/components/Settings/Apps/Apps.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
72 changes: 72 additions & 0 deletions webapp/javascript/components/Settings/Apps/getAppTableRows.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<LoadingSpinner className={styles.loadingIcon} />
) : (
<Button type="button" kind="danger" onClick={handleDeleteClick}>
<Icon icon={faTimes} />
</Button>
);
}

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: (
<div className={styles.actions}>
<DeleteButton
app={app}
onDelete={handleDeleteApp}
isLoading={appsInProcessing.includes(name)}
/>
</div>
),
align: 'center',
},
],
};

acc.push(row);
return acc;
}, [] as BodyRow[]);

return bodyRows;
}
107 changes: 107 additions & 0 deletions webapp/javascript/components/Settings/Apps/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
const [deletedApps, setDeletedApps] = useState<string[]>([]);

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 (
<>
<h2 className={appsStyles.tabNameContrainer}>
Apps
{isLoading && !!apps ? <LoadingSpinner /> : null}
</h2>
<div className={appsStyles.searchContainer}>
<Input
type="text"
placeholder="Search app"
value={search}
onChange={(v) => setSearchField(v.target.value)}
name="Search app input"
/>
</div>
<TableUI
className={cl(appsStyles.appsTable, tableStyles.settingsTable)}
table={{ headRow, ...tableBodyProps }}
isLoading={isLoading && !apps}
/>
</>
);
}

export default Apps;
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
};

Expand Down
22 changes: 22 additions & 0 deletions webapp/javascript/components/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -93,6 +95,20 @@ function Settings() {
<Icon icon={faKey} /> API keys
</NavLink>
</li>
<li>
<NavLink
to={`${url}/apps`}
className={(isActive) =>
cx({
[styles.navLink]: true,
[styles.navLinkActive]: isActive,
})
}
data-testid="settings-appstab"
>
<Icon icon={faNetworkWired} /> Apps
</NavLink>
</li>
</>
) : null}
</ul>
Expand Down Expand Up @@ -136,6 +152,12 @@ function Settings() {
<APIKeyAddForm />
</>
</Route>
<Route exact path={`${path}/apps`}>
<>
<PageTitle title="Settings / Apps" />
<Apps />
</>
</Route>
</Switch>
</Box>
</div>
Expand Down
10 changes: 10 additions & 0 deletions webapp/javascript/models/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

export const appModel = z.object({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eh-am should we use uppercase in zod entities?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like appModel => AppModel

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or AppSchema

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made names here like in this users.ts file

name: z.string(),
});

export const appsModel = z.array(appModel);

export type Apps = z.infer<typeof appsModel>;
export type App = z.infer<typeof appModel>;
Loading