web: easier to navigate around + a lot of minor ui improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-12-12 14:26:54 +01:00
parent c175707be4
commit 7ca643bf7e
18 changed files with 271 additions and 40 deletions

View File

@@ -1,5 +1,20 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function Card({ children }: HtmlProps<HTMLDivElement>) {
return <div className="border border-border rounded">{children}</div>;
type CardProps = HtmlProps<HTMLDivElement> & {
hover?: boolean;
};
export function Card({ children, hover }: CardProps) {
return (
<div
className={cn(
'border border-border rounded',
hover &&
'transition-all hover:-translate-y-0.5 hover:shadow hover:border-black'
)}
>
{children}
</div>
);
}

View File

@@ -29,6 +29,16 @@ export function Breadcrumbs() {
}
);
const dashboard = api.dashboard.get.useQuery(
{
slug: params.dashboard,
},
{
enabled: !!params.dashboard,
staleTime: Infinity,
}
);
return (
<div className="border-b border-border text-xs">
<Container className="flex items-center gap-2 h-8">
@@ -52,6 +62,18 @@ export function Breadcrumbs() {
</Link>
</>
)}
{org.data && pro.data && dashboard.data && (
<>
<ChevronRight size={10} />
<Link
shallow
href={`/${org.data.slug}/${pro.data.slug}/${dashboard.data.slug}`}
>
{dashboard.data.name}
</Link>
</>
)}
</Container>
</div>
);

View File

@@ -1,5 +1,6 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import { strip } from '@/utils/object';
import Link from 'next/link';
import { NavbarUserDropdown } from './NavbarUserDropdown';
@@ -29,7 +30,12 @@ export function NavbarMenu() {
{params.project && (
<Link
shallow
href={`/${params.organization}/${params.project}/reports`}
href={{
pathname: `/${params.organization}/${params.project}/reports`,
query: strip({
dashboard: params.dashboard,
}),
}}
>
Create report
</Link>

View File

@@ -1,16 +1,19 @@
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { pushModal } from '@/modals';
import { useSelector } from '@/redux';
import { useDispatch, useSelector } from '@/redux';
import { api, handleError } from '@/utils/api';
import { SaveIcon } from 'lucide-react';
import { useReportId } from './hooks/useReportId';
import { resetDirty } from './reportSlice';
export function ReportSaveButton() {
const { reportId } = useReportId();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
onSuccess() {
dispatch(resetDirty());
toast({
title: 'Success',
description: 'Report updated.',

View File

@@ -1,11 +1,14 @@
import airplane from '@/lottie/airplane.json';
import ballon from '@/lottie/ballon.json';
import noData from '@/lottie/no-data.json';
import { cn } from '@/utils/cn';
import type { LottieComponentProps } from 'lottie-react';
import Lottie from 'lottie-react';
const animations = {
airplane,
ballon,
noData,
};
type Animations = keyof typeof animations;
@@ -15,3 +18,12 @@ export const ChartAnimation = ({
}: Omit<LottieComponentProps, 'animationData'> & {
name: Animations;
}) => <Lottie animationData={animations[name]} loop={true} {...props} />;
export const ChartAnimationContainer = (
props: React.ButtonHTMLAttributes<HTMLDivElement>
) => (
<div
{...props}
className={cn('border border-border rounded-md p-8', props.className)}
/>
);

View File

@@ -3,7 +3,7 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import type { IChartInput } from '@/types';
import { api } from '@/utils/api';
import { ChartAnimation } from './ChartAnimation';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { withChartProivder } from './ChartProvider';
import { ReportBarChart } from './ReportBarChart';
import { ReportLineChart } from './ReportLineChart';
@@ -45,15 +45,32 @@ export const Chart = memo(
const anyData = Boolean(chart.data?.series?.[0]?.data);
if (!enabled) {
return <p>Select events & filters to begin</p>;
return (
<ChartAnimationContainer>
<ChartAnimation name="ballon" className="w-96 mx-auto" />
<p className="text-center font-medium">
Please select at least one event to see the chart.
</p>
</ChartAnimationContainer>
);
}
if (chart.isFetching) {
return <ChartAnimation name="airplane" className="w-96 mx-auto" />;
return (
<ChartAnimationContainer>
<ChartAnimation name="airplane" className="w-96 mx-auto" />
<p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer>
);
}
if (chart.isError) {
return <p>Error</p>;
return (
<ChartAnimationContainer>
<ChartAnimation name="noData" className="w-96 mx-auto" />
<p className="text-center font-medium">Something went wrong...</p>
</ChartAnimationContainer>
);
}
if (!chart.isSuccess) {
@@ -61,7 +78,12 @@ export const Chart = memo(
}
if (!anyData) {
return <ChartAnimation name="ballon" className="w-96 mx-auto" />;
return (
<ChartAnimationContainer>
<ChartAnimation name="noData" className="w-96 mx-auto" />
<p className="text-center font-medium">No data</p>
</ChartAnimationContainer>
);
}
if (chartType === 'bar') {
@@ -72,6 +94,13 @@ export const Chart = memo(
return <ReportLineChart interval={interval} data={chart.data} />;
}
return <p>Chart type &quot;{chartType}&quot; is not supported yet.</p>;
return (
<ChartAnimationContainer>
<ChartAnimation name="ballon" className="w-96 mx-auto" />
<p className="text-center font-medium">
Chart type &quot;{chartType}&quot; is not supported yet.
</p>
</ChartAnimationContainer>
);
})
);

View File

@@ -33,6 +33,12 @@ export const reportSlice = createSlice({
name: 'counter',
initialState,
reducers: {
resetDirty(state) {
return {
...state,
dirty: false,
};
},
reset() {
return initialState;
},
@@ -165,6 +171,7 @@ export const {
changeInterval,
changeDateRanges,
changeChartType,
resetDirty,
} = reportSlice.actions;
export default reportSlice.reducer;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,78 @@
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 AddDashboardProps {
organizationSlug: string;
projectSlug: string;
}
const validator = z.object({
name: z.string().min(1, 'Required'),
});
type IForm = z.infer<typeof validator>;
export default function AddDashboard({
// organizationSlug,
projectSlug,
}: AddDashboardProps) {
const refetch = useRefetchActive();
const { register, handleSubmit, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: '',
},
});
const mutation = api.dashboard.create.useMutation({
onError: handleError,
onSuccess() {
refetch();
toast({
title: 'Success',
description: 'Dashboard created.',
});
popModal();
},
});
return (
<ModalContent>
<ModalHeader title="Edit client" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ name }) => {
mutation.mutate({
name,
projectSlug,
});
})}
>
<InputWithLabel
label="Name"
placeholder="Name of the dashboard"
{...register('name')}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
@@ -8,6 +9,7 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useRefetchActive } from '@/hooks/useRefetchActive';
import type { IChartInput } from '@/types';
import { api, handleError } from '@/utils/api';
import { strip } from '@/utils/object';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { Controller, useForm } from 'react-hook-form';
@@ -30,7 +32,7 @@ type IForm = z.infer<typeof validator>;
export default function SaveReport({ report }: SaveReportProps) {
const router = useRouter();
const { organization, project } = useOrganizationParams();
const { organization, project, dashboard } = useOrganizationParams();
const refetch = useRefetchActive();
const save = api.report.save.useMutation({
onError: handleError,
@@ -41,7 +43,10 @@ export default function SaveReport({ report }: SaveReportProps) {
});
popModal();
refetch();
router.push(`/${organization}/${project}/reports/${res.id}`);
router.push({
pathname: `/${organization}/${project}/reports/${res.id}`,
query: strip({ dashboard }),
});
},
});
@@ -58,7 +63,7 @@ export default function SaveReport({ report }: SaveReportProps) {
onError: handleError,
onSuccess(res) {
setValue('dashboardId', res.id);
dashboasrdQuery.refetch();
dashboardQuery.refetch();
toast({
title: 'Success',
description: 'Dashboard created.',
@@ -66,15 +71,24 @@ export default function SaveReport({ report }: SaveReportProps) {
},
});
const dashboasrdQuery = api.dashboard.list.useQuery({
const dashboardQuery = api.dashboard.list.useQuery({
projectSlug: project,
});
const dashboards = (dashboasrdQuery.data ?? []).map((item) => ({
const dashboards = (dashboardQuery.data ?? []).map((item) => ({
value: item.id,
label: item.name,
}));
useEffect(() => {
if (dashboard && dashboardQuery.data) {
const match = dashboardQuery.data.find((item) => item.slug === dashboard);
if (match) {
setValue('dashboardId', match.id);
}
}
}, [dashboard, dashboardQuery]);
return (
<ModalContent>
<ModalHeader title="Edit client" />

View File

@@ -31,6 +31,9 @@ const modals = {
SaveReport: dynamic(() => import('./SaveReport'), {
loading: Loading,
}),
AddDashboard: dynamic(() => import('./AddDashboard'), {
loading: Loading,
}),
};
const emitter = mitt<{

View File

@@ -11,6 +11,7 @@ import { api } from '@/utils/api';
import { cn } from '@/utils/cn';
import { timeRanges } from '@/utils/constants';
import { getRangeLabel } from '@/utils/getRangeLabel';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
export const getServerSideProps = createServerSideProps();
@@ -60,19 +61,22 @@ export default function Dashboard() {
key={report.id}
>
<Link
href={`/${params.organization}/${params.project}/reports/${report.id}`}
className="block border-b border-border p-4 leading-none hover:underline"
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"
shallow
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span className={range !== null ? 'line-through' : ''}>
{chartRange}
</span>
{range !== null && <span>{getRangeLabel(range)}</span>}
</div>
)}
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span className={range !== null ? 'line-through' : ''}>
{chartRange}
</span>
{range !== null && <span>{getRangeLabel(range)}</span>}
</div>
)}
</div>
<ChevronRight className="opacity-0 transition-opacity" />
</Link>
<div
className={cn(

View File

@@ -3,8 +3,10 @@ import { Container } from '@/components/Container';
import { MainLayout } from '@/components/layouts/MainLayout';
import { PageTitle } from '@/components/PageTitle';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { pushModal } from '@/modals';
import { createServerSideProps } from '@/server/getServerSideProps';
import { api } from '@/utils/api';
import { Plus } from 'lucide-react';
import Link from 'next/link';
export const getServerSideProps = createServerSideProps();
@@ -27,16 +29,30 @@ export default function Home() {
<PageTitle>Dashboards</PageTitle>
<div className="grid sm:grid-cols-2 gap-4">
{dashboards.map((item) => (
<Card key={item.id}>
<Card key={item.id} hover>
<Link
href={`/${params.organization}/${params.project}/${item.slug}`}
className="block p-4 font-medium leading-none hover:underline"
className="block p-4 font-medium leading-none"
shallow
>
{item.name}
</Link>
</Card>
))}
<Card hover>
<button
className="flex items-center justify-between w-full p-4 font-medium leading-none"
onClick={() => {
pushModal('AddDashboard', {
projectSlug: params.project,
organizationSlug: params.organization,
});
}}
>
Create new dashboard
<Plus size={16} />
</button>
</Card>
</div>
</Container>
</MainLayout>

View File

@@ -29,10 +29,10 @@ export default function Home() {
<PageTitle>Projects</PageTitle>
<div className="grid sm:grid-cols-2 gap-4">
{projects.map((item) => (
<Card key={item.id}>
<Card key={item.id} hover>
<Link
href={`/${params.organization}/${item.slug}`}
className="block p-4 font-medium leading-none hover:underline"
className="block p-4 font-medium leading-none"
shallow
>
{item.name}

View File

@@ -455,8 +455,6 @@ async function getChartData({
);
}
console.log(sql);
// group by sql label
const series = result.reduce(
(acc, item) => {
@@ -528,8 +526,8 @@ function fillEmptySpotsInTimeline(
}
if (interval === 'month') {
clonedStartDate.setDate(1);
clonedEndDate.setDate(1);
clonedStartDate.setUTCDate(1);
clonedEndDate.setUTCDate(1);
}
// Force if interval is month and the start date is the same month as today
@@ -537,11 +535,17 @@ function fillEmptySpotsInTimeline(
interval === 'month' &&
clonedStartDate.getUTCFullYear() === today.getUTCFullYear() &&
clonedStartDate.getUTCMonth() === today.getUTCMonth();
let prev = undefined;
while (
shouldForce() ||
clonedStartDate.getTime() <= clonedEndDate.getTime()
) {
if (prev === clonedStartDate.getTime()) {
console.log('GET OUT NOW!');
break;
}
prev = clonedStartDate.getTime();
const getYear = (date: Date) => date.getUTCFullYear();
const getMonth = (date: Date) => date.getUTCMonth();
const getDay = (date: Date) => date.getUTCDate();
@@ -598,19 +602,19 @@ function fillEmptySpotsInTimeline(
switch (interval) {
case 'day': {
clonedStartDate.setDate(clonedStartDate.getUTCDate() + 1);
clonedStartDate.setUTCDate(clonedStartDate.getUTCDate() + 1);
break;
}
case 'hour': {
clonedStartDate.setHours(clonedStartDate.getUTCHours() + 1);
clonedStartDate.setUTCHours(clonedStartDate.getUTCHours() + 1);
break;
}
case 'minute': {
clonedStartDate.setMinutes(clonedStartDate.getUTCMinutes() + 1);
clonedStartDate.setUTCMinutes(clonedStartDate.getUTCMinutes() + 1);
break;
}
case 'month': {
clonedStartDate.setMonth(clonedStartDate.getUTCMonth() + 1);
clonedStartDate.setUTCMonth(clonedStartDate.getUTCMonth() + 1);
break;
}
}

View File

@@ -1,10 +1,20 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getDashboardBySlug } from '@/server/services/dashboard.service';
import { getProjectBySlug } from '@/server/services/project.service';
import { slug } from '@/utils/slug';
import { z } from 'zod';
export const dashboardRouter = createTRPCRouter({
get: protectedProcedure
.input(
z.object({
slug: z.string(),
})
)
.query(async ({ input: { slug } }) => {
return getDashboardBySlug(slug);
}),
list: protectedProcedure
.input(
z

View File

@@ -12,7 +12,10 @@ import type { Client, Project } from '@prisma/client';
import type { TooltipProps } from 'recharts';
import type { z } from 'zod';
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
export type HtmlProps<T> = Omit<
React.DetailedHTMLProps<React.HTMLAttributes<T>, T>,
'ref'
>;
export type IChartInput = z.infer<typeof zChartInput>;
export type IChartInputWithDates = z.infer<typeof zChartInputWithDates>;

View File

@@ -1,3 +1,5 @@
import { anyPass, isEmpty, isNil, reject } from 'ramda';
export function toDots(
obj: Record<string, unknown>,
path = ''
@@ -16,3 +18,5 @@ export function toDots(
};
}, {});
}
export const strip = reject(anyPass([isEmpty, isNil]));