responsive design and bug fixes
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
@@ -48,7 +49,9 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
|
"react-in-viewport": "1.0.0-alpha.30",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
|
"react-responsive": "^9.0.2",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.20",
|
"react-virtualized-auto-sizer": "^1.0.20",
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
|
|||||||
const profile = info.getValue();
|
const profile = info.getValue();
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
shallow
|
||||||
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -91,7 +92,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
|
|||||||
footer: () => 'Created At',
|
footer: () => 'Created At',
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}, []);
|
}, [params]);
|
||||||
|
|
||||||
return (
|
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 Link from 'next/link';
|
||||||
|
|
||||||
import { Container } from '../Container';
|
import { Container } from '../Container';
|
||||||
|
import { Breadcrumbs } from '../navbar/Breadcrumbs';
|
||||||
import { NavbarMenu } from '../navbar/NavbarMenu';
|
import { NavbarMenu } from '../navbar/NavbarMenu';
|
||||||
import { NavbarUserDropdown } from '../navbar/NavbarUserDropdown';
|
|
||||||
|
|
||||||
interface MainLayoutProps {
|
interface MainLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,23 +13,40 @@ interface MainLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MainLayout({ children, className }: MainLayoutProps) {
|
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
|
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
|
||||||
<nav className="border-b border-border">
|
<nav className="border-b border-border">
|
||||||
<Container className="flex h-20 items-center justify-between ">
|
<Container className="flex h-20 items-center justify-between ">
|
||||||
<Link href="/" className="text-3xl">
|
<Link shallow href="/" className="text-3xl">
|
||||||
mixan
|
mixan
|
||||||
</Link>
|
</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 />
|
<NavbarMenu />
|
||||||
<div>
|
|
||||||
<NavbarUserDropdown />
|
|
||||||
</div>
|
|
||||||
</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>
|
</Container>
|
||||||
</nav>
|
</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>
|
<Sidebar>
|
||||||
{links.map(({ href, label }) => (
|
{links.map(({ href, label }) => (
|
||||||
<Link
|
<Link
|
||||||
|
shallow
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -17,14 +16,17 @@ export function NavbarCreate() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm">Create</Button>
|
<button>Create</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end">
|
<DropdownMenuContent className="w-56" align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/${params.organization}/reports`}>
|
<Link
|
||||||
|
shallow
|
||||||
|
href={`/${params.organization}/${params.project}/reports`}
|
||||||
|
>
|
||||||
<LineChart className="mr-2 h-4 w-4" />
|
<LineChart className="mr-2 h-4 w-4" />
|
||||||
<span>Create a report</span>
|
<span>Create a report</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { NavbarCreate } from './NavbarCreate';
|
import { NavbarUserDropdown } from './NavbarUserDropdown';
|
||||||
|
|
||||||
export function NavbarMenu() {
|
export function NavbarMenu() {
|
||||||
const params = useOrganizationParams();
|
const params = useOrganizationParams();
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 items-center">
|
<div className={cn('flex gap-6 items-center text-sm', 'max-sm:flex-col')}>
|
||||||
<Link href={`/${params.organization}`}>Home</Link>
|
|
||||||
{params.project && (
|
{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
|
Events
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{params.project && (
|
{params.project && (
|
||||||
<Link href={`/${params.organization}/${params.project}/profiles`}>
|
<Link
|
||||||
|
shallow
|
||||||
|
href={`/${params.organization}/${params.project}/profiles`}
|
||||||
|
>
|
||||||
Profiles
|
Profiles
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<NavbarCreate />
|
{params.project && (
|
||||||
|
<Link
|
||||||
|
shallow
|
||||||
|
href={`/${params.organization}/${params.project}/reports`}
|
||||||
|
>
|
||||||
|
Create report
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<NavbarUserDropdown />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,25 +27,28 @@ export function NavbarUserDropdown() {
|
|||||||
<DropdownMenuContent align="end" className="w-[200px]">
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<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" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Organization
|
Organization
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<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" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Projects
|
Projects
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<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" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Clients
|
Clients
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<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" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -3,32 +3,23 @@ import type { IChartType } from '@/types';
|
|||||||
import { chartTypes } from '@/utils/constants';
|
import { chartTypes } from '@/utils/constants';
|
||||||
|
|
||||||
import { Combobox } from '../ui/combobox';
|
import { Combobox } from '../ui/combobox';
|
||||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
import { changeChartType } from './reportSlice';
|
||||||
import {
|
|
||||||
changeChartType,
|
|
||||||
changeDateRanges,
|
|
||||||
changeInterval,
|
|
||||||
} from './reportSlice';
|
|
||||||
|
|
||||||
export function ReportChartType() {
|
export function ReportChartType() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const type = useSelector((state) => state.report.chartType);
|
const type = useSelector((state) => state.report.chartType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Combobox
|
||||||
<div className="w-full max-w-[200px]">
|
placeholder="Chart type"
|
||||||
<Combobox
|
onChange={(value) => {
|
||||||
placeholder="Chart type"
|
dispatch(changeChartType(value as IChartType));
|
||||||
onChange={(value) => {
|
}}
|
||||||
dispatch(changeChartType(value as IChartType));
|
value={type}
|
||||||
}}
|
items={Object.entries(chartTypes).map(([key, value]) => ({
|
||||||
value={type}
|
label: value,
|
||||||
items={Object.entries(chartTypes).map(([key, value]) => ({
|
value: key,
|
||||||
label: value,
|
}))}
|
||||||
value: key,
|
/>
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,28 @@
|
|||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import type { IInterval } from '@/types';
|
import { timeRanges } from '@/utils/constants';
|
||||||
import { intervals, timeRanges } from '@/utils/constants';
|
|
||||||
|
|
||||||
import { Combobox } from '../ui/combobox';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||||
import { changeDateRanges, changeInterval } from './reportSlice';
|
import { changeDateRanges } from './reportSlice';
|
||||||
|
|
||||||
export function ReportDateRange() {
|
export function ReportDateRange() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const range = useSelector((state) => state.report.range);
|
const range = useSelector((state) => state.report.range);
|
||||||
const interval = useSelector((state) => state.report.interval);
|
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RadioGroup className="overflow-auto">
|
||||||
<RadioGroup>
|
{timeRanges.map((item) => {
|
||||||
{timeRanges.map((item) => {
|
return (
|
||||||
return (
|
<RadioGroupItem
|
||||||
<RadioGroupItem
|
key={item.range}
|
||||||
key={item.range}
|
active={item.range === range}
|
||||||
active={item.range === range}
|
onClick={() => {
|
||||||
onClick={() => {
|
dispatch(changeDateRanges(item.range));
|
||||||
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));
|
|
||||||
}}
|
}}
|
||||||
value={interval}
|
>
|
||||||
items={Object.entries(intervals).map(([key, value]) => ({
|
{item.title}
|
||||||
label: value,
|
</RadioGroupItem>
|
||||||
value: key,
|
);
|
||||||
}))}
|
})}
|
||||||
/>
|
</RadioGroup>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { useSelector } from '@/redux';
|
import { useSelector } from '@/redux';
|
||||||
import { api, handleError } from '@/utils/api';
|
import { api, handleError } from '@/utils/api';
|
||||||
|
import { SaveIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { useReportId } from '../hooks/useReportId';
|
import { useReportId } from './hooks/useReportId';
|
||||||
|
|
||||||
export function ReportSaveButton() {
|
export function ReportSaveButton() {
|
||||||
|
const params = useOrganizationParams();
|
||||||
const { reportId } = useReportId();
|
const { reportId } = useReportId();
|
||||||
const update = api.report.update.useMutation({
|
const update = api.report.update.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -27,10 +30,9 @@ export function ReportSaveButton() {
|
|||||||
update.mutate({
|
update.mutate({
|
||||||
reportId,
|
reportId,
|
||||||
report,
|
report,
|
||||||
dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd',
|
|
||||||
projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d',
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
icon={SaveIcon}
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
@@ -43,8 +45,9 @@ export function ReportSaveButton() {
|
|||||||
report,
|
report,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
icon={SaveIcon}
|
||||||
>
|
>
|
||||||
Create
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createContext, memo, useContext, useMemo } from 'react';
|
import { createContext, memo, useContext, useMemo } from 'react';
|
||||||
import { pick } from 'ramda';
|
|
||||||
|
|
||||||
interface ChartContextType {
|
export interface ChartContextType {
|
||||||
editMode: boolean;
|
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(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
debugTable: true,
|
// debugTable: true,
|
||||||
debugHeaders: true,
|
// debugHeaders: true,
|
||||||
debugColumns: true,
|
// debugColumns: true,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
|
|||||||
@@ -40,47 +40,52 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AutoSizer disableHeight>
|
<div className="max-sm:-mx-3">
|
||||||
{({ width }) => (
|
<AutoSizer disableHeight>
|
||||||
<LineChart width={width} height={Math.min(width * 0.5, 400)}>
|
{({ width }) => (
|
||||||
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
|
<LineChart
|
||||||
<Tooltip content={<ReportLineChartTooltip />} />
|
width={width}
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||||
<XAxis
|
>
|
||||||
fontSize={12}
|
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
|
||||||
dataKey="date"
|
<Tooltip content={<ReportLineChartTooltip />} />
|
||||||
tickFormatter={(m: string) => {
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
return formatDate(m);
|
<XAxis
|
||||||
}}
|
fontSize={12}
|
||||||
tickLine={false}
|
dataKey="date"
|
||||||
allowDuplicatedCategory={false}
|
tickFormatter={(m: string) => {
|
||||||
/>
|
return formatDate(m);
|
||||||
{data?.series
|
}}
|
||||||
.filter((serie) => {
|
tickLine={false}
|
||||||
return visibleSeries.includes(serie.name);
|
allowDuplicatedCategory={false}
|
||||||
})
|
/>
|
||||||
.map((serie) => {
|
{data?.series
|
||||||
const realIndex = data?.series.findIndex(
|
.filter((serie) => {
|
||||||
(item) => item.name === serie.name
|
return visibleSeries.includes(serie.name);
|
||||||
);
|
})
|
||||||
const key = serie.name;
|
.map((serie) => {
|
||||||
const strokeColor = getChartColor(realIndex);
|
const realIndex = data?.series.findIndex(
|
||||||
return (
|
(item) => item.name === serie.name
|
||||||
<Line
|
);
|
||||||
type="monotone"
|
const key = serie.name;
|
||||||
key={key}
|
const strokeColor = getChartColor(realIndex);
|
||||||
isAnimationActive={false}
|
return (
|
||||||
strokeWidth={2}
|
<Line
|
||||||
dataKey="count"
|
type="monotone"
|
||||||
stroke={strokeColor}
|
key={key}
|
||||||
data={serie.data}
|
isAnimationActive={false}
|
||||||
name={serie.name}
|
strokeWidth={2}
|
||||||
/>
|
dataKey="count"
|
||||||
);
|
stroke={strokeColor}
|
||||||
})}
|
data={serie.data}
|
||||||
</LineChart>
|
name={serie.name}
|
||||||
)}
|
/>
|
||||||
</AutoSizer>
|
);
|
||||||
|
})}
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
{editMode && (
|
{editMode && (
|
||||||
<ReportTable
|
<ReportTable
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import type { IChartInput } from '@/types';
|
import type { IChartInput } from '@/types';
|
||||||
import { api } from '@/utils/api';
|
import { api } from '@/utils/api';
|
||||||
|
|
||||||
@@ -5,17 +7,18 @@ import { withChartProivder } from './ChartProvider';
|
|||||||
import { ReportBarChart } from './ReportBarChart';
|
import { ReportBarChart } from './ReportBarChart';
|
||||||
import { ReportLineChart } from './ReportLineChart';
|
import { ReportLineChart } from './ReportLineChart';
|
||||||
|
|
||||||
type ReportLineChartProps = IChartInput;
|
export type ReportChartProps = IChartInput;
|
||||||
|
|
||||||
export const Chart = withChartProivder(
|
export const Chart = memo(
|
||||||
({
|
withChartProivder(function Chart({
|
||||||
interval,
|
interval,
|
||||||
events,
|
events,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
chartType,
|
chartType,
|
||||||
name,
|
name,
|
||||||
range,
|
range,
|
||||||
}: ReportLineChartProps) => {
|
}: ReportChartProps) {
|
||||||
|
const params = useOrganizationParams();
|
||||||
const hasEmptyFilters = events.some((event) =>
|
const hasEmptyFilters = events.some((event) =>
|
||||||
event.filters.some((filter) => filter.value.length === 0)
|
event.filters.some((filter) => filter.value.length === 0)
|
||||||
);
|
);
|
||||||
@@ -29,6 +32,7 @@ export const Chart = withChartProivder(
|
|||||||
range,
|
range,
|
||||||
startDate: null,
|
startDate: null,
|
||||||
endDate: null,
|
endDate: null,
|
||||||
|
projectSlug: params.project,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
@@ -63,5 +67,5 @@ export const Chart = withChartProivder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <p>Chart type "{chartType}" is not supported yet.</p>;
|
return <p>Chart type "{chartType}" is not supported yet.</p>;
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { alphabetIds } from '@/utils/constants';
|
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
@@ -104,6 +104,13 @@ export const reportSlice = createSlice({
|
|||||||
// Chart type
|
// Chart type
|
||||||
changeChartType: (state, action: PayloadAction<IChartType>) => {
|
changeChartType: (state, action: PayloadAction<IChartType>) => {
|
||||||
state.chartType = action.payload;
|
state.chartType = action.payload;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||||
|
state.interval === 'minute'
|
||||||
|
) {
|
||||||
|
state.interval = 'hour';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ColorSquare } from '@/components/ColorSquare';
|
import { ColorSquare } from '@/components/ColorSquare';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { RenderDots } from '@/components/ui/RenderDots';
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import type { IChartBreakdown } from '@/types';
|
import type { IChartBreakdown } from '@/types';
|
||||||
import { api } from '@/utils/api';
|
import { api } from '@/utils/api';
|
||||||
@@ -10,9 +10,12 @@ import { ReportBreakdownMore } from './ReportBreakdownMore';
|
|||||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||||
|
|
||||||
export function ReportBreakdowns() {
|
export function ReportBreakdowns() {
|
||||||
|
const params = useOrganizationParams();
|
||||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const propertiesQuery = api.chart.properties.useQuery();
|
const propertiesQuery = api.chart.properties.useQuery({
|
||||||
|
projectSlug: params.project,
|
||||||
|
});
|
||||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||||
value: item,
|
value: item,
|
||||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
import { RenderDots } from '@/components/ui/RenderDots';
|
import { RenderDots } from '@/components/ui/RenderDots';
|
||||||
import { useMappings } from '@/hooks/useMappings';
|
import { useMappings } from '@/hooks/useMappings';
|
||||||
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { useDispatch } from '@/redux';
|
import { useDispatch } from '@/redux';
|
||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
@@ -37,10 +38,12 @@ export function ReportEventFilters({
|
|||||||
isCreating,
|
isCreating,
|
||||||
setIsCreating,
|
setIsCreating,
|
||||||
}: ReportEventFiltersProps) {
|
}: ReportEventFiltersProps) {
|
||||||
|
const params = useOrganizationParams();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const propertiesQuery = api.chart.properties.useQuery(
|
const propertiesQuery = api.chart.properties.useQuery(
|
||||||
{
|
{
|
||||||
event: event.name,
|
event: event.name,
|
||||||
|
projectSlug: params.project,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!event.name,
|
enabled: !!event.name,
|
||||||
@@ -99,11 +102,13 @@ interface FilterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Filter({ filter, event }: FilterProps) {
|
function Filter({ filter, event }: FilterProps) {
|
||||||
|
const params = useOrganizationParams();
|
||||||
const getLabel = useMappings();
|
const getLabel = useMappings();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const potentialValues = api.chart.values.useQuery({
|
const potentialValues = api.chart.values.useQuery({
|
||||||
event: event.name,
|
event: event.name,
|
||||||
property: filter.name,
|
property: filter.name,
|
||||||
|
projectSlug: params.project,
|
||||||
});
|
});
|
||||||
|
|
||||||
const valuesCombobox =
|
const valuesCombobox =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { ColorSquare } from '@/components/ColorSquare';
|
import { ColorSquare } from '@/components/ColorSquare';
|
||||||
import { Dropdown } from '@/components/Dropdown';
|
import { Dropdown } from '@/components/Dropdown';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import type { IChartEvent } from '@/types';
|
import type { IChartEvent } from '@/types';
|
||||||
import { api } from '@/utils/api';
|
import { api } from '@/utils/api';
|
||||||
@@ -16,7 +17,10 @@ export function ReportEvents() {
|
|||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const selectedEvents = useSelector((state) => state.report.events);
|
const selectedEvents = useSelector((state) => state.report.events);
|
||||||
const dispatch = useDispatch();
|
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) => ({
|
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||||
value: item.name,
|
value: item.name,
|
||||||
label: 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 { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportEvents } from './ReportEvents';
|
import { ReportEvents } from './ReportEvents';
|
||||||
import { ReportSaveButton } from './ReportSaveButton';
|
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
<ReportBreakdowns />
|
<ReportBreakdowns />
|
||||||
<ReportSaveButton />
|
<SheetClose asChild>
|
||||||
|
<Button>Done</Button>
|
||||||
|
</SheetClose>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Avatar = React.forwardRef<
|
|||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
@@ -39,6 +40,7 @@ interface ButtonProps
|
|||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
icon?: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
@@ -51,11 +53,13 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
children,
|
children,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
|
icon,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
const Icon = loading ? Loader2 : icon ?? null;
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
@@ -63,7 +67,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
disabled={loading ?? disabled}
|
disabled={loading ?? disabled}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="animate-spin" /> : <>{children}</>}
|
{Icon && (
|
||||||
|
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
</Comp>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ScrollArea } from './scroll-area';
|
||||||
|
|
||||||
interface ComboboxProps {
|
interface ComboboxProps {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
items: {
|
items: {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
}[];
|
}[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -51,7 +54,7 @@ export function Combobox({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
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">
|
<span className="overflow-hidden text-ellipsis">
|
||||||
{value ? find(value)?.label ?? 'No match' : placeholder}
|
{value ? find(value)?.label ?? 'No match' : placeholder}
|
||||||
@@ -82,27 +85,30 @@ export function Combobox({
|
|||||||
) : (
|
) : (
|
||||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<div className="max-h-[300px] overflow-scroll">
|
||||||
{items.map((item) => (
|
<CommandGroup>
|
||||||
<CommandItem
|
{items.map((item) => (
|
||||||
key={item.value}
|
<CommandItem
|
||||||
value={item.value}
|
key={item.value}
|
||||||
onSelect={(currentValue) => {
|
value={item.value}
|
||||||
const value = find(currentValue)?.value ?? currentValue;
|
onSelect={(currentValue) => {
|
||||||
onChange(value);
|
const value = find(currentValue)?.value ?? currentValue;
|
||||||
setOpen(false);
|
onChange(value);
|
||||||
}}
|
setOpen(false);
|
||||||
>
|
}}
|
||||||
<Check
|
{...(item.disabled && { disabled: true })}
|
||||||
className={cn(
|
>
|
||||||
'mr-2 h-4 w-4 flex-shrink-0',
|
<Check
|
||||||
value === item.value ? 'opacity-100' : 'opacity-0'
|
className={cn(
|
||||||
)}
|
'mr-2 h-4 w-4 flex-shrink-0',
|
||||||
/>
|
value === item.value ? 'opacity-100' : 'opacity-0'
|
||||||
{item.label}
|
)}
|
||||||
</CommandItem>
|
/>
|
||||||
))}
|
{item.label}
|
||||||
</CommandGroup>
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -114,9 +114,10 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
|
data-disabled={props.disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</>
|
||||||
));
|
));
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
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,
|
||||||
|
};
|
||||||
29
apps/web/src/hooks/useBreakpoint.ts
Normal file
29
apps/web/src/hooks/useBreakpoint.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { theme } from '@/utils/theme';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
import type { ScreensConfig } from 'tailwindcss/types/config';
|
||||||
|
|
||||||
|
const breakpoints = theme?.screens ?? {
|
||||||
|
xs: '480px',
|
||||||
|
sm: '640px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '1024px',
|
||||||
|
xl: '1280px',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useBreakpoint<K extends string>(breakpointKey: K) {
|
||||||
|
const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig];
|
||||||
|
const bool = useMediaQuery({
|
||||||
|
query: `(max-width: ${breakpointValue})`,
|
||||||
|
});
|
||||||
|
const capitalizedKey =
|
||||||
|
breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1);
|
||||||
|
|
||||||
|
type KeyAbove = `isAbove${Capitalize<K>}`;
|
||||||
|
type KeyBelow = `isBelow${Capitalize<K>}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
[breakpointKey]: Number(String(breakpointValue).replace(/[^0-9]/g, '')),
|
||||||
|
[`isAbove${capitalizedKey}`]: !bool,
|
||||||
|
[`isBelow${capitalizedKey}`]: bool,
|
||||||
|
} as Record<K, number> & Record<KeyAbove | KeyBelow, boolean>;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import type { IChartInput } from '@/types';
|
|||||||
import { api, handleError } from '@/utils/api';
|
import { api, handleError } from '@/utils/api';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
@@ -23,7 +23,6 @@ interface SaveReportProps {
|
|||||||
|
|
||||||
const validator = z.object({
|
const validator = z.object({
|
||||||
name: z.string().min(1, 'Required'),
|
name: z.string().min(1, 'Required'),
|
||||||
projectId: z.string().min(1, 'Required'),
|
|
||||||
dashboardId: z.string().min(1, 'Required'),
|
dashboardId: z.string().min(1, 'Required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ type IForm = z.infer<typeof validator>;
|
|||||||
|
|
||||||
export default function SaveReport({ report }: SaveReportProps) {
|
export default function SaveReport({ report }: SaveReportProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { organization } = useOrganizationParams();
|
const { organization, project } = useOrganizationParams();
|
||||||
const refetch = useRefetchActive();
|
const refetch = useRefetchActive();
|
||||||
const save = api.report.save.useMutation({
|
const save = api.report.save.useMutation({
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
@@ -42,7 +41,7 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
});
|
});
|
||||||
popModal();
|
popModal();
|
||||||
refetch();
|
refetch();
|
||||||
router.push(`/${organization}/reports/${res.id}`);
|
router.push(`/${organization}/${project}/reports/${res.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
resolver: zodResolver(validator),
|
resolver: zodResolver(validator),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
projectId: '',
|
|
||||||
dashboardId: '',
|
dashboardId: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -68,29 +66,10 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectId = useWatch({
|
const dashboasrdQuery = api.dashboard.list.useQuery({
|
||||||
name: 'projectId',
|
projectSlug: project,
|
||||||
control,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectQuery = api.project.list.useQuery({
|
|
||||||
organizationSlug: organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dashboasrdQuery = api.dashboard.list.useQuery(
|
|
||||||
{
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!projectId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projects = (projectQuery.data ?? []).map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const dashboards = (dashboasrdQuery.data ?? []).map((item) => ({
|
const dashboards = (dashboasrdQuery.data ?? []).map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
label: item.name,
|
label: item.name,
|
||||||
@@ -117,22 +96,6 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
{...register('name')}
|
{...register('name')}
|
||||||
defaultValue={report.name}
|
defaultValue={report.name}
|
||||||
/>
|
/>
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="projectId"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Label>Project</Label>
|
|
||||||
<Combobox
|
|
||||||
{...field}
|
|
||||||
items={projects}
|
|
||||||
placeholder="Select a project"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="dashboardId"
|
name="dashboardId"
|
||||||
@@ -141,13 +104,12 @@ export default function SaveReport({ report }: SaveReportProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Label>Dashboard</Label>
|
<Label>Dashboard</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
disabled={!projectId}
|
|
||||||
{...field}
|
{...field}
|
||||||
items={dashboards}
|
items={dashboards}
|
||||||
placeholder="Select a dashboard"
|
placeholder="Select a dashboard"
|
||||||
onCreate={(value) => {
|
onCreate={(value) => {
|
||||||
dashboardMutation.mutate({
|
dashboardMutation.mutate({
|
||||||
projectId,
|
projectSlug: project,
|
||||||
name: value,
|
name: value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Suspense, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Container } from '@/components/Container';
|
import { Container } from '@/components/Container';
|
||||||
import { MainLayout } from '@/components/layouts/MainLayout';
|
import { MainLayout } from '@/components/layouts/MainLayout';
|
||||||
import { PageTitle } from '@/components/PageTitle';
|
import { PageTitle } from '@/components/PageTitle';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { createServerSideProps } from '@/server/getServerSideProps';
|
import { createServerSideProps } from '@/server/getServerSideProps';
|
||||||
import type { IChartRange } from '@/types';
|
import type { IChartRange } from '@/types';
|
||||||
import { api } from '@/utils/api';
|
import { api } from '@/utils/api';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
import { timeRanges } from '@/utils/constants';
|
import { timeRanges } from '@/utils/constants';
|
||||||
import { getRangeLabel } from '@/utils/getRangeLabel';
|
import { getRangeLabel } from '@/utils/getRangeLabel';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -32,59 +33,63 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container>
|
<Container>
|
||||||
<Suspense fallback="Loading">
|
<PageTitle>{dashboard?.name}</PageTitle>
|
||||||
<PageTitle>{dashboard?.name}</PageTitle>
|
|
||||||
|
|
||||||
<RadioGroup className="mb-8">
|
<RadioGroup className="mb-8 overflow-auto">
|
||||||
{timeRanges.map((item) => {
|
{timeRanges.map((item) => {
|
||||||
return (
|
return (
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
key={item.range}
|
key={item.range}
|
||||||
active={item.range === range}
|
active={item.range === range}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRange((p) => (p === item.range ? null : item.range));
|
setRange((p) => (p === item.range ? null : item.range));
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</RadioGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{reports.map((report) => {
|
||||||
|
const chartRange = getRangeLabel(report.range);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-border bg-white shadow"
|
||||||
|
key={report.id}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${params.organization}/${params.project}/reports/${report.id}`}
|
||||||
|
className="block border-b border-border p-4 leading-none hover:underline"
|
||||||
|
shallow
|
||||||
>
|
>
|
||||||
{item.title}
|
<div className="font-medium">{report.name}</div>
|
||||||
</RadioGroupItem>
|
{chartRange !== null && (
|
||||||
);
|
<div className="mt-2 text-sm flex gap-2">
|
||||||
})}
|
<span className={range !== null ? 'line-through' : ''}>
|
||||||
</RadioGroup>
|
{chartRange}
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{range !== null && <span>{getRangeLabel(range)}</span>}
|
||||||
{reports.map((report) => {
|
</div>
|
||||||
const chartRange = getRangeLabel(report.range);
|
)}
|
||||||
return (
|
</Link>
|
||||||
<div
|
<div
|
||||||
className="rounded-md border border-border bg-white shadow"
|
className={cn(
|
||||||
key={report.id}
|
'p-4 pl-2',
|
||||||
|
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
<LazyChart
|
||||||
href={`/${params.organization}/reports/${report.id}`}
|
{...report}
|
||||||
className="block border-b border-border p-4 leading-none hover:underline"
|
range={range ?? report.range}
|
||||||
>
|
editMode={false}
|
||||||
<div className="font-medium">{report.name}</div>
|
/>
|
||||||
{chartRange && (
|
|
||||||
<div className="mt-2 text-sm flex gap-2">
|
|
||||||
<span className={range ? 'line-through' : ''}>
|
|
||||||
{chartRange}
|
|
||||||
</span>
|
|
||||||
{range && <span>{getRangeLabel(range)}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<div className="aspect-[1.8/1] overflow-auto p-4 pl-2">
|
|
||||||
<Chart
|
|
||||||
{...report}
|
|
||||||
range={range ?? report.range}
|
|
||||||
editMode={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
</Suspense>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ export default function Home() {
|
|||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container>
|
<Container>
|
||||||
<PageTitle>Dashboards</PageTitle>
|
<PageTitle>Dashboards</PageTitle>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
{dashboards.map((item) => (
|
{dashboards.map((item) => (
|
||||||
<Card key={item.id}>
|
<Card key={item.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${params.organization}/${params.project}/${item.slug}`}
|
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 hover:underline"
|
||||||
|
shallow
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { PageTitle } from '@/components/PageTitle';
|
|||||||
import { usePagination } from '@/components/Pagination';
|
import { usePagination } from '@/components/Pagination';
|
||||||
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
|
||||||
import { useQueryParams } from '@/hooks/useQueryParams';
|
import { useQueryParams } from '@/hooks/useQueryParams';
|
||||||
|
import { createServerSideProps } from '@/server/getServerSideProps';
|
||||||
import { api } from '@/utils/api';
|
import { api } from '@/utils/api';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export default function ProfileId() {
|
export default function ProfileId() {
|
||||||
@@ -17,6 +19,9 @@ export default function ProfileId() {
|
|||||||
profileId: z.string(),
|
profileId: z.string(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const profileQuery = api.profile.get.useQuery({
|
||||||
|
id: profileId,
|
||||||
|
});
|
||||||
const eventsQuery = api.event.list.useQuery(
|
const eventsQuery = api.event.list.useQuery(
|
||||||
{
|
{
|
||||||
projectSlug: params.project,
|
projectSlug: params.project,
|
||||||
@@ -27,12 +32,14 @@ export default function ProfileId() {
|
|||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const profile = profileQuery.data ?? null;
|
||||||
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container>
|
<Container>
|
||||||
<PageTitle>Profile</PageTitle>
|
<PageTitle>{getProfileName(profile)}</PageTitle>
|
||||||
|
<pre>{JSON.stringify(profile?.properties, null, 2)}</pre>
|
||||||
<EventsTable data={events} pagination={pagination} />
|
<EventsTable data={events} pagination={pagination} />
|
||||||
</Container>
|
</Container>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function Events() {
|
|||||||
<Link
|
<Link
|
||||||
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
shallow
|
||||||
>
|
>
|
||||||
<Avatar className="h-6 w-6">
|
<Avatar className="h-6 w-6">
|
||||||
{profile?.avatar && <AvatarImage src={profile.avatar} />}
|
{profile?.avatar && <AvatarImage src={profile.avatar} />}
|
||||||
@@ -56,7 +57,16 @@ export default function Events() {
|
|||||||
{profile?.first_name?.at(0)}
|
{profile?.first_name?.at(0)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{`${profile?.first_name} ${profile?.last_name ?? ''}`}
|
<div className="flex flex-col">
|
||||||
|
<div>
|
||||||
|
{[profile?.first_name, profile?.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{profile.external_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { Container } from '@/components/Container';
|
||||||
import { MainLayout } from '@/components/layouts/MainLayout';
|
import { MainLayout } from '@/components/layouts/MainLayout';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
import { useReportId } from '@/components/report/hooks/useReportId';
|
import { useReportId } from '@/components/report/hooks/useReportId';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportDateRange } from '@/components/report/ReportDateRange';
|
import { ReportDateRange } from '@/components/report/ReportDateRange';
|
||||||
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
|
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||||
import { reset, setReport } from '@/components/report/reportSlice';
|
import { reset, setReport } from '@/components/report/reportSlice';
|
||||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
import { useRouterBeforeLeave } from '@/hooks/useRouterBeforeLeave';
|
import { useRouterBeforeLeave } from '@/hooks/useRouterBeforeLeave';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { createServerSideProps } from '@/server/getServerSideProps';
|
import { createServerSideProps } from '@/server/getServerSideProps';
|
||||||
@@ -39,18 +51,32 @@ export default function Page() {
|
|||||||
}, [reportId, reportQuery.data, dispatch]);
|
}, [reportId, reportQuery.data, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
<Sheet>
|
||||||
<div>
|
<MainLayout>
|
||||||
|
<Container>
|
||||||
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ReportDateRange />
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-between">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<ReportChartType />
|
||||||
|
<ReportInterval />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button size="default">Select events & Filters</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<ReportSaveButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Chart {...report} editMode />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</MainLayout>
|
||||||
|
<SheetContent className="!max-w-lg w-full">
|
||||||
<ReportSidebar />
|
<ReportSidebar />
|
||||||
</div>
|
</SheetContent>
|
||||||
<div className="flex flex-col gap-4 p-4">
|
</Sheet>
|
||||||
<div className="flex gap-4">
|
|
||||||
<ReportDateRange />
|
|
||||||
<ReportChartType />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Chart {...report} editMode />
|
|
||||||
</div>
|
|
||||||
</MainLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,12 +27,13 @@ export default function Home() {
|
|||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container>
|
<Container>
|
||||||
<PageTitle>Projects</PageTitle>
|
<PageTitle>Projects</PageTitle>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
{projects.map((item) => (
|
{projects.map((item) => (
|
||||||
<Card key={item.id}>
|
<Card key={item.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/${params.organization}/${item.slug}`}
|
href={`/${params.organization}/${item.slug}`}
|
||||||
className="block p-4 font-medium leading-none hover:underline"
|
className="block p-4 font-medium leading-none hover:underline"
|
||||||
|
shallow
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { organizationRouter } from './routers/organization';
|
|||||||
import { profileRouter } from './routers/profile';
|
import { profileRouter } from './routers/profile';
|
||||||
import { projectRouter } from './routers/project';
|
import { projectRouter } from './routers/project';
|
||||||
import { reportRouter } from './routers/report';
|
import { reportRouter } from './routers/report';
|
||||||
|
import { uiRouter } from './routers/ui';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
client: clientRouter,
|
client: clientRouter,
|
||||||
event: eventRouter,
|
event: eventRouter,
|
||||||
profile: profileRouter,
|
profile: profileRouter,
|
||||||
|
ui: uiRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
|
import * as cache from '@/server/cache';
|
||||||
import { db } from '@/server/db';
|
import { db } from '@/server/db';
|
||||||
|
import { getProjectBySlug } from '@/server/services/project.service';
|
||||||
import type { IChartEvent, IChartInputWithDates, IChartRange } from '@/types';
|
import type { IChartEvent, IChartInputWithDates, IChartRange } from '@/types';
|
||||||
import { getDaysOldDate } from '@/utils/date';
|
import { getDaysOldDate } from '@/utils/date';
|
||||||
import { toDots } from '@/utils/object';
|
import { toDots } from '@/utils/object';
|
||||||
@@ -14,28 +16,46 @@ export const config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const chartRouter = createTRPCRouter({
|
export const chartRouter = createTRPCRouter({
|
||||||
events: protectedProcedure.query(async () => {
|
events: protectedProcedure
|
||||||
const events = await db.event.findMany({
|
.input(z.object({ projectSlug: z.string() }))
|
||||||
take: 500,
|
.query(async ({ input: { projectSlug } }) => {
|
||||||
distinct: ['name'],
|
const project = await getProjectBySlug(projectSlug);
|
||||||
});
|
const events = await cache.getOr(
|
||||||
|
`events_${project.id}`,
|
||||||
|
1000 * 60 * 60,
|
||||||
|
() =>
|
||||||
|
db.event.findMany({
|
||||||
|
take: 500,
|
||||||
|
distinct: ['name'],
|
||||||
|
where: {
|
||||||
|
project_id: project.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
properties: protectedProcedure
|
properties: protectedProcedure
|
||||||
.input(z.object({ event: z.string() }).optional())
|
.input(z.object({ event: z.string().optional(), projectSlug: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input: { projectSlug, event } }) => {
|
||||||
const events = await db.event.findMany({
|
const project = await getProjectBySlug(projectSlug);
|
||||||
take: 500,
|
const events = await cache.getOr(
|
||||||
where: {
|
`events_${project.id}_${event ?? 'all'}`,
|
||||||
...(input?.event
|
1000 * 60 * 60,
|
||||||
? {
|
() =>
|
||||||
name: input.event,
|
db.event.findMany({
|
||||||
}
|
take: 500,
|
||||||
: {}),
|
where: {
|
||||||
},
|
project_id: project.id,
|
||||||
});
|
...(event
|
||||||
|
? {
|
||||||
|
name: event,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const properties = events
|
const properties = events
|
||||||
.reduce((acc, event) => {
|
.reduce((acc, event) => {
|
||||||
@@ -53,51 +73,69 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
values: protectedProcedure
|
values: protectedProcedure
|
||||||
.input(z.object({ event: z.string(), property: z.string() }))
|
.input(
|
||||||
.query(async ({ input }) => {
|
z.object({
|
||||||
if (isJsonPath(input.property)) {
|
event: z.string(),
|
||||||
|
property: z.string(),
|
||||||
|
projectSlug: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input: { event, property, projectSlug } }) => {
|
||||||
|
const project = await getProjectBySlug(projectSlug);
|
||||||
|
if (isJsonPath(property)) {
|
||||||
const events = await db.$queryRawUnsafe<{ value: string }[]>(
|
const events = await db.$queryRawUnsafe<{ value: string }[]>(
|
||||||
`SELECT ${selectJsonPath(
|
`SELECT ${selectJsonPath(
|
||||||
input.property
|
property
|
||||||
)} AS value from events WHERE name = '${
|
)} AS value from events WHERE project_id = '${
|
||||||
input.event
|
project.id
|
||||||
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
|
}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
`SELECT ${selectJsonPath(
|
||||||
|
property
|
||||||
|
)} AS value from events WHERE project_id = '${
|
||||||
|
project.id
|
||||||
|
}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values: uniq(events.map((item) => item.value)),
|
values: uniq(events.map((item) => item.value)),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const events = await db.event.findMany({
|
const events = await db.event.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: input.event,
|
project_id: project.id,
|
||||||
[input.property]: {
|
name: event,
|
||||||
|
[property]: {
|
||||||
not: null,
|
not: null,
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
|
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
distinct: input.property as any,
|
distinct: property as any,
|
||||||
select: {
|
select: {
|
||||||
[input.property]: true,
|
[property]: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values: uniq(events.map((item) => item[input.property]!)),
|
values: uniq(events.map((item) => item[property]!)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
chart: protectedProcedure
|
chart: protectedProcedure
|
||||||
.input(zChartInputWithDates)
|
.input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() })))
|
||||||
.query(async ({ input: { events, ...input } }) => {
|
.query(async ({ input: { projectSlug, events, ...input } }) => {
|
||||||
|
const project = await getProjectBySlug(projectSlug);
|
||||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
series.push(
|
series.push(
|
||||||
...(await getChartData({
|
...(await getChartData({
|
||||||
...input,
|
...input,
|
||||||
event,
|
event,
|
||||||
|
projectId: project.id,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,9 +265,12 @@ function getChartSql({
|
|||||||
interval,
|
interval,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
}: Omit<IGetChartDataInput, 'range'>) {
|
projectId,
|
||||||
|
}: Omit<IGetChartDataInput, 'range'> & {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
const select = [];
|
const select = [];
|
||||||
const where = [];
|
const where = [`project_id = '${projectId}'`];
|
||||||
const groupBy = [];
|
const groupBy = [];
|
||||||
const orderBy = [];
|
const orderBy = [];
|
||||||
|
|
||||||
@@ -352,7 +393,10 @@ async function getChartData({
|
|||||||
range,
|
range,
|
||||||
startDate: _startDate,
|
startDate: _startDate,
|
||||||
endDate: _endDate,
|
endDate: _endDate,
|
||||||
}: IGetChartDataInput) {
|
projectId,
|
||||||
|
}: IGetChartDataInput & {
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
const { startDate, endDate } =
|
const { startDate, endDate } =
|
||||||
_startDate && _endDate
|
_startDate && _endDate
|
||||||
? {
|
? {
|
||||||
@@ -368,6 +412,7 @@ async function getChartData({
|
|||||||
interval,
|
interval,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||||
@@ -381,6 +426,7 @@ async function getChartData({
|
|||||||
interval,
|
interval,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
projectId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -453,8 +499,8 @@ function fillEmptySpotsInTimeline(
|
|||||||
clonedStartDate.setMinutes(0, 0, 0);
|
clonedStartDate.setMinutes(0, 0, 0);
|
||||||
clonedEndDate.setMinutes(0, 0, 0);
|
clonedEndDate.setMinutes(0, 0, 0);
|
||||||
} else {
|
} else {
|
||||||
clonedStartDate.setHours(2, 0, 0, 0);
|
clonedStartDate.setUTCHours(0, 0, 0, 0);
|
||||||
clonedEndDate.setHours(2, 0, 0, 0);
|
clonedEndDate.setUTCHours(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force if interval is month and the start date is the same month as today
|
// Force if interval is month and the start date is the same month as today
|
||||||
|
|||||||
@@ -35,14 +35,15 @@ export const dashboardRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
projectId: z.string(),
|
projectSlug: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input: { projectId, name } }) => {
|
.mutation(async ({ input: { projectSlug, name } }) => {
|
||||||
|
const project = await getProjectBySlug(projectSlug);
|
||||||
return db.dashboard.create({
|
return db.dashboard.create({
|
||||||
data: {
|
data: {
|
||||||
slug: slug(name),
|
slug: slug(name),
|
||||||
project_id: projectId,
|
project_id: project.id,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,4 +34,17 @@ export const profileRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input: { id } }) => {
|
||||||
|
return db.profile.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
import { db } from '@/server/db';
|
import { db } from '@/server/db';
|
||||||
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||||
|
import { getProjectBySlug } from '@/server/services/project.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
@@ -20,11 +21,17 @@ export const projectRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z
|
||||||
id: z.string(),
|
.object({
|
||||||
})
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.or(z.object({ slug: z.string() }))
|
||||||
)
|
)
|
||||||
.query(({ input }) => {
|
.query(({ input }) => {
|
||||||
|
if ('slug' in input) {
|
||||||
|
return getProjectBySlug(input.slug);
|
||||||
|
}
|
||||||
|
|
||||||
return db.project.findUniqueOrThrow({
|
return db.project.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
|
|||||||
@@ -94,14 +94,18 @@ export const reportRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
report: zChartInput,
|
report: zChartInput,
|
||||||
projectId: z.string(),
|
|
||||||
dashboardId: z.string(),
|
dashboardId: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(({ input: { report, projectId, dashboardId } }) => {
|
.mutation(async ({ input: { report, dashboardId } }) => {
|
||||||
|
const dashboard = await db.dashboard.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id: dashboardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
return db.report.create({
|
return db.report.create({
|
||||||
data: {
|
data: {
|
||||||
project_id: projectId,
|
project_id: dashboard.project_id,
|
||||||
dashboard_id: dashboardId,
|
dashboard_id: dashboardId,
|
||||||
name: report.name,
|
name: report.name,
|
||||||
events: report.events,
|
events: report.events,
|
||||||
@@ -117,18 +121,14 @@ export const reportRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
reportId: z.string(),
|
reportId: z.string(),
|
||||||
report: zChartInput,
|
report: zChartInput,
|
||||||
projectId: z.string(),
|
|
||||||
dashboardId: z.string(),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
|
.mutation(({ input: { report, reportId } }) => {
|
||||||
return db.report.update({
|
return db.report.update({
|
||||||
where: {
|
where: {
|
||||||
id: reportId,
|
id: reportId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
project_id: projectId,
|
|
||||||
dashboard_id: dashboardId,
|
|
||||||
name: report.name,
|
name: report.name,
|
||||||
events: report.events,
|
events: report.events,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
|
|||||||
22
apps/web/src/server/api/routers/ui.ts
Normal file
22
apps/web/src/server/api/routers/ui.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
|
import { db } from '@/server/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
responseLimit: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uiRouter = createTRPCRouter({
|
||||||
|
breadcrumbs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input: { url } }) => {
|
||||||
|
const parts = url.split('/').filter(Boolean);
|
||||||
|
return parts;
|
||||||
|
}),
|
||||||
|
});
|
||||||
39
apps/web/src/server/cache.ts
Normal file
39
apps/web/src/server/cache.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const cache = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
expires: number;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function get(key: string) {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit) {
|
||||||
|
if (hit.expires > Date.now()) {
|
||||||
|
return hit.data;
|
||||||
|
}
|
||||||
|
cache.delete(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(key: string, expires: number, data: any) {
|
||||||
|
cache.set(key, {
|
||||||
|
expires: Date.now() + expires,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOr<T>(
|
||||||
|
key: string,
|
||||||
|
expires: number,
|
||||||
|
fn: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const hit = get(key);
|
||||||
|
if (hit) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
const data = await fn();
|
||||||
|
set(key, expires, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -45,3 +45,7 @@ export const timeRanges = [
|
|||||||
{ range: 180, title: '6mo' },
|
{ range: 180, title: '6mo' },
|
||||||
{ range: 365, title: '1y' },
|
{ range: 365, title: '1y' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export function isMinuteIntervalEnabledByRange(range: number) {
|
||||||
|
return range === 0.3 || range === 0.6;
|
||||||
|
}
|
||||||
|
|||||||
6
apps/web/src/utils/getters.ts
Normal file
6
apps/web/src/utils/getters.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Profile } from '@prisma/client';
|
||||||
|
|
||||||
|
export function getProfileName(profile: Profile | undefined | null) {
|
||||||
|
if (!profile) return '';
|
||||||
|
return [profile.first_name, profile.last_name].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -2,14 +2,16 @@ import resolveConfig from 'tailwindcss/resolveConfig';
|
|||||||
|
|
||||||
import tailwinConfig from '../../tailwind.config';
|
import tailwinConfig from '../../tailwind.config';
|
||||||
|
|
||||||
const config = resolveConfig<any>(tailwinConfig);
|
export const resolvedTailwindConfig = resolveConfig(tailwinConfig);
|
||||||
|
|
||||||
export const theme = config.theme;
|
export const theme = resolvedTailwindConfig.theme;
|
||||||
|
|
||||||
export function getChartColor(index: number): string {
|
export function getChartColor(index: number): string {
|
||||||
const chartColors: string[] = Object.keys(theme.colors ?? {})
|
const colors = theme?.colors ?? {};
|
||||||
|
const chartColors: string[] = Object.keys(colors)
|
||||||
.filter((key) => key.startsWith('chart-'))
|
.filter((key) => key.startsWith('chart-'))
|
||||||
.map((key) => theme.colors[key] as string);
|
.map((key) => colors[key])
|
||||||
|
.filter((item): item is string => typeof item === 'string');
|
||||||
|
|
||||||
return chartColors[index % chartColors.length]!;
|
return chartColors[index % chartColors.length]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ const config = {
|
|||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'accordion-down': {
|
'accordion-down': {
|
||||||
from: { height: 0 },
|
from: { height: '0px' },
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
'accordion-up': {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
to: { height: 0 },
|
to: { height: '0px' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
|||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-scroll-area':
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.0.2
|
specifier: ^1.0.2
|
||||||
version: 1.0.2(@types/react@18.2.34)(react@18.2.0)
|
version: 1.0.2(@types/react@18.2.34)(react@18.2.0)
|
||||||
@@ -132,9 +135,15 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.47.0
|
specifier: ^7.47.0
|
||||||
version: 7.47.0(react@18.2.0)
|
version: 7.47.0(react@18.2.0)
|
||||||
|
react-in-viewport:
|
||||||
|
specifier: 1.0.0-alpha.30
|
||||||
|
version: 1.0.0-alpha.30(react-dom@18.2.0)(react@18.2.0)
|
||||||
react-redux:
|
react-redux:
|
||||||
specifier: ^8.1.3
|
specifier: ^8.1.3
|
||||||
version: 8.1.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
|
version: 8.1.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
|
||||||
|
react-responsive:
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2(react@18.2.0)
|
||||||
react-syntax-highlighter:
|
react-syntax-highlighter:
|
||||||
specifier: ^15.5.0
|
specifier: ^15.5.0
|
||||||
version: 15.5.0(react@18.2.0)
|
version: 15.5.0(react@18.2.0)
|
||||||
@@ -1073,6 +1082,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Be5hoNF8k+lkB3uEMiCHbhbfF6aj1GnrTBnn5iYFT7GEr3TsOEp1soviEcBR0tYCgHbxjcIxJMhdbvxALJhAqg==}
|
resolution: {integrity: sha512-Be5hoNF8k+lkB3uEMiCHbhbfF6aj1GnrTBnn5iYFT7GEr3TsOEp1soviEcBR0tYCgHbxjcIxJMhdbvxALJhAqg==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
|
|
||||||
|
/@radix-ui/number@1.0.1:
|
||||||
|
resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==}
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.23.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/primitive@1.0.0:
|
/@radix-ui/primitive@1.0.0:
|
||||||
resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==}
|
resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1757,6 +1772,35 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-scroll-area@1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.23.2
|
||||||
|
'@radix-ui/number': 1.0.1
|
||||||
|
'@radix-ui/primitive': 1.0.1
|
||||||
|
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@radix-ui/react-context': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@types/react': 18.2.34
|
||||||
|
'@types/react-dom': 18.2.14
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-slot@1.0.0(react@18.2.0):
|
/@radix-ui/react-slot@1.0.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
|
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2841,6 +2885,10 @@ packages:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
/css-mediaquery@0.1.2:
|
||||||
|
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/cssesc@3.0.0:
|
/cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3766,6 +3814,10 @@ packages:
|
|||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/hyphenate-style-name@1.0.4:
|
||||||
|
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/ignore@5.2.4:
|
/ignore@5.2.4:
|
||||||
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
|
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -4174,6 +4226,12 @@ packages:
|
|||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/matchmediaquery@0.3.1:
|
||||||
|
resolution: {integrity: sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==}
|
||||||
|
dependencies:
|
||||||
|
css-mediaquery: 0.1.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/merge-stream@2.0.0:
|
/merge-stream@2.0.0:
|
||||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -4770,6 +4828,17 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-in-viewport@1.0.0-alpha.30(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-fmjnsfWM+D739UKgQNY9E03Bjf+q9iTwMqwqXboThlfYZ7yLWXEGvTNsCPKwDYlrPinDX8/nZl4wAABsc5OH7w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.3 || ^17.0.0 || ^18.0.0
|
||||||
|
react-dom: ^16.8.3 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
hoist-non-react-statics: 3.3.2
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-is@16.13.1:
|
/react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
dev: false
|
dev: false
|
||||||
@@ -4881,6 +4950,19 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-responsive@9.0.2(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
hyphenate-style-name: 1.0.4
|
||||||
|
matchmediaquery: 0.3.1
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
shallow-equal: 1.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-smooth@2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0):
|
/react-smooth@2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==}
|
resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5175,6 +5257,10 @@ packages:
|
|||||||
has-property-descriptors: 1.0.1
|
has-property-descriptors: 1.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/shallow-equal@1.2.1:
|
||||||
|
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/shebang-command@2.0.0:
|
/shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|||||||
Reference in New Issue
Block a user