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?
|
## 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "{search}"</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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
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'), {
|
Confirm: dynamic(() => import('./Confirm'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
|
SaveReport: dynamic(() => import('./SaveReport'), {
|
||||||
|
loading: Loading,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitter = mitt<{
|
const emitter = mitt<{
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
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