add save report modal

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-27 21:38:29 +02:00
parent ed7ed2ab24
commit a05f7933af
14 changed files with 296 additions and 56 deletions

View File

@@ -4,9 +4,14 @@ Mixan is a simple analytics tool for logging events on web and react-native. My
## Whats left?
> Currently storing events on postgres but will probably move it to [clickhouse](https://clickhouse.com/) to speed up queries. Don't have any performance issues yet so will wait and see how well postgres can handle it.
### GUI
* [ ] Rename event label
* [ ] Real time data (mostly screen_views stats)
* [ ] Active users (5min, 10min, 30min)
* [ ] Save report to a specific dashboard
* [X] Save report to a specific dashboard
* [ ] View events in a list
* [ ] View profiles in a list
* [ ] Invite users
@@ -17,11 +22,17 @@ Mixan is a simple analytics tool for logging events on web and react-native. My
* [ ] Pie
* [ ] Area
* [ ] Support funnels
* [ ] Create native sdk
* [ ] Create web sdk
* [ ] Support multiple breakdowns
* [ ] Aggregations (sum, average...)
### SDK
* [ ] Store duration on screen view events (can be done in backend as well)
* [ ] Create native sdk
* [ ] Handle sessions
* [ ] Create web sdk
* [ ] Screen view function should take in title, path and parse query string (especially utm tags)
## @mixan/sdk
For pushing events

View File

@@ -52,6 +52,7 @@
"react-syntax-highlighter": "^15.5.0",
"react-virtualized-auto-sizer": "^1.0.20",
"recharts": "^2.8.0",
"slugify": "^1.6.6",
"superjson": "^1.13.1",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",

View File

@@ -2,10 +2,10 @@ import { Button } from "@/components/ui/button";
import { useReportId } from "../hooks/useReportId";
import { api } from "@/utils/api";
import { useSelector } from "@/redux";
import { pushModal } from "@/modals";
export function ReportSave() {
export function ReportSaveButton() {
const { reportId } = useReportId();
const save = api.report.save.useMutation();
const update = api.report.update.useMutation();
const report = useSelector((state) => state.report);
@@ -22,11 +22,9 @@ export function ReportSave() {
return (
<Button
onClick={() => {
save.mutate({
pushModal('SaveReport', {
report,
dashboardId: "9227feb4-ad59-40f3-b887-3501685733dd",
projectId: "f7eabf0c-e0b0-4ac0-940f-1589715b0c3d",
});
})
}}
>
Create

View File

@@ -1,13 +1,13 @@
import { ReportEvents } from "./ReportEvents";
import { ReportBreakdowns } from "./ReportBreakdowns";
import { ReportSave } from "./ReportSave";
import { ReportSaveButton } from "./ReportSaveButton";
export function ReportSidebar() {
return (
<div className="flex flex-col gap-4 p-4">
<ReportEvents />
<ReportBreakdowns />
<ReportSave />
<ReportSaveButton />
</div>
);
}

View File

@@ -24,7 +24,8 @@ type ComboboxProps = {
}>;
value: string;
onChange: (value: string) => void;
children?: React.ReactNode
children?: React.ReactNode;
onCreate?: (value: string) => void;
};
export function Combobox({
@@ -32,10 +33,11 @@ export function Combobox({
items,
value,
onChange,
children
children,
onCreate,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
function find(value: string) {
return items.find(
(item) => item.value.toLowerCase() === value.toLowerCase(),
@@ -45,20 +47,34 @@ export function Combobox({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children ?? <Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-w-0 justify-between"
>
<span className="overflow-hidden text-ellipsis">{value ? find(value)?.label ?? "No match" : placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>}
{children ?? (
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-w-0 justify-between"
>
<span className="overflow-hidden text-ellipsis">
{value ? find(value)?.label ?? "No match" : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-full min-w-0 p-0" align="start">
<Command>
<CommandInput placeholder="Search item..." />
<CommandEmpty>Nothing selected</CommandEmpty>
<CommandInput placeholder="Search item..." value={search} onValueChange={setSearch} />
{typeof onCreate === "function" && search ? (
<CommandEmpty className="p-2">
<Button onClick={() => {
onCreate(search)
setSearch('')
setOpen(false)
}}>Create &quot;{search}&quot;</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<CommandGroup className="max-h-[200px] overflow-auto">
{items.map((item) => (
<CommandItem

View File

@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} className={className} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -55,7 +55,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}

View File

@@ -119,7 +119,7 @@ export default function CreateProject() {
render={({ field }) => {
return (
<div>
<Label className="mb-2 block">Project</Label>
<Label>Project</Label>
<Combobox
{...field}
onChange={(value) => {

View File

@@ -20,7 +20,7 @@ type ModalHeaderProps = {
export function ModalHeader({ title }: ModalHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-6">
<div className="font-medium">{title}</div>
<Button variant="ghost" size="sm" onClick={() => popModal()}>
<X className="h-4 w-4" />

View File

@@ -0,0 +1,166 @@
import { api, handleError } from "@/utils/api";
import { ModalContent, ModalHeader } from "./Modal/Container";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { ButtonContainer } from "@/components/ButtonContainer";
import { popModal } from ".";
import { toast } from "@/components/ui/use-toast";
import { InputWithLabel } from "@/components/forms/InputWithLabel";
import { useRefetchActive } from "@/hooks/useRefetchActive";
import { type IChartInput } from "@/types";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { useOrganizationParams } from "@/hooks/useOrganizationParams";
type SaveReportProps = {
report: IChartInput;
reportId?: string;
};
const validator = z.object({
name: z.string().min(1, "Required"),
projectId: z.string().min(1, "Required"),
dashboardId: z.string().min(1, "Required"),
});
type IForm = z.infer<typeof validator>;
export default function SaveReport({ report }: SaveReportProps) {
const { organization } = useOrganizationParams();
const refetch = useRefetchActive();
const save = api.report.save.useMutation({
onError: handleError,
onSuccess() {
toast({
title: "Success",
description: "Report saved.",
});
popModal();
refetch();
},
});
const { register, handleSubmit, formState, control, setValue } =
useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: "",
projectId: "",
dashboardId: "",
},
});
const dashboardMutation = api.dashboard.create.useMutation({
onError: handleError,
onSuccess(res) {
setValue("dashboardId", res.id);
dashboasrdQuery.refetch();
toast({
title: "Success",
description: "Dashboard created.",
});
},
});
const projectId = useWatch({
name: "projectId",
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) => ({
value: item.id,
label: item.name,
}));
return (
<ModalContent>
<ModalHeader title="Edit client" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ name, ...values }) => {
save.mutate({
report: {
...report,
name,
},
...values,
});
})}
>
<InputWithLabel
label="Report name"
placeholder="Name"
{...register("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
control={control}
name="dashboardId"
render={({ field }) => {
return (
<div>
<Label>Dashboard</Label>
<Combobox
disabled={!projectId}
{...field}
items={dashboards}
placeholder="Select a dashboard"
onCreate={(value) => {
dashboardMutation.mutate({
projectId,
name: value,
});
}}
/>
</div>
);
}}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Save
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -28,6 +28,9 @@ const modals = {
Confirm: dynamic(() => import('./Confirm'), {
loading: Loading,
}),
SaveReport: dynamic(() => import('./SaveReport'), {
loading: Loading,
}),
};
const emitter = mitt<{

View File

@@ -3,22 +3,48 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getProjectBySlug } from "@/server/services/project.service";
import { slug } from "@/utils/slug";
export const dashboardRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string(),
projectSlug: z.string(),
}),
z
.object({
projectSlug: z.string(),
})
.or(
z.object({
projectId: z.string(),
}),
),
)
.query(async ({ input: { projectSlug } }) => {
const project = await getProjectBySlug(projectSlug)
.query(async ({ input }) => {
let projectId = null;
if ("projectId" in input) {
projectId = input.projectId;
} else {
projectId = (await getProjectBySlug(input.projectSlug)).id;
}
return db.dashboard.findMany({
where: {
project_id: project.id,
project_id: projectId,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
}),
)
.mutation(async ({ input: { projectId, name } }) => {
return db.dashboard.create({
data: {
slug: slug(name),
project_id: projectId,
name,
},
});
}),

View File

@@ -6,17 +6,19 @@ import { getOrganizationBySlug } from "@/server/services/organization.service";
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
organizationSlug: z.string()
}))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug)
return db.project.findMany({
where: {
organization_id: organization.id,
},
});
}),
.input(
z.object({
organizationSlug: z.string(),
}),
)
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
return db.project.findMany({
where: {
organization_id: organization.id,
},
});
}),
get: protectedProcedure
.input(
z.object({
@@ -27,7 +29,6 @@ export const projectRouter = createTRPCRouter({
return db.project.findUniqueOrThrow({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
});
}),
@@ -42,7 +43,6 @@ export const projectRouter = createTRPCRouter({
return db.project.update({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
data: {
name: input.name,
@@ -53,17 +53,19 @@ export const projectRouter = createTRPCRouter({
.input(
z.object({
name: z.string(),
organizationSlug: z.string(),
}),
)
.mutation(({ input }) => {
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
return db.project.create({
data: {
organization_id: "d433c614-69f9-443a-8961-92a662869929",
organization_id: organization.id,
name: input.name,
},
});
}),
remove: protectedProcedure
remove: protectedProcedure
.input(
z.object({
id: z.string(),
@@ -73,9 +75,8 @@ export const projectRouter = createTRPCRouter({
await db.project.delete({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
});
return true
return true;
}),
});

View File

@@ -0,0 +1,18 @@
import _slugify from 'slugify'
const slugify = (str: string) => {
return _slugify(
str
.replace('å', 'a')
.replace('ä', 'a')
.replace('ö', 'o')
.replace('Å', 'A')
.replace('Ä', 'A')
.replace('Ö', 'O'),
{ lower: true, strict: true, trim: true },
)
}
export function slug(str: string): string {
return slugify(str)
}

BIN
bun.lockb

Binary file not shown.