From f5670253bcca38cbd952ec2e0a9ee6b36f5b2cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 4 Nov 2023 10:01:22 +0100 Subject: [PATCH] responsive design and bug fixes --- apps/web/package.json | 3 + .../web/src/components/events/EventsTable.tsx | 3 +- .../web/src/components/layouts/MainLayout.tsx | 34 ++++- .../src/components/layouts/SettingsLayout.tsx | 1 + .../web/src/components/navbar/Breadcrumbs.tsx | 58 ++++++++ .../src/components/navbar/NavbarCreate.tsx | 8 +- apps/web/src/components/navbar/NavbarMenu.tsx | 28 +++- .../components/navbar/NavbarUserDropdown.tsx | 11 +- .../src/components/report/ReportChartType.tsx | 33 ++--- .../src/components/report/ReportDateRange.tsx | 53 ++----- .../src/components/report/ReportInterval.tsx | 46 ++++++ .../report/{sidebar => }/ReportSaveButton.tsx | 11 +- .../components/report/chart/ChartProvider.tsx | 3 +- .../src/components/report/chart/LazyChart.tsx | 30 ++++ .../report/chart/ReportBarChart.tsx | 6 +- .../report/chart/ReportLineChart.tsx | 87 +++++------ .../web/src/components/report/chart/index.tsx | 14 +- apps/web/src/components/report/reportSlice.ts | 9 +- .../report/sidebar/ReportBreakdowns.tsx | 7 +- .../report/sidebar/ReportEventFilters.tsx | 5 + .../report/sidebar/ReportEvents.tsx | 6 +- .../report/sidebar/ReportSidebar.tsx | 10 +- apps/web/src/components/ui/avatar.tsx | 2 +- apps/web/src/components/ui/button.tsx | 9 +- apps/web/src/components/ui/combobox.tsx | 50 ++++--- apps/web/src/components/ui/command.tsx | 3 +- apps/web/src/components/ui/popover.tsx | 4 +- apps/web/src/components/ui/scroll-area.tsx | 51 +++++++ apps/web/src/components/ui/sheet.tsx | 138 ++++++++++++++++++ apps/web/src/hooks/useBreakpoint.ts | 29 ++++ apps/web/src/modals/SaveReport.tsx | 50 +------ .../[organization]/[project]/[dashboard].tsx | 105 ++++++------- .../pages/[organization]/[project]/index.tsx | 3 +- .../[project]/profiles/[profileId].tsx | 9 +- .../[project]/profiles/index.tsx | 12 +- .../{ => [project]}/reports/[reportId].tsx | 0 .../{ => [project]}/reports/index.tsx | 50 +++++-- apps/web/src/pages/[organization]/index.tsx | 3 +- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/chart.ts | 122 +++++++++++----- apps/web/src/server/api/routers/dashboard.ts | 7 +- apps/web/src/server/api/routers/profile.ts | 13 ++ apps/web/src/server/api/routers/project.ts | 13 +- apps/web/src/server/api/routers/report.ts | 16 +- apps/web/src/server/api/routers/ui.ts | 22 +++ apps/web/src/server/cache.ts | 39 +++++ apps/web/src/utils/constants.ts | 4 + apps/web/src/utils/getters.ts | 6 + apps/web/src/utils/theme.ts | 10 +- ...{tailwind.config.ts => tailwind.config.js} | 4 +- pnpm-lock.yaml | 86 +++++++++++ 51 files changed, 992 insertions(+), 336 deletions(-) create mode 100644 apps/web/src/components/navbar/Breadcrumbs.tsx create mode 100644 apps/web/src/components/report/ReportInterval.tsx rename apps/web/src/components/report/{sidebar => }/ReportSaveButton.tsx (78%) create mode 100644 apps/web/src/components/report/chart/LazyChart.tsx create mode 100644 apps/web/src/components/ui/scroll-area.tsx create mode 100644 apps/web/src/components/ui/sheet.tsx create mode 100644 apps/web/src/hooks/useBreakpoint.ts rename apps/web/src/pages/[organization]/{ => [project]}/reports/[reportId].tsx (100%) rename apps/web/src/pages/[organization]/{ => [project]}/reports/index.tsx (53%) create mode 100644 apps/web/src/server/api/routers/ui.ts create mode 100644 apps/web/src/server/cache.ts create mode 100644 apps/web/src/utils/getters.ts rename apps/web/{tailwind.config.ts => tailwind.config.js} (97%) diff --git a/apps/web/package.json b/apps/web/package.json index acd01be0..0d850637 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@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-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", @@ -48,7 +49,9 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.47.0", + "react-in-viewport": "1.0.0-alpha.30", "react-redux": "^8.1.3", + "react-responsive": "^9.0.2", "react-syntax-highlighter": "^15.5.0", "react-virtualized-auto-sizer": "^1.0.20", "recharts": "^2.8.0", diff --git a/apps/web/src/components/events/EventsTable.tsx b/apps/web/src/components/events/EventsTable.tsx index 6d91f06b..163bba72 100644 --- a/apps/web/src/components/events/EventsTable.tsx +++ b/apps/web/src/components/events/EventsTable.tsx @@ -47,6 +47,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) { const profile = info.getValue(); return ( @@ -91,7 +92,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) { footer: () => 'Created At', }), ]; - }, []); + }, [params]); return ( <> diff --git a/apps/web/src/components/layouts/MainLayout.tsx b/apps/web/src/components/layouts/MainLayout.tsx index 5caa2a81..49ed82e7 100644 --- a/apps/web/src/components/layouts/MainLayout.tsx +++ b/apps/web/src/components/layouts/MainLayout.tsx @@ -1,8 +1,11 @@ +import { useState } from 'react'; +import { cn } from '@/utils/cn'; +import { MenuIcon } from 'lucide-react'; import Link from 'next/link'; import { Container } from '../Container'; +import { Breadcrumbs } from '../navbar/Breadcrumbs'; import { NavbarMenu } from '../navbar/NavbarMenu'; -import { NavbarUserDropdown } from '../navbar/NavbarUserDropdown'; interface MainLayoutProps { children: React.ReactNode; @@ -10,23 +13,40 @@ interface MainLayoutProps { } export function MainLayout({ children, className }: MainLayoutProps) { + const [visible, setVisible] = useState(false); return ( <>
-
{children}
+ +
{children}
); } diff --git a/apps/web/src/components/layouts/SettingsLayout.tsx b/apps/web/src/components/layouts/SettingsLayout.tsx index 32166a2e..7c495b6a 100644 --- a/apps/web/src/components/layouts/SettingsLayout.tsx +++ b/apps/web/src/components/layouts/SettingsLayout.tsx @@ -33,6 +33,7 @@ export function SettingsLayout({ children, className }: SettingsLayoutProps) { {links.map(({ href, label }) => ( + + {org.isLoading && pro.isLoading && ( +
+ )} + {org.data && ( + <> + + + {org.data.name} + + + )} + + {org.data && pro.data && ( + <> + + + {pro.data.name} + + + )} +
+ + ); +} diff --git a/apps/web/src/components/navbar/NavbarCreate.tsx b/apps/web/src/components/navbar/NavbarCreate.tsx index 0dbd8862..ae1ca0c9 100644 --- a/apps/web/src/components/navbar/NavbarCreate.tsx +++ b/apps/web/src/components/navbar/NavbarCreate.tsx @@ -1,4 +1,3 @@ -import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -17,14 +16,17 @@ export function NavbarCreate() { return ( - + Actions - + Create a report diff --git a/apps/web/src/components/navbar/NavbarMenu.tsx b/apps/web/src/components/navbar/NavbarMenu.tsx index 1629e682..8447d74b 100644 --- a/apps/web/src/components/navbar/NavbarMenu.tsx +++ b/apps/web/src/components/navbar/NavbarMenu.tsx @@ -1,24 +1,40 @@ import { useOrganizationParams } from '@/hooks/useOrganizationParams'; +import { cn } from '@/utils/cn'; import Link from 'next/link'; -import { NavbarCreate } from './NavbarCreate'; +import { NavbarUserDropdown } from './NavbarUserDropdown'; export function NavbarMenu() { const params = useOrganizationParams(); return ( -
- Home +
{params.project && ( - + + Home + + )} + {params.project && ( + Events )} {params.project && ( - + Profiles )} - + {params.project && ( + + Create report + + )} +
); } diff --git a/apps/web/src/components/navbar/NavbarUserDropdown.tsx b/apps/web/src/components/navbar/NavbarUserDropdown.tsx index a9eca765..ec72e2d2 100644 --- a/apps/web/src/components/navbar/NavbarUserDropdown.tsx +++ b/apps/web/src/components/navbar/NavbarUserDropdown.tsx @@ -27,25 +27,28 @@ export function NavbarUserDropdown() { - + Organization - + Projects - + Clients - + Profile diff --git a/apps/web/src/components/report/ReportChartType.tsx b/apps/web/src/components/report/ReportChartType.tsx index 7ffadbfd..2e3a632f 100644 --- a/apps/web/src/components/report/ReportChartType.tsx +++ b/apps/web/src/components/report/ReportChartType.tsx @@ -3,32 +3,23 @@ import type { IChartType } from '@/types'; import { chartTypes } from '@/utils/constants'; import { Combobox } from '../ui/combobox'; -import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; -import { - changeChartType, - changeDateRanges, - changeInterval, -} from './reportSlice'; +import { changeChartType } from './reportSlice'; export function ReportChartType() { const dispatch = useDispatch(); const type = useSelector((state) => state.report.chartType); return ( - <> -
- { - dispatch(changeChartType(value as IChartType)); - }} - value={type} - items={Object.entries(chartTypes).map(([key, value]) => ({ - label: value, - value: key, - }))} - /> -
- + { + dispatch(changeChartType(value as IChartType)); + }} + value={type} + items={Object.entries(chartTypes).map(([key, value]) => ({ + label: value, + value: key, + }))} + /> ); } diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx index 05f1cc9d..29f0c56b 100644 --- a/apps/web/src/components/report/ReportDateRange.tsx +++ b/apps/web/src/components/report/ReportDateRange.tsx @@ -1,49 +1,28 @@ import { useDispatch, useSelector } from '@/redux'; -import type { IInterval } from '@/types'; -import { intervals, timeRanges } from '@/utils/constants'; +import { timeRanges } from '@/utils/constants'; -import { Combobox } from '../ui/combobox'; import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; -import { changeDateRanges, changeInterval } from './reportSlice'; +import { changeDateRanges } from './reportSlice'; export function ReportDateRange() { const dispatch = useDispatch(); const range = useSelector((state) => state.report.range); - const interval = useSelector((state) => state.report.interval); - const chartType = useSelector((state) => state.report.chartType); return ( - <> - - {timeRanges.map((item) => { - return ( - { - dispatch(changeDateRanges(item.range)); - }} - > - {item.title} - - ); - })} - - {chartType === 'linear' && ( -
- { - dispatch(changeInterval(value as IInterval)); + + {timeRanges.map((item) => { + return ( + { + dispatch(changeDateRanges(item.range)); }} - value={interval} - items={Object.entries(intervals).map(([key, value]) => ({ - label: value, - value: key, - }))} - /> -
- )} - + > + {item.title} + + ); + })} + ); } diff --git a/apps/web/src/components/report/ReportInterval.tsx b/apps/web/src/components/report/ReportInterval.tsx new file mode 100644 index 00000000..ec86a5e8 --- /dev/null +++ b/apps/web/src/components/report/ReportInterval.tsx @@ -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 ( + { + 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, + }, + ]} + /> + ); +} diff --git a/apps/web/src/components/report/sidebar/ReportSaveButton.tsx b/apps/web/src/components/report/ReportSaveButton.tsx similarity index 78% rename from apps/web/src/components/report/sidebar/ReportSaveButton.tsx rename to apps/web/src/components/report/ReportSaveButton.tsx index e4d2aca5..fa5104bb 100644 --- a/apps/web/src/components/report/sidebar/ReportSaveButton.tsx +++ b/apps/web/src/components/report/ReportSaveButton.tsx @@ -1,12 +1,15 @@ import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui/use-toast'; +import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { pushModal } from '@/modals'; import { useSelector } from '@/redux'; import { api, handleError } from '@/utils/api'; +import { SaveIcon } from 'lucide-react'; -import { useReportId } from '../hooks/useReportId'; +import { useReportId } from './hooks/useReportId'; export function ReportSaveButton() { + const params = useOrganizationParams(); const { reportId } = useReportId(); const update = api.report.update.useMutation({ onSuccess() { @@ -27,10 +30,9 @@ export function ReportSaveButton() { update.mutate({ reportId, report, - dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd', - projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d', }); }} + icon={SaveIcon} > Update @@ -43,8 +45,9 @@ export function ReportSaveButton() { report, }); }} + icon={SaveIcon} > - Create + Save ); } diff --git a/apps/web/src/components/report/chart/ChartProvider.tsx b/apps/web/src/components/report/chart/ChartProvider.tsx index 81e64624..65542370 100644 --- a/apps/web/src/components/report/chart/ChartProvider.tsx +++ b/apps/web/src/components/report/chart/ChartProvider.tsx @@ -1,7 +1,6 @@ import { createContext, memo, useContext, useMemo } from 'react'; -import { pick } from 'ramda'; -interface ChartContextType { +export interface ChartContextType { editMode: boolean; } diff --git a/apps/web/src/components/report/chart/LazyChart.tsx b/apps/web/src/components/report/chart/LazyChart.tsx new file mode 100644 index 00000000..10eb95dd --- /dev/null +++ b/apps/web/src/components/report/chart/LazyChart.tsx @@ -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(null); + const once = useRef(false); + const { inViewport } = useInViewport(ref, undefined, { + disconnectOnLeave: true, + }); + + useEffect(() => { + if (inViewport) { + once.current = true; + } + }, [inViewport]); + + return ( +
+ {once.current || inViewport ? ( + + ) : ( +
+ )} +
+ ); +} diff --git a/apps/web/src/components/report/chart/ReportBarChart.tsx b/apps/web/src/components/report/chart/ReportBarChart.tsx index f9a1fc5c..61d3d22f 100644 --- a/apps/web/src/components/report/chart/ReportBarChart.tsx +++ b/apps/web/src/components/report/chart/ReportBarChart.tsx @@ -93,9 +93,9 @@ export function ReportBarChart({ data }: ReportBarChartProps) { getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), - debugTable: true, - debugHeaders: true, - debugColumns: true, + // debugTable: true, + // debugHeaders: true, + // debugColumns: true, }); return (
diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index 2b7b7d3d..749d200d 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -40,47 +40,52 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) { return ( <> - - {({ width }) => ( - - - } /> - - { - return formatDate(m); - }} - tickLine={false} - allowDuplicatedCategory={false} - /> - {data?.series - .filter((serie) => { - return visibleSeries.includes(serie.name); - }) - .map((serie) => { - const realIndex = data?.series.findIndex( - (item) => item.name === serie.name - ); - const key = serie.name; - const strokeColor = getChartColor(realIndex); - return ( - - ); - })} - - )} - +
+ + {({ width }) => ( + + + } /> + + { + return formatDate(m); + }} + tickLine={false} + allowDuplicatedCategory={false} + /> + {data?.series + .filter((serie) => { + return visibleSeries.includes(serie.name); + }) + .map((serie) => { + const realIndex = data?.series.findIndex( + (item) => item.name === serie.name + ); + const key = serie.name; + const strokeColor = getChartColor(realIndex); + return ( + + ); + })} + + )} + +
{editMode && ( { + }: ReportChartProps) { + const params = useOrganizationParams(); const hasEmptyFilters = events.some((event) => event.filters.some((filter) => filter.value.length === 0) ); @@ -29,6 +32,7 @@ export const Chart = withChartProivder( range, startDate: null, endDate: null, + projectSlug: params.project, }, { keepPreviousData: true, @@ -63,5 +67,5 @@ export const Chart = withChartProivder( } return

Chart type "{chartType}" is not supported yet.

; - } + }) ); diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 3710b458..d7ecfbd8 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -6,7 +6,7 @@ import type { IChartType, IInterval, } from '@/types'; -import { alphabetIds } from '@/utils/constants'; +import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants'; import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; @@ -104,6 +104,13 @@ export const reportSlice = createSlice({ // Chart type changeChartType: (state, action: PayloadAction) => { state.chartType = action.payload; + + if ( + !isMinuteIntervalEnabledByRange(state.range) && + state.interval === 'minute' + ) { + state.interval = 'hour'; + } }, // Date range diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx index 0a89b42c..9c3275f0 100644 --- a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx @@ -1,6 +1,6 @@ import { ColorSquare } from '@/components/ColorSquare'; import { Combobox } from '@/components/ui/combobox'; -import { RenderDots } from '@/components/ui/RenderDots'; +import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useDispatch, useSelector } from '@/redux'; import type { IChartBreakdown } from '@/types'; import { api } from '@/utils/api'; @@ -10,9 +10,12 @@ import { ReportBreakdownMore } from './ReportBreakdownMore'; import type { ReportEventMoreProps } from './ReportEventMore'; export function ReportBreakdowns() { + const params = useOrganizationParams(); const selectedBreakdowns = useSelector((state) => state.report.breakdowns); const dispatch = useDispatch(); - const propertiesQuery = api.chart.properties.useQuery(); + const propertiesQuery = api.chart.properties.useQuery({ + projectSlug: params.project, + }); const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({ value: item, label: item, // {item}, diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx index 32ede0e3..b11c606a 100644 --- a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx +++ b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/command'; import { RenderDots } from '@/components/ui/RenderDots'; import { useMappings } from '@/hooks/useMappings'; +import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useDispatch } from '@/redux'; import type { IChartEvent, @@ -37,10 +38,12 @@ export function ReportEventFilters({ isCreating, setIsCreating, }: ReportEventFiltersProps) { + const params = useOrganizationParams(); const dispatch = useDispatch(); const propertiesQuery = api.chart.properties.useQuery( { event: event.name, + projectSlug: params.project, }, { enabled: !!event.name, @@ -99,11 +102,13 @@ interface FilterProps { } function Filter({ filter, event }: FilterProps) { + const params = useOrganizationParams(); const getLabel = useMappings(); const dispatch = useDispatch(); const potentialValues = api.chart.values.useQuery({ event: event.name, property: filter.name, + projectSlug: params.project, }); const valuesCombobox = diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx index 6770e686..5dea0574 100644 --- a/apps/web/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { ColorSquare } from '@/components/ColorSquare'; import { Dropdown } from '@/components/Dropdown'; import { Combobox } from '@/components/ui/combobox'; +import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useDispatch, useSelector } from '@/redux'; import type { IChartEvent } from '@/types'; import { api } from '@/utils/api'; @@ -16,7 +17,10 @@ export function ReportEvents() { const [isCreating, setIsCreating] = useState(false); const selectedEvents = useSelector((state) => state.report.events); const dispatch = useDispatch(); - const eventsQuery = api.chart.events.useQuery(); + const params = useOrganizationParams(); + const eventsQuery = api.chart.events.useQuery({ + projectSlug: params.project, + }); const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({ value: item.name, label: item.name, diff --git a/apps/web/src/components/report/sidebar/ReportSidebar.tsx b/apps/web/src/components/report/sidebar/ReportSidebar.tsx index 29938af0..49279e65 100644 --- a/apps/web/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/web/src/components/report/sidebar/ReportSidebar.tsx @@ -1,13 +1,17 @@ +import { Button } from '@/components/ui/button'; +import { SheetClose } from '@/components/ui/sheet'; + import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportEvents } from './ReportEvents'; -import { ReportSaveButton } from './ReportSaveButton'; export function ReportSidebar() { return ( -
+
- + + +
); } diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 65ec2474..24042045 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -9,7 +9,7 @@ const Avatar = React.forwardRef< { asChild?: boolean; loading?: boolean; + icon?: LucideIcon; } const Button = React.forwardRef( @@ -51,11 +53,13 @@ const Button = React.forwardRef( children, loading, disabled, + icon, ...props }, ref ) => { const Comp = asChild ? Slot : 'button'; + const Icon = loading ? Loader2 : icon ?? null; return ( ( disabled={loading ?? disabled} {...props} > - {loading ? : <>{children}} + {Icon && ( + + )} + {children} ); } diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 75457959..1c6fcf18 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -15,11 +15,14 @@ import { import { cn } from '@/utils/cn'; import { Check, ChevronsUpDown } from 'lucide-react'; +import { ScrollArea } from './scroll-area'; + interface ComboboxProps { placeholder: string; items: { value: string; label: string; + disabled?: boolean; }[]; value: string; onChange: (value: string) => void; @@ -51,7 +54,7 @@ export function Combobox({ variant="outline" role="combobox" aria-expanded={open} - className="w-full min-w-0 justify-between" + className="w-full justify-between min-w-[150px]" > {value ? find(value)?.label ?? 'No match' : placeholder} @@ -82,27 +85,30 @@ export function Combobox({ ) : ( Nothing selected )} - - {items.map((item) => ( - { - const value = find(currentValue)?.value ?? currentValue; - onChange(value); - setOpen(false); - }} - > - - {item.label} - - ))} - +
+ + {items.map((item) => ( + { + const value = find(currentValue)?.value ?? currentValue; + onChange(value); + setOpen(false); + }} + {...(item.disabled && { disabled: true })} + > + + {item.label} + + ))} + +
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 7ccfcab8..c143a853 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -114,9 +114,10 @@ const CommandItem = React.forwardRef< )); diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 4ccf24f2..4c068572 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - + <> - + )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..f25a5f8c --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx new file mode 100644 index 00000000..cad567d8 --- /dev/null +++ b/apps/web/src/components/ui/sheet.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/web/src/hooks/useBreakpoint.ts b/apps/web/src/hooks/useBreakpoint.ts new file mode 100644 index 00000000..de8c1fd7 --- /dev/null +++ b/apps/web/src/hooks/useBreakpoint.ts @@ -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(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}`; + type KeyBelow = `isBelow${Capitalize}`; + + return { + [breakpointKey]: Number(String(breakpointValue).replace(/[^0-9]/g, '')), + [`isAbove${capitalizedKey}`]: !bool, + [`isBelow${capitalizedKey}`]: bool, + } as Record & Record; +} diff --git a/apps/web/src/modals/SaveReport.tsx b/apps/web/src/modals/SaveReport.tsx index 7cd1a442..768ec979 100644 --- a/apps/web/src/modals/SaveReport.tsx +++ b/apps/web/src/modals/SaveReport.tsx @@ -10,7 +10,7 @@ import type { IChartInput } from '@/types'; import { api, handleError } from '@/utils/api'; import { zodResolver } from '@hookform/resolvers/zod'; 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 { popModal } from '.'; @@ -23,7 +23,6 @@ interface SaveReportProps { const validator = z.object({ name: z.string().min(1, 'Required'), - projectId: z.string().min(1, 'Required'), dashboardId: z.string().min(1, 'Required'), }); @@ -31,7 +30,7 @@ type IForm = z.infer; export default function SaveReport({ report }: SaveReportProps) { const router = useRouter(); - const { organization } = useOrganizationParams(); + const { organization, project } = useOrganizationParams(); const refetch = useRefetchActive(); const save = api.report.save.useMutation({ onError: handleError, @@ -42,7 +41,7 @@ export default function SaveReport({ report }: SaveReportProps) { }); popModal(); 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), defaultValues: { name: '', - projectId: '', dashboardId: '', }, }); @@ -68,29 +66,10 @@ export default function SaveReport({ report }: SaveReportProps) { }, }); - const projectId = useWatch({ - name: 'projectId', - control, + const dashboasrdQuery = api.dashboard.list.useQuery({ + projectSlug: project, }); - 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) => ({ value: item.id, label: item.name, @@ -117,22 +96,6 @@ export default function SaveReport({ report }: SaveReportProps) { {...register('name')} defaultValue={report.name} /> - { - return ( -
- - -
- ); - }} - /> { dashboardMutation.mutate({ - projectId, + projectSlug: project, name: value, }); }} diff --git a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx index 01a7c73e..ed1c0e5b 100644 --- a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx +++ b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx @@ -1,13 +1,14 @@ -import { Suspense, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Container } from '@/components/Container'; import { MainLayout } from '@/components/layouts/MainLayout'; 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 { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { createServerSideProps } from '@/server/getServerSideProps'; import type { IChartRange } from '@/types'; import { api } from '@/utils/api'; +import { cn } from '@/utils/cn'; import { timeRanges } from '@/utils/constants'; import { getRangeLabel } from '@/utils/getRangeLabel'; import Link from 'next/link'; @@ -32,59 +33,63 @@ export default function Dashboard() { return ( - - {dashboard?.name} + {dashboard?.name} - - {timeRanges.map((item) => { - return ( - { - setRange((p) => (p === item.range ? null : item.range)); - }} + + {timeRanges.map((item) => { + return ( + { + setRange((p) => (p === item.range ? null : item.range)); + }} + > + {item.title} + + ); + })} + + +
+ {reports.map((report) => { + const chartRange = getRangeLabel(report.range); + return ( +
+ - {item.title} - - ); - })} - - -
- {reports.map((report) => { - const chartRange = getRangeLabel(report.range); - return ( +
{report.name}
+ {chartRange !== null && ( +
+ + {chartRange} + + {range !== null && {getRangeLabel(range)}} +
+ )} +
- -
{report.name}
- {chartRange && ( -
- - {chartRange} - - {range && {getRangeLabel(range)}} -
- )} - -
- -
+
- ); - })} -
- +
+ ); + })} +
); diff --git a/apps/web/src/pages/[organization]/[project]/index.tsx b/apps/web/src/pages/[organization]/[project]/index.tsx index c572bdbe..56368bce 100644 --- a/apps/web/src/pages/[organization]/[project]/index.tsx +++ b/apps/web/src/pages/[organization]/[project]/index.tsx @@ -25,12 +25,13 @@ export default function Home() { Dashboards -
+
{dashboards.map((item) => ( {item.name} diff --git a/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx b/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx index c32646ae..345b83a3 100644 --- a/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx +++ b/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx @@ -6,7 +6,9 @@ import { PageTitle } from '@/components/PageTitle'; import { usePagination } from '@/components/Pagination'; import { useOrganizationParams } from '@/hooks/useOrganizationParams'; import { useQueryParams } from '@/hooks/useQueryParams'; +import { createServerSideProps } from '@/server/getServerSideProps'; import { api } from '@/utils/api'; +import { getProfileName } from '@/utils/getters'; import { z } from 'zod'; export default function ProfileId() { @@ -17,6 +19,9 @@ export default function ProfileId() { profileId: z.string(), }) ); + const profileQuery = api.profile.get.useQuery({ + id: profileId, + }); const eventsQuery = api.event.list.useQuery( { projectSlug: params.project, @@ -27,12 +32,14 @@ export default function ProfileId() { keepPreviousData: true, } ); + const profile = profileQuery.data ?? null; const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); return ( - Profile + {getProfileName(profile)} +
{JSON.stringify(profile?.properties, null, 2)}
diff --git a/apps/web/src/pages/[organization]/[project]/profiles/index.tsx b/apps/web/src/pages/[organization]/[project]/profiles/index.tsx index 4b5e5381..d4a1e86a 100644 --- a/apps/web/src/pages/[organization]/[project]/profiles/index.tsx +++ b/apps/web/src/pages/[organization]/[project]/profiles/index.tsx @@ -49,6 +49,7 @@ export default function Events() { {profile?.avatar && } @@ -56,7 +57,16 @@ export default function Events() { {profile?.first_name?.at(0)} - {`${profile?.first_name} ${profile?.last_name ?? ''}`} +
+
+ {[profile?.first_name, profile?.last_name] + .filter(Boolean) + .join(' ')} +
+
+ {profile.external_id} +
+
); }, diff --git a/apps/web/src/pages/[organization]/reports/[reportId].tsx b/apps/web/src/pages/[organization]/[project]/reports/[reportId].tsx similarity index 100% rename from apps/web/src/pages/[organization]/reports/[reportId].tsx rename to apps/web/src/pages/[organization]/[project]/reports/[reportId].tsx diff --git a/apps/web/src/pages/[organization]/reports/index.tsx b/apps/web/src/pages/[organization]/[project]/reports/index.tsx similarity index 53% rename from apps/web/src/pages/[organization]/reports/index.tsx rename to apps/web/src/pages/[organization]/[project]/reports/index.tsx index 02a81787..45a74fe5 100644 --- a/apps/web/src/pages/[organization]/reports/index.tsx +++ b/apps/web/src/pages/[organization]/[project]/reports/index.tsx @@ -1,11 +1,23 @@ import { useCallback, useEffect } from 'react'; +import { Container } from '@/components/Container'; import { MainLayout } from '@/components/layouts/MainLayout'; import { Chart } from '@/components/report/chart'; import { useReportId } from '@/components/report/hooks/useReportId'; import { ReportChartType } from '@/components/report/ReportChartType'; 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 { 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 { useDispatch, useSelector } from '@/redux'; import { createServerSideProps } from '@/server/getServerSideProps'; @@ -39,18 +51,32 @@ export default function Page() { }, [reportId, reportQuery.data, dispatch]); return ( - -
+ + + +
+
+ +
+
+ + +
+
+ + + + +
+
+
+ +
+
+
+ -
-
-
- - -
- - -
-
+ + ); } diff --git a/apps/web/src/pages/[organization]/index.tsx b/apps/web/src/pages/[organization]/index.tsx index 1384d671..af3f5c46 100644 --- a/apps/web/src/pages/[organization]/index.tsx +++ b/apps/web/src/pages/[organization]/index.tsx @@ -27,12 +27,13 @@ export default function Home() { Projects -
+
{projects.map((item) => ( {item.name} diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 68cbe02d..353d8717 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -8,6 +8,7 @@ import { organizationRouter } from './routers/organization'; import { profileRouter } from './routers/profile'; import { projectRouter } from './routers/project'; import { reportRouter } from './routers/report'; +import { uiRouter } from './routers/ui'; import { userRouter } from './routers/user'; /** @@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({ client: clientRouter, event: eventRouter, profile: profileRouter, + ui: uiRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index 8e6f960d..785df900 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -1,5 +1,7 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; +import * as cache from '@/server/cache'; import { db } from '@/server/db'; +import { getProjectBySlug } from '@/server/services/project.service'; import type { IChartEvent, IChartInputWithDates, IChartRange } from '@/types'; import { getDaysOldDate } from '@/utils/date'; import { toDots } from '@/utils/object'; @@ -14,28 +16,46 @@ export const config = { }; export const chartRouter = createTRPCRouter({ - events: protectedProcedure.query(async () => { - const events = await db.event.findMany({ - take: 500, - distinct: ['name'], - }); + events: protectedProcedure + .input(z.object({ projectSlug: z.string() })) + .query(async ({ input: { projectSlug } }) => { + 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 - .input(z.object({ event: z.string() }).optional()) - .query(async ({ input }) => { - const events = await db.event.findMany({ - take: 500, - where: { - ...(input?.event - ? { - name: input.event, - } - : {}), - }, - }); + .input(z.object({ event: z.string().optional(), projectSlug: z.string() })) + .query(async ({ input: { projectSlug, event } }) => { + const project = await getProjectBySlug(projectSlug); + const events = await cache.getOr( + `events_${project.id}_${event ?? 'all'}`, + 1000 * 60 * 60, + () => + db.event.findMany({ + take: 500, + where: { + project_id: project.id, + ...(event + ? { + name: event, + } + : {}), + }, + }) + ); const properties = events .reduce((acc, event) => { @@ -53,51 +73,69 @@ export const chartRouter = createTRPCRouter({ }), values: protectedProcedure - .input(z.object({ event: z.string(), property: z.string() })) - .query(async ({ input }) => { - if (isJsonPath(input.property)) { + .input( + z.object({ + 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 }[]>( `SELECT ${selectJsonPath( - input.property - )} AS value from events WHERE name = '${ - input.event - }' AND "createdAt" >= NOW() - INTERVAL '30 days'` + property + )} AS value from events WHERE project_id = '${ + project.id + }' 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 { values: uniq(events.map((item) => item.value)), }; } else { const events = await db.event.findMany({ where: { - name: input.event, - [input.property]: { + project_id: project.id, + name: event, + [property]: { not: null, }, createdAt: { gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30), }, }, - distinct: input.property as any, + distinct: property as any, select: { - [input.property]: true, + [property]: true, }, }); return { - values: uniq(events.map((item) => item[input.property]!)), + values: uniq(events.map((item) => item[property]!)), }; } }), chart: protectedProcedure - .input(zChartInputWithDates) - .query(async ({ input: { events, ...input } }) => { + .input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() }))) + .query(async ({ input: { projectSlug, events, ...input } }) => { + const project = await getProjectBySlug(projectSlug); const series: Awaited> = []; for (const event of events) { series.push( ...(await getChartData({ ...input, event, + projectId: project.id, })) ); } @@ -227,9 +265,12 @@ function getChartSql({ interval, startDate, endDate, -}: Omit) { + projectId, +}: Omit & { + projectId: string; +}) { const select = []; - const where = []; + const where = [`project_id = '${projectId}'`]; const groupBy = []; const orderBy = []; @@ -352,7 +393,10 @@ async function getChartData({ range, startDate: _startDate, endDate: _endDate, -}: IGetChartDataInput) { + projectId, +}: IGetChartDataInput & { + projectId: string; +}) { const { startDate, endDate } = _startDate && _endDate ? { @@ -368,6 +412,7 @@ async function getChartData({ interval, startDate, endDate, + projectId, }); let result = await db.$queryRawUnsafe(sql); @@ -381,6 +426,7 @@ async function getChartData({ interval, startDate, endDate, + projectId, }) ); } @@ -453,8 +499,8 @@ function fillEmptySpotsInTimeline( clonedStartDate.setMinutes(0, 0, 0); clonedEndDate.setMinutes(0, 0, 0); } else { - clonedStartDate.setHours(2, 0, 0, 0); - clonedEndDate.setHours(2, 0, 0, 0); + clonedStartDate.setUTCHours(0, 0, 0, 0); + clonedEndDate.setUTCHours(0, 0, 0, 0); } // Force if interval is month and the start date is the same month as today diff --git a/apps/web/src/server/api/routers/dashboard.ts b/apps/web/src/server/api/routers/dashboard.ts index db507904..bfc6633a 100644 --- a/apps/web/src/server/api/routers/dashboard.ts +++ b/apps/web/src/server/api/routers/dashboard.ts @@ -35,14 +35,15 @@ export const dashboardRouter = createTRPCRouter({ .input( z.object({ 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({ data: { slug: slug(name), - project_id: projectId, + project_id: project.id, name, }, }); diff --git a/apps/web/src/server/api/routers/profile.ts b/apps/web/src/server/api/routers/profile.ts index abf32709..bc0c4b5e 100644 --- a/apps/web/src/server/api/routers/profile.ts +++ b/apps/web/src/server/api/routers/profile.ts @@ -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, + }, + }); + }), }); diff --git a/apps/web/src/server/api/routers/project.ts b/apps/web/src/server/api/routers/project.ts index 0fab882b..975a77fd 100644 --- a/apps/web/src/server/api/routers/project.ts +++ b/apps/web/src/server/api/routers/project.ts @@ -1,6 +1,7 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; import { getOrganizationBySlug } from '@/server/services/organization.service'; +import { getProjectBySlug } from '@/server/services/project.service'; import { z } from 'zod'; export const projectRouter = createTRPCRouter({ @@ -20,11 +21,17 @@ export const projectRouter = createTRPCRouter({ }), get: protectedProcedure .input( - z.object({ - id: z.string(), - }) + z + .object({ + id: z.string(), + }) + .or(z.object({ slug: z.string() })) ) .query(({ input }) => { + if ('slug' in input) { + return getProjectBySlug(input.slug); + } + return db.project.findUniqueOrThrow({ where: { id: input.id, diff --git a/apps/web/src/server/api/routers/report.ts b/apps/web/src/server/api/routers/report.ts index 890f6120..c8cedb35 100644 --- a/apps/web/src/server/api/routers/report.ts +++ b/apps/web/src/server/api/routers/report.ts @@ -94,14 +94,18 @@ export const reportRouter = createTRPCRouter({ .input( z.object({ report: zChartInput, - projectId: 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({ data: { - project_id: projectId, + project_id: dashboard.project_id, dashboard_id: dashboardId, name: report.name, events: report.events, @@ -117,18 +121,14 @@ export const reportRouter = createTRPCRouter({ z.object({ reportId: z.string(), report: zChartInput, - projectId: z.string(), - dashboardId: z.string(), }) ) - .mutation(({ input: { report, projectId, dashboardId, reportId } }) => { + .mutation(({ input: { report, reportId } }) => { return db.report.update({ where: { id: reportId, }, data: { - project_id: projectId, - dashboard_id: dashboardId, name: report.name, events: report.events, interval: report.interval, diff --git a/apps/web/src/server/api/routers/ui.ts b/apps/web/src/server/api/routers/ui.ts new file mode 100644 index 00000000..d643ccfd --- /dev/null +++ b/apps/web/src/server/api/routers/ui.ts @@ -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; + }), +}); diff --git a/apps/web/src/server/cache.ts b/apps/web/src/server/cache.ts new file mode 100644 index 00000000..1ce18bb9 --- /dev/null +++ b/apps/web/src/server/cache.ts @@ -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( + key: string, + expires: number, + fn: () => Promise +): Promise { + const hit = get(key); + if (hit) { + return hit; + } + const data = await fn(); + set(key, expires, data); + return data; +} diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index 8368df95..e79a1b0b 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -45,3 +45,7 @@ export const timeRanges = [ { range: 180, title: '6mo' }, { range: 365, title: '1y' }, ] as const; + +export function isMinuteIntervalEnabledByRange(range: number) { + return range === 0.3 || range === 0.6; +} diff --git a/apps/web/src/utils/getters.ts b/apps/web/src/utils/getters.ts new file mode 100644 index 00000000..613b026f --- /dev/null +++ b/apps/web/src/utils/getters.ts @@ -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(' '); +} diff --git a/apps/web/src/utils/theme.ts b/apps/web/src/utils/theme.ts index e8c3dd28..d67e187d 100644 --- a/apps/web/src/utils/theme.ts +++ b/apps/web/src/utils/theme.ts @@ -2,14 +2,16 @@ import resolveConfig from 'tailwindcss/resolveConfig'; import tailwinConfig from '../../tailwind.config'; -const config = resolveConfig(tailwinConfig); +export const resolvedTailwindConfig = resolveConfig(tailwinConfig); -export const theme = config.theme; +export const theme = resolvedTailwindConfig.theme; 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-')) - .map((key) => theme.colors[key] as string); + .map((key) => colors[key]) + .filter((item): item is string => typeof item === 'string'); return chartColors[index % chartColors.length]!; } diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.js similarity index 97% rename from apps/web/tailwind.config.ts rename to apps/web/tailwind.config.js index 591c62f5..931a5665 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.js @@ -82,12 +82,12 @@ const config = { }, keyframes: { 'accordion-down': { - from: { height: 0 }, + from: { height: '0px' }, to: { height: 'var(--radix-accordion-content-height)' }, }, 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, - to: { height: 0 }, + to: { height: '0px' }, }, }, animation: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d4fb85c..f9787164 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@radix-ui/react-popover': 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) + '@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': specifier: ^1.0.2 version: 1.0.2(@types/react@18.2.34)(react@18.2.0) @@ -132,9 +135,15 @@ importers: react-hook-form: specifier: ^7.47.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: 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) + react-responsive: + specifier: ^9.0.2 + version: 9.0.2(react@18.2.0) react-syntax-highlighter: specifier: ^15.5.0 version: 15.5.0(react@18.2.0) @@ -1073,6 +1082,12 @@ packages: resolution: {integrity: sha512-Be5hoNF8k+lkB3uEMiCHbhbfF6aj1GnrTBnn5iYFT7GEr3TsOEp1soviEcBR0tYCgHbxjcIxJMhdbvxALJhAqg==} 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: resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} dependencies: @@ -1757,6 +1772,35 @@ packages: react-dom: 18.2.0(react@18.2.0) 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): resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==} peerDependencies: @@ -2841,6 +2885,10 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3766,6 +3814,10 @@ packages: engines: {node: '>=10.17.0'} dev: true + /hyphenate-style-name@1.0.4: + resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} + dev: false + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -4174,6 +4226,12 @@ packages: semver: 6.3.1 dev: false + /matchmediaquery@0.3.1: + resolution: {integrity: sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==} + dependencies: + css-mediaquery: 0.1.2 + dev: false + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true @@ -4770,6 +4828,17 @@ packages: react: 18.2.0 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: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false @@ -4881,6 +4950,19 @@ packages: react-dom: 18.2.0(react@18.2.0) 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): resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==} peerDependencies: @@ -5175,6 +5257,10 @@ packages: has-property-descriptors: 1.0.1 dev: false + /shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'}