web: edit report and edit dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-12-17 13:44:45 +01:00
parent 5ae8decbc0
commit fdb9b912d7
12 changed files with 284 additions and 22 deletions

View File

@@ -27,7 +27,9 @@ export function NavbarMenu() {
return ( return (
<div className={cn('flex gap-1 items-center text-sm', 'max-sm:flex-col')}> <div className={cn('flex gap-1 items-center text-sm', 'max-sm:flex-col')}>
{params.project && ( {params.project && (
<Item href={`/${params.organization}/${params.project}`}>Home</Item> <Item href={`/${params.organization}/${params.project}`}>
Dashboards
</Item>
)} )}
{params.project && ( {params.project && (
<Item href={`/${params.organization}/${params.project}/events`}> <Item href={`/${params.organization}/${params.project}/events`}>

View File

@@ -47,7 +47,7 @@ export const Chart = memo(
if (!enabled) { if (!enabled) {
return ( return (
<ChartAnimationContainer> <ChartAnimationContainer>
<ChartAnimation name="ballon" className="w-96 mx-auto" /> <ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium"> <p className="text-center font-medium">
Please select at least one event to see the chart. Please select at least one event to see the chart.
</p> </p>
@@ -58,7 +58,7 @@ export const Chart = memo(
if (chart.isFetching) { if (chart.isFetching) {
return ( return (
<ChartAnimationContainer> <ChartAnimationContainer>
<ChartAnimation name="airplane" className="w-96 mx-auto" /> <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">Loading...</p> <p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer> </ChartAnimationContainer>
); );
@@ -67,20 +67,22 @@ export const Chart = memo(
if (chart.isError) { if (chart.isError) {
return ( return (
<ChartAnimationContainer> <ChartAnimationContainer>
<ChartAnimation name="noData" className="w-96 mx-auto" /> <ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">Something went wrong...</p> <p className="text-center font-medium">Something went wrong...</p>
</ChartAnimationContainer> </ChartAnimationContainer>
); );
} }
if (!chart.isSuccess) { if (!chart.isSuccess) {
return <ChartAnimation name="ballon" className="w-96 mx-auto" />; return (
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
);
} }
if (!anyData) { if (!anyData) {
return ( return (
<ChartAnimationContainer> <ChartAnimationContainer>
<ChartAnimation name="noData" className="w-96 mx-auto" /> <ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">No data</p> <p className="text-center font-medium">No data</p>
</ChartAnimationContainer> </ChartAnimationContainer>
); );
@@ -96,7 +98,7 @@ export const Chart = memo(
return ( return (
<ChartAnimationContainer> <ChartAnimationContainer>
<ChartAnimation name="ballon" className="w-96 mx-auto" /> <ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium"> <p className="text-center font-medium">
Chart type &quot;{chartType}&quot; is not supported yet. Chart type &quot;{chartType}&quot; is not supported yet.
</p> </p>

View File

@@ -19,7 +19,7 @@ type InitialState = IChartInput & {
// First approach: define the initial state using that type // First approach: define the initial state using that type
const initialState: InitialState = { const initialState: InitialState = {
dirty: false, dirty: false,
name: 'screen_view', name: 'Untitled',
chartType: 'linear', chartType: 'linear',
interval: 'day', interval: 'day',
breakdowns: [], breakdowns: [],
@@ -30,7 +30,7 @@ const initialState: InitialState = {
}; };
export const reportSlice = createSlice({ export const reportSlice = createSlice({
name: 'counter', name: 'report',
initialState, initialState,
reducers: { reducers: {
resetDirty(state) { resetDirty(state) {
@@ -50,6 +50,10 @@ export const reportSlice = createSlice({
dirty: false, dirty: false,
}; };
}, },
setName(state, action: PayloadAction<string>) {
state.dirty = true;
state.name = action.payload;
},
// Events // Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true; state.dirty = true;
@@ -162,6 +166,7 @@ export const reportSlice = createSlice({
export const { export const {
reset, reset,
setReport, setReport,
setName,
addEvent, addEvent,
removeEvent, removeEvent,
changeEvent, changeEvent,

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useRefetchActive } from '@/hooks/useRefetchActive';
import { api, handleError } from '@/utils/api';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface EditDashboardProps {
id: string;
}
const validator = z.object({
id: z.string().min(1),
name: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
export default function EditDashboard({ id }: EditDashboardProps) {
const refetch = useRefetchActive();
const mutation = api.dashboard.update.useMutation({
onError: handleError,
onSuccess() {
toast({
title: 'Success',
description: 'Dashboard updated.',
});
popModal();
refetch();
},
});
const query = api.dashboard.get.useQuery({ id });
const data = query.data;
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id: '',
name: '',
},
});
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
return (
<ModalContent>
<ModalHeader title="Edit dashboard" />
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<InputWithLabel label="Name" placeholder="Name" {...register('name')} />
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useRefetchActive } from '@/hooks/useRefetchActive';
import { api, handleError } from '@/utils/api';
import { zodResolver } from '@hookform/resolvers/zod';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
interface EditReportProps {
form: IForm;
onSubmit: SubmitHandler<IForm>;
}
export default function EditReport({ form, onSubmit }: EditReportProps) {
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: form,
});
return (
<ModalContent>
<ModalHeader title="Edit report" />
<form onSubmit={handleSubmit(onSubmit)}>
<InputWithLabel label="Name" placeholder="Name" {...register('name')} />
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -54,7 +54,7 @@ export default function SaveReport({ report }: SaveReportProps) {
useForm<IForm>({ useForm<IForm>({
resolver: zodResolver(validator), resolver: zodResolver(validator),
defaultValues: { defaultValues: {
name: '', name: report.name,
dashboardId: '', dashboardId: '',
}, },
}); });
@@ -136,7 +136,7 @@ export default function SaveReport({ report }: SaveReportProps) {
<Button type="button" variant="outline" onClick={() => popModal()}> <Button type="button" variant="outline" onClick={() => popModal()}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={!formState.isDirty}> <Button type="submit" disabled={!formState.isValid}>
Save Save
</Button> </Button>
</ButtonContainer> </ButtonContainer>

View File

@@ -34,6 +34,12 @@ const modals = {
AddDashboard: dynamic(() => import('./AddDashboard'), { AddDashboard: dynamic(() => import('./AddDashboard'), {
loading: Loading, loading: Loading,
}), }),
EditDashboard: dynamic(() => import('./EditDashboard'), {
loading: Loading,
}),
EditReport: dynamic(() => import('./EditReport'), {
loading: Loading,
}),
}; };
const emitter = mitt<{ const emitter = mitt<{

View File

@@ -1,17 +1,25 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CardActions, CardActionsItem } from '@/components/Card';
import { Container } from '@/components/Container'; import { Container } from '@/components/Container';
import { MainLayout } from '@/components/layouts/MainLayout'; import { MainLayout } from '@/components/layouts/MainLayout';
import { PageTitle } from '@/components/PageTitle'; import { PageTitle } from '@/components/PageTitle';
import { LazyChart } from '@/components/report/chart/LazyChart'; import { LazyChart } from '@/components/report/chart/LazyChart';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { createServerSideProps } from '@/server/getServerSideProps'; import { createServerSideProps } from '@/server/getServerSideProps';
import type { IChartRange } from '@/types'; import type { IChartRange } from '@/types';
import { api } from '@/utils/api'; import { api, handleError } from '@/utils/api';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { timeRanges } from '@/utils/constants'; import { timeRanges } from '@/utils/constants';
import { getRangeLabel } from '@/utils/getRangeLabel'; import { getRangeLabel } from '@/utils/getRangeLabel';
import { ChevronRight } from 'lucide-react'; import { ChevronRight, MoreHorizontal, Trash } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export const getServerSideProps = createServerSideProps(); export const getServerSideProps = createServerSideProps();
@@ -29,6 +37,13 @@ export default function Dashboard() {
return query.data?.reports ?? []; return query.data?.reports ?? [];
}, [query]); }, [query]);
const deletion = api.report.delete.useMutation({
onError: handleError,
onSuccess() {
query.refetch();
},
});
const [range, setRange] = useState<null | IChartRange>(null); const [range, setRange] = useState<null | IChartRange>(null);
return ( return (
@@ -62,7 +77,7 @@ export default function Dashboard() {
> >
<Link <Link
href={`/${params.organization}/${params.project}/reports/${report.id}?dashboard=${params.dashboard}`} href={`/${params.organization}/${params.project}/reports/${report.id}?dashboard=${params.dashboard}`}
className="flex border-b border-border p-4 leading-none [&>svg]:hover:opacity-100 items-center justify-between" className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
shallow shallow
> >
<div> <div>
@@ -76,7 +91,33 @@ export default function Dashboard() {
</div> </div>
)} )}
</div> </div>
<ChevronRight className="opacity-0 transition-opacity" /> <div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
deletion.mutate({
reportId: report.id,
});
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
className="opacity-10 transition-opacity"
size={16}
/>
</div>
</Link> </Link>
<div <div
className={cn( className={cn(

View File

@@ -7,7 +7,7 @@ import { useRefetchActive } from '@/hooks/useRefetchActive';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { createServerSideProps } from '@/server/getServerSideProps'; import { createServerSideProps } from '@/server/getServerSideProps';
import { api, handleError } from '@/utils/api'; import { api, handleError } from '@/utils/api';
import { Plus, Trash } from 'lucide-react'; import { Pencil, Plus, Trash } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
export const getServerSideProps = createServerSideProps(); export const getServerSideProps = createServerSideProps();
@@ -46,6 +46,18 @@ export default function Home() {
</Link> </Link>
<CardActions> <CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', {
id: item.id,
});
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="text-destructive w-full" asChild> <CardActionsItem className="text-destructive w-full" asChild>
<button <button
onClick={() => { onClick={() => {

View File

@@ -1,20 +1,23 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { Container } from '@/components/Container'; import { Container } from '@/components/Container';
import { MainLayout } from '@/components/layouts/MainLayout'; import { MainLayout } from '@/components/layouts/MainLayout';
import { PageTitle } from '@/components/PageTitle';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { useReportId } from '@/components/report/hooks/useReportId'; import { useReportId } from '@/components/report/hooks/useReportId';
import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportDateRange } from '@/components/report/ReportDateRange'; import { ReportDateRange } from '@/components/report/ReportDateRange';
import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportSaveButton } from '@/components/report/ReportSaveButton'; import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import { reset, setReport } from '@/components/report/reportSlice'; import { reset, setName, setReport } from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useRouterBeforeLeave } from '@/hooks/useRouterBeforeLeave'; import { useRouterBeforeLeave } from '@/hooks/useRouterBeforeLeave';
import { popModal, pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch, useSelector } from '@/redux';
import { createServerSideProps } from '@/server/getServerSideProps'; import { createServerSideProps } from '@/server/getServerSideProps';
import { api } from '@/utils/api'; import { api } from '@/utils/api';
import { Pencil } from 'lucide-react';
export const getServerSideProps = createServerSideProps(); export const getServerSideProps = createServerSideProps();
@@ -51,6 +54,27 @@ export default function Page() {
<Sheet> <Sheet>
<MainLayout> <MainLayout>
<Container> <Container>
<PageTitle>
<span className="flex items-center gap-4">
{report.name}
<Button
variant={'outline'}
onClick={() => {
pushModal('EditReport', {
form: {
name: report.name,
},
onSubmit: (values) => {
dispatch(setName(values.name));
popModal('EditReport');
},
});
}}
>
<Pencil size={16} />
</Button>
</span>
</PageTitle>
<div className="flex flex-col gap-4 mt-8"> <div className="flex flex-col gap-4 mt-8">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<ReportDateRange /> <ReportDateRange />

View File

@@ -10,12 +10,22 @@ import { z } from 'zod';
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
get: protectedProcedure get: protectedProcedure
.input( .input(
z.object({ z
slug: z.string(), .object({
}) slug: z.string(),
})
.or(z.object({ id: z.string() }))
) )
.query(async ({ input: { slug } }) => { .query(async ({ input }) => {
return getDashboardBySlug(slug); if ('id' in input) {
return db.dashboard.findUnique({
where: {
id: input.id,
},
});
} else {
return getDashboardBySlug(input.slug);
}
}), }),
list: protectedProcedure list: protectedProcedure
.input( .input(
@@ -41,6 +51,9 @@ export const dashboardRouter = createTRPCRouter({
where: { where: {
project_id: projectId, project_id: projectId,
}, },
orderBy: {
createdAt: 'desc',
},
}); });
}), }),
create: protectedProcedure create: protectedProcedure
@@ -60,6 +73,23 @@ export const dashboardRouter = createTRPCRouter({
}, },
}); });
}), }),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
})
)
.mutation(({ input }) => {
return db.dashboard.update({
where: {
id: input.id,
},
data: {
name: input.name,
},
});
}),
delete: protectedProcedure delete: protectedProcedure
.input( .input(
z.object({ z.object({

View File

@@ -83,6 +83,9 @@ export const reportRouter = createTRPCRouter({
project_id: project.id, project_id: project.id,
dashboard_id: dashboard.id, dashboard_id: dashboard.id,
}, },
orderBy: {
createdAt: 'desc',
},
}); });
return { return {
@@ -138,4 +141,17 @@ export const reportRouter = createTRPCRouter({
}, },
}); });
}), }),
delete: protectedProcedure
.input(
z.object({
reportId: z.string(),
})
)
.mutation(({ input: { reportId } }) => {
return db.report.delete({
where: {
id: reportId,
},
});
}),
}); });