migrate to app dir and ssr

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-20 22:54:38 +01:00
parent 719a82f1c4
commit 308ae98472
194 changed files with 4706 additions and 2194 deletions

View File

@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import type { getReportsByDashboardId } from '@/server/services/reports.service';
import type { IChartRange } from '@/types';
import { cn } from '@/utils/cn';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
}
export function ListReports({ reports }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const [range, setRange] = useState<null | IChartRange>(null);
return (
<>
<StickyBelowHeader className="p-4 items-center justify-between flex">
<Combobox
className="min-w-0"
placeholder="Override range"
value={range}
onChange={(value) => {
setRange((p) => (p === value ? null : value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/>
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</StickyBelowHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
return (
<div
className="rounded-md border border-border bg-white shadow"
key={report.id}
>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
shallow
>
<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>{range}</span>}
</div>
)}
</div>
<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>
<div
className={cn(
'p-4 pl-2',
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
)}
>
<LazyChart
{...report}
range={range ?? report.range}
interval={
range ? getDefaultIntervalByRange(range) : report.interval
}
editMode={false}
/>
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
import PageLayout from '@/app/(app)/page-layout';
import { getSession } from '@/server/auth';
import {
createRecentDashboard,
getDashboardById,
} from '@/server/services/dashboard.service';
import { getReportsByDashboardId } from '@/server/services/reports.service';
import { revalidateTag } from 'next/cache';
import { ListReports } from './list-reports';
interface PageProps {
params: {
organizationId: string;
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { organizationId, projectId, dashboardId },
}: PageProps) {
const session = await getSession();
const dashboard = await getDashboardById(dashboardId);
const reports = await getReportsByDashboardId(dashboardId);
const userId = session?.user.id;
if (userId && dashboard) {
await createRecentDashboard({
userId,
organizationId,
projectId,
dashboardId,
});
revalidateTag(`recentDashboards__${userId}`);
}
return (
<PageLayout title={dashboard.name} organizationId={organizationId}>
<ListReports reports={reports} />
</PageLayout>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react';
const variants = cva('flex items-center justify-center shrink-0', {
variants: {
size: {
sm: 'w-6 h-6 rounded',
default: 'w-12 h-12 rounded-xl',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
className?: string;
};
const records = {
default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' },
screen_view: {
Icon: MonitorPlayIcon,
text: 'text-chart-3',
bg: 'bg-chart-3/10',
},
session_start: {
Icon: ActivityIcon,
text: 'text-chart-2',
bg: 'bg-chart-2/10',
},
};
export function EventIcon({ className, name, size }: EventIconProps) {
const { Icon, text, bg } =
name in records ? records[name as keyof typeof records] : records.default;
return (
<div className={cn(variants({ size }), bg, className)}>
<Icon size={20} className={text} />
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useMemo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
import Link from 'next/link';
import { EventIcon } from './event-icon';
type EventListItemProps = RouterOutputs['event']['list'][number];
export function EventListItem({
profile,
createdAt,
name,
properties,
}: EventListItemProps) {
const params = useAppParams();
const bullets = useMemo(() => {
const bullets: React.ReactNode[] = [
<span>{formatDateTime(createdAt)}</span>,
];
if (profile) {
bullets.push(
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
className="flex items-center gap-1 text-black font-medium hover:underline"
>
<ProfileAvatar size="xs" {...(profile ?? {})}></ProfileAvatar>
{getProfileName(profile)}
</Link>
);
}
if (typeof properties.duration === 'number') {
bullets.push(`${round(properties.duration / 1000, 1)}s`);
}
switch (name) {
case 'screen_view': {
const route = (properties?.route || properties?.path) as string;
if (route) {
bullets.push(route);
}
break;
}
}
return bullets;
}, [name, createdAt, profile, properties, params]);
return (
<ExpandableListItem
title={name.split('_').join(' ')}
bullets={bullets}
image={<EventIcon name={name} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useMemo, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { EventListItem } from './event-list-item';
interface ListEventsProps {
projectId: string;
}
export function ListEvents({ projectId }: ListEventsProps) {
const pagination = usePagination();
const [eventFilters, setEventFilters] = useState<string[]>([]);
const eventsQuery = api.event.list.useQuery(
{
events: eventFilters,
projectId: projectId,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const filterEventsQuery = api.chart.events.useQuery({
projectId: projectId,
});
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<div>
<ComboboxAdvanced
items={filterEvents}
value={eventFilters}
onChange={setEventFilters}
placeholder="Filter by event"
/>
</div>
</StickyBelowHeader>
<div className="p-4">
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,19 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListEvents } from './list-events';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({
params: { organizationId, projectId },
}: PageProps) {
return (
<PageLayout title="Events" organizationId={organizationId}>
<ListEvents projectId={projectId} />
</PageLayout>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
interface HeaderDashboardsProps {
projectId: string;
}
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
return (
<StickyBelowHeader>
<div className="p-4 flex justify-between items-center">
<div />
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard', {
projectId,
});
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
</StickyBelowHeader>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { api, handleError, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { ToastAction } from '@/components/ui/toast';
import { toast } from '@/components/ui/use-toast';
import { pushModal } from '@/modals';
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { Pencil, Plus, Trash } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
interface ListDashboardsProps {
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useParams();
const { organizationId, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: (
<ToastAction
altText="Force delete"
onClick={() => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
}}
>
Force delete
</ToastAction>
),
})(error);
},
onSuccess() {
router.refresh();
toast({
title: 'Success',
description: 'Dashboard deleted.',
});
},
});
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{dashboards.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${projectId}/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground text-sm">
{item.project.name}
</span>
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="text-destructive w-full" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
import PageLayout from '@/app/(app)/page-layout';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<PageLayout title="Dashboards" organizationId={organizationId}>
<HeaderDashboards projectId={projectId} />
<ListDashboards dashboards={dashboards} />
</PageLayout>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { useEventNames } from '@/hooks/useEventNames';
import { parseAsJson, useQueryState } from 'nuqs';
import { EventListItem } from '../../events/event-list-item';
interface ListProfileEvents {
projectId: string;
profileId: string;
}
export default function ListProfileEvents({
projectId,
profileId,
}: ListProfileEvents) {
const pagination = usePagination();
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsJson<string[]>().withDefault([])
);
const eventNames = useEventNames(projectId);
const eventsQuery = api.event.list.useQuery(
{
projectId,
profileId,
events: eventFilters,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<div className="flex items-center justify-between mb-4">
<ComboboxAdvanced
placeholder="Filter events"
items={eventNames}
value={eventFilters}
onChange={setEventFilters}
/>
</div>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
);
}

View File

@@ -0,0 +1,87 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListProperties } from '@/components/events/ListProperties';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Avatar } from '@/components/ui/avatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import {
getProfileById,
getProfilesByExternalId,
} from '@/server/services/profile.service';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import ListProfileEvents from './list-profile-events';
interface PageProps {
params: {
organizationId: string;
projectId: string;
profileId: string;
};
}
export default async function Page({
params: { organizationId, projectId, profileId },
}: PageProps) {
const profile = await getProfileById(profileId);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
return (
<PageLayout
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</div>
}
organizationId={organizationId}
>
<div className="p-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
<Widget>
<WidgetHead>
<span className="title">Properties</span>
</WidgetHead>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</Widget>
<Widget>
<WidgetHead>
<span className="title">Linked profile</span>
</WidgetHead>
{profiles.length > 0 ? (
<div className="flex flex-col gap-4">
{profiles.map((profile) => (
<div key={profile.id} className="border-b border-border">
<WidgetBody className="flex gap-4">
<ProfileAvatar {...profile} />
<div>
<div className="font-medium mt-1">
{getProfileName(profile)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
<span>{profile.id}</span>
<span>{formatDateTime(profile.createdAt)}</span>
</div>
</div>
</WidgetBody>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</div>
))}
</div>
) : (
<div className="p-4">No linked profiles</div>
)}
</Widget>
</div>
<ListProfileEvents projectId={projectId} profileId={profileId} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input';
import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
organizationId: string;
}
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const eventsQuery = api.profile.list.useQuery(
{
projectId,
query,
...pagination,
},
{
keepPreviousData: true,
}
);
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<Input
placeholder="Search by name"
value={query ?? ''}
onChange={(event) => setQuery(event.target.value || null)}
/>
</StickyBelowHeader>
<div className="p-4">
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,19 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListProfiles } from './list-profiles';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({
params: { organizationId, projectId },
}: PageProps) {
return (
<PageLayout title="Events" organizationId={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
</PageLayout>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useMemo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const bullets = useMemo(() => {
const bullets: React.ReactNode[] = [
<span>{formatDateTime(createdAt)}</span>,
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>,
];
return bullets;
}, [createdAt, id, params]);
return (
<ExpandableListItem
title={getProfileName(props)}
bullets={bullets}
image={<ProfileAvatar {...props} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,32 @@
import PageLayout from '@/app/(app)/page-layout';
import { getReportById } from '@/server/services/reports.service';
import { Pencil } from 'lucide-react';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
reportId: string;
};
}
export default async function Page({
params: { organizationId, reportId },
}: PageProps) {
const report = await getReportById(reportId);
return (
<PageLayout
title={
<div className="flex gap-2 items-center cursor-pointer">
{report.name}
<Pencil size={16} />
</div>
}
organizationId={organizationId}
>
<ReportEditor report={report} />
</PageLayout>
);
}

View File

@@ -0,0 +1,27 @@
import PageLayout from '@/app/(app)/page-layout';
import { Pencil } from 'lucide-react';
import ReportEditor from './report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
<Pencil size={16} />
</div>
}
organizationId={organizationId}
>
<ReportEditor reportId={null} />
</PageLayout>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useEffect, useRef } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
ready,
reset,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useDispatch, useSelector } from '@/redux';
import type { IServiceReport } from '@/server/services/reports.service';
import { timeRanges } from '@/utils/constants';
import { GanttChartSquareIcon } from 'lucide-react';
interface ReportEditorProps {
report: IServiceReport | null;
}
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
// Set report if reportId exists
useEffect(() => {
if (initialReport) {
dispatch(setReport(initialReport));
} else {
dispatch(ready());
}
return () => {
dispatch(reset());
};
}, [initialReport, dispatch]);
return (
<Sheet>
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
Pick events
</Button>
</div>
</SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<Combobox
className="min-w-0 flex-1"
placeholder="Range"
value={report.range}
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportChartType className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
<ReportSaveButton />
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && <Chart {...report} editMode />}
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<ReportSidebar />
</SheetContent>
</Sheet>
);
}