move clients settings into projects

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-02 22:33:13 +02:00
parent 1245047a7c
commit d9e3045a5b
11 changed files with 182 additions and 124 deletions

View File

@@ -117,11 +117,6 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
label="Projects" label="Projects"
href={`/${params.organizationId}/${projectId}/settings/projects`} href={`/${params.organizationId}/${projectId}/settings/projects`}
/> />
<LinkWithIcon
icon={KeySquareIcon}
label="Clients"
href={`/${params.organizationId}/${projectId}/settings/clients`}
/>
<LinkWithIcon <LinkWithIcon
icon={UserIcon} icon={UserIcon}
label="Profile (yours)" label="Profile (yours)"

View File

@@ -1,32 +0,0 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import type { getClientsByOrganizationId } from '@openpanel/db';
interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}
export default function ListClients({ clients }: ListClientsProps) {
return (
<>
<StickyBelowHeader>
<div className="flex items-center justify-between p-4">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
<span className="max-sm:hidden">Create client</span>
<span className="sm:hidden">Client</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={clients} columns={columns} />
</div>
</>
);
}

View File

@@ -1,21 +0,0 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getClientsByOrganizationId } from '@openpanel/db';
import ListClients from './list-clients';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} />
</PageLayout>
);
}

View File

@@ -1,19 +1,29 @@
'use client'; 'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/data-table'; import { ClientActions } from '@/components/clients/client-actions';
import { columns } from '@/components/projects/table'; import { ProjectActions } from '@/components/projects/project-actions';
// import { columns } from '@/components/projects/table';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react'; import { InfoIcon, PlusIcon, PlusSquareIcon } from 'lucide-react';
import type { getProjectsByOrganizationSlug } from '@openpanel/db'; import type { IServiceClientWithProject, IServiceProject } from '@openpanel/db';
interface ListProjectsProps { interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>; projects: IServiceProject[];
clients: IServiceClientWithProject[];
} }
export default function ListProjects({ projects }: ListProjectsProps) { export default function ListProjects({ projects, clients }: ListProjectsProps) {
const organizationId = useAppParams().organizationId; const organizationId = useAppParams().organizationId;
return ( return (
<> <>
@@ -34,7 +44,83 @@ export default function ListProjects({ projects }: ListProjectsProps) {
</div> </div>
</StickyBelowHeader> </StickyBelowHeader>
<div className="p-4"> <div className="p-4">
<DataTable data={projects} columns={columns} /> <div className="card p-4">
<Alert className="mb-4">
<InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle>
<AlertDescription>
A project can be a website, mobile app or any other application
that you want to track event for. Each project can have one or
more clients. The client is used to send events to the project.
</AlertDescription>
</Alert>
<Accordion type="single" collapsible className="-mx-4">
{projects.map((project) => {
const pClients = clients.filter(
(client) => client.project_id === project.id
);
return (
<AccordionItem
value={project.id}
key={project.id}
className="last:border-b-0"
>
<AccordionTrigger className="px-4">
<div className="flex-1 text-left">
{project.name}
<span className="ml-2 text-muted-foreground">
{pClients.length > 0
? `(${pClients.length} clients)`
: 'No clients created yet'}
</span>
</div>
<div className="mx-4"></div>
</AccordionTrigger>
<AccordionContent className="px-4">
<ProjectActions {...project} />
<div className="mt-4 grid gap-4 md:grid-cols-3">
{pClients.map((item) => {
return (
<div
className="relative rounded border border-border p-4"
key={item.id}
>
<div className="mb-1 font-medium">{item.name}</div>
<Tooltiper
className="text-muted-foreground"
content={item.id}
>
Client ID: ...{item.id.slice(-12)}
</Tooltiper>
<div className="text-muted-foreground">
{item.secret
? 'Secret: Hidden'
: `Website: ${item.cors}`}
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
</div>
);
})}
<button
onClick={() => {
pushModal('AddClient', {
projectId: project.id,
});
}}
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
>
<PlusSquareIcon />
<div className="font-medium">New client</div>
</button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</div> </div>
</> </>
); );

View File

@@ -1,6 +1,9 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getProjectsByOrganizationSlug } from '@openpanel/db'; import {
getClientsByOrganizationId,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
import ListProjects from './list-projects'; import ListProjects from './list-projects';
@@ -11,11 +14,14 @@ interface PageProps {
} }
export default async function Page({ params: { organizationId } }: PageProps) { export default async function Page({ params: { organizationId } }: PageProps) {
const projects = await getProjectsByOrganizationSlug(organizationId); const [projects, clients] = await Promise.all([
getProjectsByOrganizationSlug(organizationId),
getClientsByOrganizationId(organizationId),
]);
return ( return (
<PageLayout title="Projects" organizationSlug={organizationId}> <PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} /> <ListProjects projects={projects} clients={clients} />
</PageLayout> </PageLayout>
); );
} }

View File

@@ -2,22 +2,13 @@
import { pushModal, showConfirm } from '@/modals'; import { pushModal, showConfirm } from '@/modals';
import { api } from '@/trpc/client'; import { api } from '@/trpc/client';
import { clipboard } from '@/utils/clipboard'; import { Edit2Icon, TrashIcon } from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { IServiceProject } from '@openpanel/db'; import type { IServiceProject } from '@openpanel/db';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ProjectActions(project: Exclude<IServiceProject, null>) { export function ProjectActions(project: Exclude<IServiceProject, null>) {
const { id } = project; const { id } = project;
@@ -32,43 +23,34 @@ export function ProjectActions(project: Exclude<IServiceProject, null>) {
}); });
return ( return (
<DropdownMenu> <div className="flex gap-2">
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost" className="h-8 w-8 p-0"> variant="secondary"
<span className="sr-only">Open menu</span> onClick={() => {
<MoreHorizontal className="h-4 w-4" /> pushModal('EditProject', project);
</Button> }}
</DropdownMenuTrigger> icon={Edit2Icon}
<DropdownMenuContent align="end"> >
<DropdownMenuLabel>Actions</DropdownMenuLabel> Edit project
<DropdownMenuItem onClick={() => clipboard(id)}> </Button>
Copy project ID <Button
</DropdownMenuItem> variant="secondary"
<DropdownMenuItem className="text-destructive"
onClick={() => { onClick={() => {
pushModal('EditProject', project); showConfirm({
}} title: 'Delete project',
> text: 'This will delete all events for this project. This action cannot be undone.',
Edit onConfirm() {
</DropdownMenuItem> deletion.mutate({
<DropdownMenuSeparator /> id,
<DropdownMenuItem });
className="text-destructive" },
onClick={() => { });
showConfirm({ }}
title: 'Delete project', icon={TrashIcon}
text: 'This will delete all events for this project. This action cannot be undone.', >
onConfirm() { Delete project
deletion.mutate({ </Button>
id, </div>
});
},
});
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
); );
} }

View File

@@ -25,7 +25,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', 'flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=closed]]:hover:bg-muted/30 [&[data-state=open]>svg]:rotate-180',
className className
)} )}
{...props} {...props}

View File

@@ -33,14 +33,22 @@ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltiperProps { interface TooltiperProps {
asChild: boolean; asChild?: boolean;
content: string; content: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
export function Tooltiper({ asChild, content, children }: TooltiperProps) { export function Tooltiper({
asChild,
content,
children,
className,
}: TooltiperProps) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger> <TooltipTrigger asChild={asChild} className={className}>
{children}
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent> <TooltipContent>{content}</TooltipContent>
</Tooltip> </Tooltip>
); );

View File

@@ -40,7 +40,10 @@ const validation = z
type IForm = z.infer<typeof validation>; type IForm = z.infer<typeof validation>;
export default function AddClient() { interface Props {
projectId: string;
}
export default function AddClient(props: Props) {
const { organizationId, projectId } = useAppParams(); const { organizationId, projectId } = useAppParams();
const router = useRouter(); const router = useRouter();
const form = useForm<IForm>({ const form = useForm<IForm>({
@@ -49,7 +52,7 @@ export default function AddClient() {
name: '', name: '',
cors: '', cors: '',
tab: 'website', tab: 'website',
projectId, projectId: props.projectId ?? projectId,
}, },
}); });
const mutation = api.client.create.useMutation({ const mutation = api.client.create.useMutation({

View File

@@ -1,6 +1,7 @@
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { escape } from 'sqlstring';
import { createEvent } from '@openpanel/db'; import { chQuery, createEvent, db } from '@openpanel/db';
import type { import type {
EventsQueuePayload, EventsQueuePayload,
EventsQueuePayloadCreateSessionEnd, EventsQueuePayloadCreateSessionEnd,
@@ -19,7 +20,17 @@ export async function eventsJob(job: Job<EventsQueuePayload>) {
if (job.attemptsStarted > 1 && job.data.payload.duration < 0) { if (job.attemptsStarted > 1 && job.data.payload.duration < 0) {
job.data.payload.duration = 0; job.data.payload.duration = 0;
} }
return await createEvent(job.data.payload); const createdEvent = await createEvent(job.data.payload);
try {
await updateEventsCount(job.data.payload.projectId);
} catch (e) {
if (e instanceof Error) {
job.log(`Failed to update events count: ${e.message}`);
} else {
job.log(`Failed to update events count: Unknown issue`);
}
}
return createdEvent;
} }
case 'createSessionEnd': { case 'createSessionEnd': {
return await createSessionEnd( return await createSessionEnd(
@@ -28,3 +39,20 @@ export async function eventsJob(job: Job<EventsQueuePayload>) {
} }
} }
} }
async function updateEventsCount(projectId: string) {
const res = await chQuery<{ count: number }>(
`SELECT * FROM events WHERE project_id = ${escape(projectId)}`
);
const count = res[0]?.count;
if (count) {
await db.project.update({
where: {
id: projectId,
},
data: {
eventsCount: count,
},
});
}
}

View File

@@ -32,6 +32,9 @@ export async function getProjectsByOrganizationSlug(slug: string) {
where: { where: {
organization_slug: slug, organization_slug: slug,
}, },
orderBy: {
createdAt: 'desc',
},
}); });
return res.map(transformProject); return res.map(transformProject);