dashboard: update event and profile list

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-18 09:08:02 +01:00
parent 2057fe083b
commit 3a8404f704
34 changed files with 942 additions and 318 deletions

View File

@@ -1,5 +1,12 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import { cn } from '@/utils/cn';
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from 'lucide-react';
import { Button } from './ui/button';
@@ -20,38 +27,72 @@ export function Pagination({
count,
cursor,
setCursor,
className,
size = 'base',
}: {
take?: number;
count?: number;
take: number;
count: number;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
className?: string;
size?: 'sm' | 'base';
}) {
const isNextDisabled =
count !== undefined && take !== undefined && cursor * take + take >= count;
const lastCursor = Math.floor(count / take) - 1;
const isNextDisabled = count === 0 || lastCursor === cursor;
return (
<div className="flex select-none items-center justify-end gap-2">
<div className="font-medium text-xs">Page: {cursor + 1}</div>
{typeof count === 'number' && (
<div className="font-medium text-xs">Total rows: {count}</div>
<div
className={cn(
'flex select-none items-center justify-end gap-2',
className
)}
>
{size === 'base' && (
<>
<div className="font-medium text-xs">Page: {cursor + 1}</div>
{typeof count === 'number' && (
<div className="font-medium text-xs">Total rows: {count}</div>
)}
</>
)}
{size === 'base' && (
<Button
variant="outline"
size="icon"
onClick={() => setCursor(0)}
disabled={cursor === 0}
className="max-sm:hidden"
>
<ChevronsLeftIcon size={14} />
</Button>
)}
<Button
variant="outline"
size="sm"
size="icon"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
disabled={cursor === 0}
>
Previous
<ChevronLeftIcon size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCursor((p) => p + 1)}
size="icon"
onClick={() => setCursor((p) => Math.min(lastCursor, p + 1))}
disabled={isNextDisabled}
>
Next
<ChevronRightIcon size={14} />
</Button>
{size === 'base' && (
<Button
variant="outline"
size="icon"
onClick={() => setCursor(lastCursor)}
disabled={isNextDisabled}
className="max-sm:hidden"
>
<ChevronsRightIcon size={14} />
</Button>
)}
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { toDots } from '@openpanel/common';
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
interface ListPropertiesProps {
data: any;
className?: string;
}
export function ListProperties({
data,
className = 'mini',
}: ListPropertiesProps) {
const dots = toDots(data);
return (
<Table className={className}>
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === 'boolean'
? dots[key]
? 'true'
: 'false'
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,69 @@
import { SerieIcon } from '../report/chart/SerieIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface Props {
country?: string;
city?: string;
os?: string;
os_version?: string;
browser?: string;
browser_version?: string;
referrer_name?: string;
referrer_type?: string;
}
export function ListPropertiesIcon({
country,
city,
os,
os_version,
browser,
browser_version,
referrer_name,
referrer_type,
}: Props) {
return (
<div className="flex gap-1">
{country && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={country} />
</TooltipTrigger>
<TooltipContent>
{country}, {city}
</TooltipContent>
</Tooltip>
)}
{os && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={os} />
</TooltipTrigger>
<TooltipContent>
{os} ({os_version})
</TooltipContent>
</Tooltip>
)}
{browser && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={browser} />
</TooltipTrigger>
<TooltipContent>
{browser} ({browser_version})
</TooltipContent>
</Tooltip>
)}
{referrer_name && (
<Tooltip>
<TooltipTrigger>
<SerieIcon name={referrer_name} />
</TooltipTrigger>
<TooltipContent>
{referrer_name} ({referrer_type})
</TooltipContent>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { BarChartIcon, LineChartIcon } from 'lucide-react';
import { Button } from '../ui/button';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewChartToggle() {
const { chartType, setChartType } = useOverviewOptions();
return (
<Button
size={'icon'}
variant={'outline'}
onClick={() => {
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
}}
>
{chartType === 'bar' ? (
<LineChartIcon size={16} />
) : (
<BarChartIcon size={16} />
)}
</Button>
);
}

View File

@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -15,7 +16,7 @@ interface OverviewTopDevicesProps {
export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate } =
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
@@ -41,7 +42,7 @@ export default function OverviewTopDevices({
name: 'device',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -71,7 +72,7 @@ export default function OverviewTopDevices({
name: 'browser',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -101,7 +102,7 @@ export default function OverviewTopDevices({
name: 'browser_version',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -131,7 +132,7 @@ export default function OverviewTopDevices({
name: 'os',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -161,7 +162,7 @@ export default function OverviewTopDevices({
name: 'os_version',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -176,7 +177,10 @@ export default function OverviewTopDevices({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button

View File

@@ -1,10 +1,13 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { BarChartIcon, LineChart, LineChartIcon } from 'lucide-react';
import { Widget, WidgetBody } from '../../Widget';
import { OverviewChartToggle } from '../overview-chart-toggle';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
@@ -17,8 +20,15 @@ export default function OverviewTopEvents({
projectId,
conversions,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const {
interval,
range,
previous,
startDate,
endDate,
chartType,
setChartType,
} = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
@@ -50,7 +60,7 @@ export default function OverviewTopEvents({
name: 'name',
},
],
chartType: 'bar',
chartType: chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -89,7 +99,7 @@ export default function OverviewTopEvents({
name: 'name',
},
],
chartType: 'bar',
chartType: chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -104,7 +114,10 @@ export default function OverviewTopEvents({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)

View File

@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -13,7 +14,7 @@ interface OverviewTopGeoProps {
projectId: string;
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } =
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
@@ -39,7 +40,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
name: 'country',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -69,7 +70,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
name: 'region',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -99,7 +100,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
name: 'city',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -114,7 +115,10 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button

View File

@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -13,7 +14,7 @@ interface OverviewTopPagesProps {
projectId: string;
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, previous, startDate, endDate } =
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
@@ -38,7 +39,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
name: 'path',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
@@ -68,7 +69,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
name: 'path',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
@@ -98,7 +99,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
name: 'path',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
@@ -113,7 +114,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button

View File

@@ -5,6 +5,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -15,7 +16,7 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, previous, startDate, endDate } =
const { interval, range, previous, startDate, endDate, chartType } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
@@ -41,7 +42,7 @@ export default function OverviewTopSources({
name: 'referrer_name',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top groups',
@@ -71,7 +72,7 @@ export default function OverviewTopSources({
name: 'referrer',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -101,7 +102,7 @@ export default function OverviewTopSources({
name: 'referrer_type',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top types',
@@ -131,7 +132,7 @@ export default function OverviewTopSources({
name: 'properties.query.utm_source',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -161,7 +162,7 @@ export default function OverviewTopSources({
name: 'properties.query.utm_medium',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -191,7 +192,7 @@ export default function OverviewTopSources({
name: 'properties.query.utm_campaign',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -221,7 +222,7 @@ export default function OverviewTopSources({
name: 'properties.query.utm_term',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -251,7 +252,7 @@ export default function OverviewTopSources({
name: 'properties.query.utm_content',
},
],
chartType: 'bar',
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
@@ -266,7 +267,11 @@ export default function OverviewTopSources({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<div className="title">
{widget.title}
<OverviewChartToggle />
</div>
<WidgetButtons>
{widgets.map((w) => (
<button

View File

@@ -19,7 +19,10 @@ import { WidgetHead as WidgetHeadBase } from '../Widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn('flex flex-col p-0 [&_.title]:p-4', className)}
className={cn(
'flex flex-col p-0 [&_.title]:p-4 [&_.title]:flex [&_.title]:justify-between [&_.title]:items-center',
className
)}
{...props}
/>
);

View File

@@ -16,6 +16,12 @@ import { mapKeys } from '@openpanel/validation';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {
const [chartType, setChartType] = useQueryState(
'ct',
parseAsStringEnum(['bar', 'linear'])
.withDefault('bar')
.withOptions(nuqsOptions)
);
const [previous, setPrevious] = useQueryState(
'compare',
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
@@ -68,5 +74,9 @@ export function useOverviewOptions() {
// Toggles
liveHistogram,
setLiveHistogram,
// Other
chartType,
setChartType,
};
}

View File

@@ -11,14 +11,14 @@ import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
extends VariantProps<typeof variants>,
Partial<Pick<IServiceProfile, 'avatar' | 'first_name'>> {
Partial<Pick<IServiceProfile, 'avatar' | 'firstName'>> {
className?: string;
}
const variants = cva('', {
variants: {
size: {
default: 'h-12 w-12 rounded-full [&>span]:rounded-full',
default: 'h-8 w-8 rounded [&>span]:rounded',
sm: 'h-6 w-6 rounded [&>span]:rounded',
xs: 'h-4 w-4 rounded [&>span]:rounded',
},
@@ -30,7 +30,7 @@ const variants = cva('', {
export function ProfileAvatar({
avatar,
first_name,
firstName,
className,
size,
}: ProfileAvatarProps) {
@@ -47,7 +47,7 @@ export function ProfileAvatar({
'bg-slate-200 text-slate-800'
)}
>
{first_name?.at(0) ?? '🫣'}
{firstName?.at(0) ?? '🫣'}
</AvatarFallback>
</Avatar>
);

View File

@@ -17,7 +17,7 @@ import {
import { NOT_SET_VALUE } from '@openpanel/constants';
interface SerieIconProps extends LucideProps {
name: string;
name?: string;
}
function getProxyImage(url: string) {
@@ -26,14 +26,16 @@ function getProxyImage(url: string) {
const createImageIcon = (url: string) => {
return function (props: LucideProps) {
return <img className="w-4 h-4 object-cover rounded" src={url} />;
return <img className="h-4 object-contain rounded-[2px]" src={url} />;
} as LucideIcon;
};
const createFlagIcon = (url: string) => {
return function (props: LucideProps) {
return (
<span className={`rounded !block !leading-[1rem] fi fi-${url}`}></span>
<span
className={`rounded-[2px] overflow-hidden !block !leading-[1rem] fi fi-${url}`}
></span>
);
} as LucideIcon;
};
@@ -92,6 +94,20 @@ const mapper: Record<string, LucideIcon> = {
),
snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
// OS
'mac os': createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/c/c9/Finder_Icon_macOS_Big_Sur.png'
),
windows: createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png'
),
ios: createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/9/96/IOS_17_logo.png'
),
android: createImageIcon(
'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png'
),
// Misc
mobile: SmartphoneIcon,
desktop: MonitorIcon,
@@ -220,6 +236,10 @@ const mapper: Record<string, LucideIcon> = {
export function SerieIcon({ name, ...props }: SerieIconProps) {
const Icon = useMemo(() => {
if (!name) {
return null;
}
const mapped = mapper[name.toLowerCase()] ?? null;
if (mapped) {
@@ -234,7 +254,7 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
}, [name]);
return Icon ? (
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
<div className="h-4 flex-shrink-0 relative [&_a]:![&_a]:!h-4 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} />
</div>
) : null;

View File

@@ -101,7 +101,7 @@ const SheetFooter = ({
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
'sticky bottom-0 left-0 right-0 mt-auto',
'sticky bottom-0 left-0 right-0 mt-auto bg-white',
className
)}
{...props}

View File

@@ -27,3 +27,17 @@ const TooltipContent = React.forwardRef<
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltiperProps {
asChild: boolean;
content: string;
children: React.ReactNode;
}
export function Tooltiper({ asChild, content, children }: TooltiperProps) {
return (
<Tooltip>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,34 @@
interface Props<T> {
columns: {
name: string;
render: (item: T) => React.ReactNode;
}[];
keyExtractor: (item: T) => string;
data: T[];
}
export function WidgetTable<T>({ columns, data, keyExtractor }: Props<T>) {
return (
<table className="w-full">
<thead className="bg-slate-50 border-b border-border text-slate-500 [&_th]:font-medium text-sm [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th:last-child]:text-right [&_th]:whitespace-nowrap">
<tr>
{columns.map((column) => (
<th key={column.name}>{column.name}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr
key={keyExtractor(item)}
className="text-sm border-b border-border last:border-0 [&_td]:p-4 [&_td:first-child]:text-left text-right"
>
{columns.map((column) => (
<td key={column.name}>{column.render(item)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}