responsive design and bug fixes
This commit is contained in:
@@ -47,6 +47,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
|
||||
const profile = info.getValue();
|
||||
return (
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -91,7 +92,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
|
||||
footer: () => 'Created At',
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [params]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Container } from '../Container';
|
||||
import { Breadcrumbs } from '../navbar/Breadcrumbs';
|
||||
import { NavbarMenu } from '../navbar/NavbarMenu';
|
||||
import { NavbarUserDropdown } from '../navbar/NavbarUserDropdown';
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -10,23 +13,40 @@ interface MainLayoutProps {
|
||||
}
|
||||
|
||||
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
|
||||
<nav className="border-b border-border">
|
||||
<Container className="flex h-20 items-center justify-between ">
|
||||
<Link href="/" className="text-3xl">
|
||||
<Link shallow href="/" className="text-3xl">
|
||||
mixan
|
||||
</Link>
|
||||
<div className="flex items-center gap-8">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-8 z-50',
|
||||
visible === false && 'max-sm:hidden',
|
||||
visible === true &&
|
||||
'max-sm:flex max-sm:flex-col max-sm:absolute max-sm:inset-0 max-sm:bg-white max-sm:justify-center max-sm:top-4 max-sm:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<NavbarMenu />
|
||||
<div>
|
||||
<NavbarUserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
'px-4 sm:hidden absolute z-50 top-9 right-4 transition-all',
|
||||
visible === true && 'rotate-90'
|
||||
)}
|
||||
onClick={() => {
|
||||
setVisible((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</Container>
|
||||
</nav>
|
||||
<main className={className}>{children}</main>
|
||||
<Breadcrumbs />
|
||||
<main className={cn(className, 'mb-8')}>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function SettingsLayout({ children, className }: SettingsLayoutProps) {
|
||||
<Sidebar>
|
||||
{links.map(({ href, label }) => (
|
||||
<Link
|
||||
shallow
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
|
||||
58
apps/web/src/components/navbar/Breadcrumbs.tsx
Normal file
58
apps/web/src/components/navbar/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { api } from '@/utils/api';
|
||||
import { ChevronRight, HomeIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Container } from '../Container';
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const params = useOrganizationParams();
|
||||
|
||||
const org = api.organization.get.useQuery(
|
||||
{
|
||||
slug: params.organization,
|
||||
},
|
||||
{
|
||||
enabled: !!params.organization,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
const pro = api.project.get.useQuery(
|
||||
{
|
||||
slug: params.project,
|
||||
},
|
||||
{
|
||||
enabled: !!params.project,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border text-xs">
|
||||
<Container className="flex items-center gap-2 h-8">
|
||||
{org.isLoading && pro.isLoading && (
|
||||
<div className="animate-pulse bg-slate-200 h-4 w-24 rounded"></div>
|
||||
)}
|
||||
{org.data && (
|
||||
<>
|
||||
<HomeIcon size={14} />
|
||||
<Link shallow href={`/${org.data.slug}`}>
|
||||
{org.data.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{org.data && pro.data && (
|
||||
<>
|
||||
<ChevronRight size={10} />
|
||||
<Link shallow href={`/${org.data.slug}/${pro.data.slug}`}>
|
||||
{pro.data.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,14 +16,17 @@ export function NavbarCreate() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm">Create</Button>
|
||||
<button>Create</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${params.organization}/reports`}>
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/reports`}
|
||||
>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
<span>Create a report</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NavbarCreate } from './NavbarCreate';
|
||||
import { NavbarUserDropdown } from './NavbarUserDropdown';
|
||||
|
||||
export function NavbarMenu() {
|
||||
const params = useOrganizationParams();
|
||||
return (
|
||||
<div className="flex gap-6 items-center">
|
||||
<Link href={`/${params.organization}`}>Home</Link>
|
||||
<div className={cn('flex gap-6 items-center text-sm', 'max-sm:flex-col')}>
|
||||
{params.project && (
|
||||
<Link href={`/${params.organization}/${params.project}/events`}>
|
||||
<Link shallow href={`/${params.organization}/${params.project}`}>
|
||||
Home
|
||||
</Link>
|
||||
)}
|
||||
{params.project && (
|
||||
<Link shallow href={`/${params.organization}/${params.project}/events`}>
|
||||
Events
|
||||
</Link>
|
||||
)}
|
||||
{params.project && (
|
||||
<Link href={`/${params.organization}/${params.project}/profiles`}>
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/profiles`}
|
||||
>
|
||||
Profiles
|
||||
</Link>
|
||||
)}
|
||||
<NavbarCreate />
|
||||
{params.project && (
|
||||
<Link
|
||||
shallow
|
||||
href={`/${params.organization}/${params.project}/reports`}
|
||||
>
|
||||
Create report
|
||||
</Link>
|
||||
)}
|
||||
<NavbarUserDropdown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,25 +27,28 @@ export function NavbarUserDropdown() {
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/organization`}>
|
||||
<Link
|
||||
href={`/${params.organization}/settings/organization`}
|
||||
shallow
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Organization
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/projects`}>
|
||||
<Link href={`/${params.organization}/settings/projects`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/clients`}>
|
||||
<Link href={`/${params.organization}/settings/clients`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Clients
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link href={`/${params.organization}/settings/profile`}>
|
||||
<Link href={`/${params.organization}/settings/profile`} shallow>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
@@ -3,32 +3,23 @@ import type { IChartType } from '@/types';
|
||||
import { chartTypes } from '@/utils/constants';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import {
|
||||
changeChartType,
|
||||
changeDateRanges,
|
||||
changeInterval,
|
||||
} from './reportSlice';
|
||||
import { changeChartType } from './reportSlice';
|
||||
|
||||
export function ReportChartType() {
|
||||
const dispatch = useDispatch();
|
||||
const type = useSelector((state) => state.report.chartType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-[200px]">
|
||||
<Combobox
|
||||
placeholder="Chart type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeChartType(value as IChartType));
|
||||
}}
|
||||
value={type}
|
||||
items={Object.entries(chartTypes).map(([key, value]) => ({
|
||||
label: value,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<Combobox
|
||||
placeholder="Chart type"
|
||||
onChange={(value) => {
|
||||
dispatch(changeChartType(value as IChartType));
|
||||
}}
|
||||
value={type}
|
||||
items={Object.entries(chartTypes).map(([key, value]) => ({
|
||||
label: value,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,28 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IInterval } from '@/types';
|
||||
import { intervals, timeRanges } from '@/utils/constants';
|
||||
import { timeRanges } from '@/utils/constants';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import { changeDateRanges, changeInterval } from './reportSlice';
|
||||
import { changeDateRanges } from './reportSlice';
|
||||
|
||||
export function ReportDateRange() {
|
||||
const dispatch = useDispatch();
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RadioGroup>
|
||||
{timeRanges.map((item) => {
|
||||
return (
|
||||
<RadioGroupItem
|
||||
key={item.range}
|
||||
active={item.range === range}
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(item.range));
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</RadioGroupItem>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
{chartType === 'linear' && (
|
||||
<div className="w-full max-w-[200px]">
|
||||
<Combobox
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
<RadioGroup className="overflow-auto">
|
||||
{timeRanges.map((item) => {
|
||||
return (
|
||||
<RadioGroupItem
|
||||
key={item.range}
|
||||
active={item.range === range}
|
||||
onClick={() => {
|
||||
dispatch(changeDateRanges(item.range));
|
||||
}}
|
||||
value={interval}
|
||||
items={Object.entries(intervals).map(([key, value]) => ({
|
||||
label: value,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
>
|
||||
{item.title}
|
||||
</RadioGroupItem>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/report/ReportInterval.tsx
Normal file
46
apps/web/src/components/report/ReportInterval.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IInterval } from '@/types';
|
||||
import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { changeInterval } from './reportSlice';
|
||||
|
||||
export function ReportInterval() {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
if (chartType !== 'linear') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
value: 'minute',
|
||||
label: 'Minute',
|
||||
disabled: !isMinuteIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
disabled: range < 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useSelector } from '@/redux';
|
||||
import { api, handleError } from '@/utils/api';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
|
||||
import { useReportId } from '../hooks/useReportId';
|
||||
import { useReportId } from './hooks/useReportId';
|
||||
|
||||
export function ReportSaveButton() {
|
||||
const params = useOrganizationParams();
|
||||
const { reportId } = useReportId();
|
||||
const update = api.report.update.useMutation({
|
||||
onSuccess() {
|
||||
@@ -27,10 +30,9 @@ export function ReportSaveButton() {
|
||||
update.mutate({
|
||||
reportId,
|
||||
report,
|
||||
dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd',
|
||||
projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d',
|
||||
});
|
||||
}}
|
||||
icon={SaveIcon}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
@@ -43,8 +45,9 @@ export function ReportSaveButton() {
|
||||
report,
|
||||
});
|
||||
}}
|
||||
icon={SaveIcon}
|
||||
>
|
||||
Create
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
import { pick } from 'ramda';
|
||||
|
||||
interface ChartContextType {
|
||||
export interface ChartContextType {
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
|
||||
30
apps/web/src/components/report/chart/LazyChart.tsx
Normal file
30
apps/web/src/components/report/chart/LazyChart.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import type { ReportChartProps } from '.';
|
||||
import { Chart } from '.';
|
||||
import type { ChartContextType } from './ChartProvider';
|
||||
|
||||
export function LazyChart(props: ReportChartProps & ChartContextType) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(ref, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewport) {
|
||||
once.current = true;
|
||||
}
|
||||
}, [inViewport]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{once.current || inViewport ? (
|
||||
<Chart {...props} editMode={false} />
|
||||
) : (
|
||||
<div className="h-64 w-full bg-gray-200 animate-pulse rounded" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,9 +93,9 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
debugTable: true,
|
||||
debugHeaders: true,
|
||||
debugColumns: true,
|
||||
// debugTable: true,
|
||||
// debugHeaders: true,
|
||||
// debugColumns: true,
|
||||
});
|
||||
return (
|
||||
<div ref={ref}>
|
||||
|
||||
@@ -40,47 +40,52 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<LineChart width={width} height={Math.min(width * 0.5, 400)}>
|
||||
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => {
|
||||
return formatDate(m);
|
||||
}}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
{data?.series
|
||||
.filter((serie) => {
|
||||
return visibleSeries.includes(serie.name);
|
||||
})
|
||||
.map((serie) => {
|
||||
const realIndex = data?.series.findIndex(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
const key = serie.name;
|
||||
const strokeColor = getChartColor(realIndex);
|
||||
return (
|
||||
<Line
|
||||
type="monotone"
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey="count"
|
||||
stroke={strokeColor}
|
||||
data={serie.data}
|
||||
name={serie.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
<div className="max-sm:-mx-3">
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<LineChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
>
|
||||
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => {
|
||||
return formatDate(m);
|
||||
}}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
{data?.series
|
||||
.filter((serie) => {
|
||||
return visibleSeries.includes(serie.name);
|
||||
})
|
||||
.map((serie) => {
|
||||
const realIndex = data?.series.findIndex(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
const key = serie.name;
|
||||
const strokeColor = getChartColor(realIndex);
|
||||
return (
|
||||
<Line
|
||||
type="monotone"
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey="count"
|
||||
stroke={strokeColor}
|
||||
data={serie.data}
|
||||
name={serie.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{editMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
|
||||
@@ -5,17 +7,18 @@ import { withChartProivder } from './ChartProvider';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
import { ReportLineChart } from './ReportLineChart';
|
||||
|
||||
type ReportLineChartProps = IChartInput;
|
||||
export type ReportChartProps = IChartInput;
|
||||
|
||||
export const Chart = withChartProivder(
|
||||
({
|
||||
export const Chart = memo(
|
||||
withChartProivder(function Chart({
|
||||
interval,
|
||||
events,
|
||||
breakdowns,
|
||||
chartType,
|
||||
name,
|
||||
range,
|
||||
}: ReportLineChartProps) => {
|
||||
}: ReportChartProps) {
|
||||
const params = useOrganizationParams();
|
||||
const hasEmptyFilters = events.some((event) =>
|
||||
event.filters.some((filter) => filter.value.length === 0)
|
||||
);
|
||||
@@ -29,6 +32,7 @@ export const Chart = withChartProivder(
|
||||
range,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
projectSlug: params.project,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
@@ -63,5 +67,5 @@ export const Chart = withChartProivder(
|
||||
}
|
||||
|
||||
return <p>Chart type "{chartType}" is not supported yet.</p>;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
IChartType,
|
||||
IInterval,
|
||||
} from '@/types';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
@@ -104,6 +104,13 @@ export const reportSlice = createSlice({
|
||||
// Chart type
|
||||
changeChartType: (state, action: PayloadAction<IChartType>) => {
|
||||
state.chartType = action.payload;
|
||||
|
||||
if (
|
||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'minute'
|
||||
) {
|
||||
state.interval = 'hour';
|
||||
}
|
||||
},
|
||||
|
||||
// Date range
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartBreakdown } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
@@ -10,9 +10,12 @@ import { ReportBreakdownMore } from './ReportBreakdownMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const params = useOrganizationParams();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery();
|
||||
const propertiesQuery = api.chart.properties.useQuery({
|
||||
projectSlug: params.project,
|
||||
});
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/components/ui/command';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type {
|
||||
IChartEvent,
|
||||
@@ -37,10 +38,12 @@ export function ReportEventFilters({
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
}: ReportEventFiltersProps) {
|
||||
const params = useOrganizationParams();
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
projectSlug: params.project,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
@@ -99,11 +102,13 @@ interface FilterProps {
|
||||
}
|
||||
|
||||
function Filter({ filter, event }: FilterProps) {
|
||||
const params = useOrganizationParams();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
projectSlug: params.project,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Dropdown } from '@/components/Dropdown';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { api } from '@/utils/api';
|
||||
@@ -16,7 +17,10 @@ export function ReportEvents() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const eventsQuery = api.chart.events.useQuery();
|
||||
const params = useOrganizationParams();
|
||||
const eventsQuery = api.chart.events.useQuery({
|
||||
projectSlug: params.project,
|
||||
});
|
||||
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SheetClose } from '@/components/ui/sheet';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportSaveButton } from './ReportSaveButton';
|
||||
|
||||
export function ReportSidebar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
<ReportBreakdowns />
|
||||
<ReportSaveButton />
|
||||
<SheetClose asChild>
|
||||
<Button>Done</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Avatar = React.forwardRef<
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full',
|
||||
'relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
@@ -39,6 +40,7 @@ interface ButtonProps
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
@@ -51,11 +53,13 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
children,
|
||||
loading,
|
||||
disabled,
|
||||
icon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const Icon = loading ? Loader2 : icon ?? null;
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
@@ -63,7 +67,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
disabled={loading ?? disabled}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <>{children}</>}
|
||||
{Icon && (
|
||||
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
interface ComboboxProps {
|
||||
placeholder: string;
|
||||
items: {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -51,7 +54,7 @@ export function Combobox({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full min-w-0 justify-between"
|
||||
className="w-full justify-between min-w-[150px]"
|
||||
>
|
||||
<span className="overflow-hidden text-ellipsis">
|
||||
{value ? find(value)?.label ?? 'No match' : placeholder}
|
||||
@@ -82,27 +85,30 @@ export function Combobox({
|
||||
) : (
|
||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
const value = find(currentValue)?.value ?? currentValue;
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 flex-shrink-0',
|
||||
value === item.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<div className="max-h-[300px] overflow-scroll">
|
||||
<CommandGroup>
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
const value = find(currentValue)?.value ?? currentValue;
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
{...(item.disabled && { disabled: true })}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 flex-shrink-0',
|
||||
value === item.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -114,9 +114,10 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-disabled={props.disabled}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
</>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
|
||||
51
apps/web/src/components/ui/scroll-area.tsx
Normal file
51
apps/web/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
className={cn(
|
||||
"relative rounded-full bg-border",
|
||||
orientation === "vertical" && "flex-1"
|
||||
)}
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
138
apps/web/src/components/ui/sheet.tsx
Normal file
138
apps/web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay className="backdrop-blur-none bg-transparent" />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
Reference in New Issue
Block a user