Skip to content

Commit

Permalink
Deleting dashboards (TELESTION-458) and changing their names (TELESTI…
Browse files Browse the repository at this point in the history
…ON-459)

This change

1. adjusts the existing editing route (`POST` / `PUT` on `/dashboards/:dashboardId/edit`) to make the dashboard title editable and adds a text field to achive this to the corresponding UI
2. adds a `DELETE` action on the same route to delete the dasboard with the ID `:dashboardId`. This redirects to `/`, which in turn redirects either to the first available dashboard or, if none exists, a corresponding page.

Note that the Browser's native `window.confirm()` was used to ask the user whether they really want to delete the dashboard. This can, eventually, be replaced by a better integrated UI, but for the MVP, works as-is without any issues.
  • Loading branch information
pklaschka committed Jan 24, 2024
1 parent ab9249e commit bba5a4b
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 45 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { z } from 'zod';
import { Form, useActionData, useLoaderData } from 'react-router-dom';
import {
Form,
useActionData,
useLoaderData,
useSubmit
} from 'react-router-dom';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { clsx } from 'clsx';
import {
Alert,
Button,
FormControl,
FormGroup,
FormLabel,
Expand Down Expand Up @@ -41,13 +47,16 @@ const actionSchema = z
.optional();

export function DashboardEditor() {
const submit = useSubmit();
const errors = actionSchema.parse(useActionData());

const {
localDashboard,
setLocalDashboard,
localWidgetInstances,
setLocalWidgetInstances,
dashboardTitle,
setDashboardTitle,
selectedWidgetInstance,
selectedWidgetId,
selectedWidgetType,
Expand All @@ -74,7 +83,7 @@ export function DashboardEditor() {
return newId;
}, [setLocalWidgetInstances]);

const onFormSelectChange = useCallback(
const onWidgetInstanceTypeChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value;
const widgetType = getWidgetById(value);
Expand Down Expand Up @@ -103,6 +112,15 @@ export function DashboardEditor() {
setLocalWidgetInstances
]
);
const onDeleteDashboard = useCallback(() => {
if (
!window.confirm(
`Are you sure you want to delete the dashboard "${dashboardTitle}" (ID: ${dashboardId})?`
)
)
return;
submit(null, { method: 'DELETE' });
}, [dashboardId, dashboardTitle, submit]);

const onConfigurationChange = (
newConfig: z.infer<typeof widgetInstanceSchema.shape.configuration>
Expand Down Expand Up @@ -132,10 +150,22 @@ export function DashboardEditor() {
{errors.errors.layout && <p>{errors.errors.layout}</p>}
</Alert>
)}
<FormGroup>
<FormGroup className={clsx('mb-3')}>
<FormLabel>Dashboard ID</FormLabel>
<FormControl readOnly name="dashboardId" value={dashboardId} />
</FormGroup>
<FormGroup className={clsx('mb-3')}>
<FormLabel>Dashboard Title</FormLabel>
<FormControl
name="title"
value={dashboardTitle}
onChange={event => setDashboardTitle(event.target.value)}
/>
</FormGroup>
<h3>Danger Zone</h3>
<Button variant="danger" onClick={onDeleteDashboard}>
Delete Dashboard
</Button>
</section>
<section className={clsx(styles.layout)}>
<h2 className={'p-3'}>Dashboard Layout</h2>
Expand Down Expand Up @@ -171,7 +201,7 @@ export function DashboardEditor() {
<FormSelect
disabled={!selectedWidgetId}
value={selectedWidgetInstance?.type ?? ''}
onChange={onFormSelectChange}
onChange={onWidgetInstanceTypeChange}
>
{!selectedWidgetId && (
<option value="" disabled>
Expand Down Expand Up @@ -225,6 +255,7 @@ function useDashboardEditorData() {
z.infer<typeof loaderSchema.shape.widgetInstances>
>({});
const [dashboardId, setDashboardId] = useState('');
const [dashboardTitle, setDashboardTitle] = useState('');

// create the local working copy of the data whenever the loader data changes
useEffect(() => {
Expand All @@ -240,6 +271,7 @@ function useDashboardEditorData() {
});
setLocalWidgetInstances(widgetInstances);
setDashboardId(dashboardId);
setDashboardTitle(dashboard.title);
}, [loaderData]);

const selectedWidgetId = getSelectedWidgetId(localDashboard);
Expand All @@ -256,6 +288,8 @@ function useDashboardEditorData() {
setLocalDashboard,
localWidgetInstances,
setLocalWidgetInstances,
dashboardTitle,
setDashboardTitle,
selectedWidgetInstance,
selectedWidgetId,
configuration,
Expand Down
120 changes: 79 additions & 41 deletions frontend-react/src/lib/application/routes/dashboard-editor/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from '../../../user-data';
import { isUserDataUpToDate } from '../../../utils';
import { TelestionOptions } from '../../model.ts';
import { setResumeAfterLogin } from '../login';
import { resetResumeAfterLogin, setResumeAfterLogin } from '../login';
import { z } from 'zod';

export function dashboardEditorLoader({ version }: TelestionOptions) {
Expand Down Expand Up @@ -80,52 +80,90 @@ export function dashboardEditorAction({ version }: TelestionOptions) {
});
}

const formData = await request.formData();
const rawNewLayout = formData.get('layout');
if (typeof rawNewLayout !== 'string') {
throw new Error('No layout given');
switch (request.method) {
case 'POST':
case 'PUT':
return editDashboard(request, userData, dashboardId);
case 'DELETE':
return deleteDashboard(userData, dashboardId);
default:
throw new Response(`Method "${request.method}" not allowed`, {
status: 405
}) as never;
}
};
}

const rawNewWidgetInstances = formData.get('widgetInstances');
if (typeof rawNewWidgetInstances !== 'string') {
throw new Error('No widgetInstances given');
}
async function editDashboard(
request: Request,
userData: UserData,
dashboardId: string
) {
const formData = await request.formData();
const rawNewLayout = formData.get('layout');
if (typeof rawNewLayout !== 'string') {
throw new Error('No layout given');
}

try {
const newLayout = layoutSchema.parse(JSON.parse(rawNewLayout));
const newWidgetInstances = z
.record(z.string(), widgetInstanceSchema)
.parse(JSON.parse(rawNewWidgetInstances));
const dashboard = userData.dashboards[dashboardId];

const newUserData: UserData = {
...userData,
dashboards: {
...userData.dashboards,
[dashboardId]: {
...dashboard,
layout: newLayout
}
},
widgetInstances: {
...userData.widgetInstances,
...newWidgetInstances
}
};
setUserData(newUserData);
return redirect(
generatePath('/dashboards/:dashboardId', { dashboardId })
);
} catch (err) {
const errors: Record<string, string> = {};
const rawNewWidgetInstances = formData.get('widgetInstances');
if (typeof rawNewWidgetInstances !== 'string') {
throw new Error('No widgetInstances given');
}

const rawNewTitle = formData.get('title');
if (typeof rawNewTitle !== 'string') {
throw new Error('No title given');
}

try {
const newLayout = layoutSchema.parse(JSON.parse(rawNewLayout));
const newWidgetInstances = z
.record(z.string(), widgetInstanceSchema)
.parse(JSON.parse(rawNewWidgetInstances));
const newTitle = z.string().parse(rawNewTitle);
const dashboard = userData.dashboards[dashboardId];

if (err instanceof Error) {
errors.layout = err.message;
} else {
errors.layout = JSON.stringify(err);
const newUserData: UserData = {
...userData,
dashboards: {
...userData.dashboards,
[dashboardId]: {
...dashboard,
layout: newLayout,
title: newTitle
}
},
widgetInstances: {
...userData.widgetInstances,
...newWidgetInstances
}
};
setUserData(newUserData);
return redirect(generatePath('/dashboards/:dashboardId', { dashboardId }));
} catch (err) {
const errors: Record<string, string> = {};

if (err instanceof Error) {
errors.layout = err.message;
} else {
errors.layout = JSON.stringify(err);
}

return { errors };
}
}

return { errors };
function deleteDashboard(userData: UserData, dashboardId: string) {
const newUserData: UserData = {
...userData,
dashboards: {
...Object.fromEntries(
Object.entries(userData.dashboards).filter(([id]) => id !== dashboardId)
)
}
};
console.debug(`Deleted dashboard "${dashboardId}"`, newUserData);
setUserData(newUserData);
resetResumeAfterLogin();
return redirect('/');
}

0 comments on commit bba5a4b

Please sign in to comment.