add save report modal
This commit is contained in:
17
README.md
17
README.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "{search}"</Button>
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
166
apps/web/src/modals/SaveReport.tsx
Normal file
166
apps/web/src/modals/SaveReport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,9 @@ const modals = {
|
||||
Confirm: dynamic(() => import('./Confirm'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
SaveReport: dynamic(() => import('./SaveReport'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
};
|
||||
|
||||
const emitter = mitt<{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
18
apps/web/src/utils/slug.ts
Normal file
18
apps/web/src/utils/slug.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user