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? ## 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) * [ ] Real time data (mostly screen_views stats)
* [ ] Active users (5min, 10min, 30min) * [ ] Active users (5min, 10min, 30min)
* [ ] Save report to a specific dashboard * [X] Save report to a specific dashboard
* [ ] View events in a list * [ ] View events in a list
* [ ] View profiles in a list * [ ] View profiles in a list
* [ ] Invite users * [ ] Invite users
@@ -17,11 +22,17 @@ Mixan is a simple analytics tool for logging events on web and react-native. My
* [ ] Pie * [ ] Pie
* [ ] Area * [ ] Area
* [ ] Support funnels * [ ] Support funnels
* [ ] Create native sdk
* [ ] Create web sdk
* [ ] Support multiple breakdowns * [ ] Support multiple breakdowns
* [ ] Aggregations (sum, average...) * [ ] 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 ## @mixan/sdk
For pushing events For pushing events

View File

@@ -52,6 +52,7 @@
"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",
"slugify": "^1.6.6",
"superjson": "^1.13.1", "superjson": "^1.13.1",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

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

View File

@@ -1,13 +1,13 @@
import { ReportEvents } from "./ReportEvents"; import { ReportEvents } from "./ReportEvents";
import { ReportBreakdowns } from "./ReportBreakdowns"; import { ReportBreakdowns } from "./ReportBreakdowns";
import { ReportSave } from "./ReportSave"; 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-4 p-4">
<ReportEvents /> <ReportEvents />
<ReportBreakdowns /> <ReportBreakdowns />
<ReportSave /> <ReportSaveButton />
</div> </div>
); );
} }

View File

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

View File

@@ -20,7 +20,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} className={className} {...props} />
)) ))
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader"
@@ -55,7 +55,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

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

View File

@@ -20,7 +20,7 @@ type ModalHeaderProps = {
export function ModalHeader({ title }: ModalHeaderProps) { export function ModalHeader({ title }: ModalHeaderProps) {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-6">
<div className="font-medium">{title}</div> <div className="font-medium">{title}</div>
<Button variant="ghost" size="sm" onClick={() => popModal()}> <Button variant="ghost" size="sm" onClick={() => popModal()}>
<X className="h-4 w-4" /> <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'), { Confirm: dynamic(() => import('./Confirm'), {
loading: Loading, loading: Loading,
}), }),
SaveReport: dynamic(() => import('./SaveReport'), {
loading: Loading,
}),
}; };
const emitter = mitt<{ const emitter = mitt<{

View File

@@ -3,22 +3,48 @@ import { z } from "zod";
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 { getProjectBySlug } from "@/server/services/project.service"; import { getProjectBySlug } from "@/server/services/project.service";
import { slug } from "@/utils/slug";
export const dashboardRouter = createTRPCRouter({ export const dashboardRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z
organizationSlug: z.string(), .object({
projectSlug: z.string(), projectSlug: z.string(),
}), })
.or(
z.object({
projectId: z.string(),
}),
),
) )
.query(async ({ input: { projectSlug } }) => { .query(async ({ input }) => {
const project = await getProjectBySlug(projectSlug) let projectId = null;
if ("projectId" in input) {
projectId = input.projectId;
} else {
projectId = (await getProjectBySlug(input.projectSlug)).id;
}
return db.dashboard.findMany({ return db.dashboard.findMany({
where: { 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({ export const projectRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input(z.object({ .input(
organizationSlug: z.string() z.object({
})) organizationSlug: z.string(),
.query(async ({ input }) => { }),
const organization = await getOrganizationBySlug(input.organizationSlug) )
return db.project.findMany({ .query(async ({ input }) => {
where: { const organization = await getOrganizationBySlug(input.organizationSlug);
organization_id: organization.id, return db.project.findMany({
}, where: {
}); organization_id: organization.id,
}), },
});
}),
get: protectedProcedure get: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -27,7 +29,6 @@ export const projectRouter = createTRPCRouter({
return db.project.findUniqueOrThrow({ return db.project.findUniqueOrThrow({
where: { where: {
id: input.id, id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
}, },
}); });
}), }),
@@ -42,7 +43,6 @@ export const projectRouter = createTRPCRouter({
return db.project.update({ return db.project.update({
where: { where: {
id: input.id, id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
}, },
data: { data: {
name: input.name, name: input.name,
@@ -53,17 +53,19 @@ export const projectRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
name: z.string(), name: z.string(),
organizationSlug: z.string(),
}), }),
) )
.mutation(({ input }) => { .mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
return db.project.create({ return db.project.create({
data: { data: {
organization_id: "d433c614-69f9-443a-8961-92a662869929", organization_id: organization.id,
name: input.name, name: input.name,
}, },
}); });
}), }),
remove: protectedProcedure remove: protectedProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
@@ -73,9 +75,8 @@ export const projectRouter = createTRPCRouter({
await db.project.delete({ await db.project.delete({
where: { where: {
id: input.id, 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.