feature(dashboard): add ability to filter out events by profile id and ip (#101)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-12-07 21:34:32 +01:00
committed by GitHub
parent 27ee623584
commit f4ad97d87d
39 changed files with 1148 additions and 542 deletions

View File

@@ -77,7 +77,9 @@ export default function LayoutProjectSelector({
<span className="mx-2 truncate">
{projectId
? projects.find((p) => p.id === projectId)?.name
: 'Select project'}
: organizationId
? organizations?.find((o) => o.id === organizationId)?.name
: 'Select project'}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -14,6 +14,7 @@ import type {
getProjectsByOrganizationId,
} from '@openpanel/db';
import Link from 'next/link';
import LayoutMenu from './layout-menu';
import LayoutProjectSelector from './layout-project-selector';
@@ -64,7 +65,9 @@ export function LayoutSidebar({
</Button>
</div>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<LogoSquare className="max-h-8" />
<Link href="/">
<LogoSquare className="max-h-8" />
</Link>
<LayoutProjectSelector
align="start"
projects={projects}

View File

@@ -41,26 +41,29 @@ export default function EditOrganization({
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
</WidgetBody>
</Widget>
</form>
<section className="max-w-screen-sm">
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Details</span>
</WidgetHead>
<WidgetBody className="flex items-end gap-2">
<InputWithLabel
className="flex-1"
label="Name"
{...register('name')}
defaultValue={organization?.name}
/>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetBody>
</Widget>
</form>
</section>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import AnimateHeight from '@/components/animate-height';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { type IProjectEdit, zProject } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, UseFormReturn, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
const validator = zProject.pick({
name: true,
id: true,
domain: true,
cors: true,
crossDomain: true,
});
type IForm = z.infer<typeof validator>;
export default function EditProjectDetails({ project }: Props) {
const [hasDomain, setHasDomain] = useState(true);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id: project.id,
name: project.name,
domain: project.domain,
cors: project.cors,
crossDomain: project.crossDomain,
},
});
const mutation = api.project.update.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project updated');
},
});
const onSubmit = (values: IForm) => {
if (hasDomain) {
let error = false;
if (values.cors.length === 0) {
form.setError('cors', {
type: 'required',
message: 'Please add at least one cors domain',
});
error = true;
}
if (!values.domain) {
form.setError('domain', {
type: 'required',
message: 'Please add a domain',
});
error = true;
}
if (error) {
return;
}
}
mutation.mutate(hasDomain ? values : { ...values, cors: [], domain: null });
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Details</span>
</WidgetHead>
<WidgetBody>
<form
onSubmit={form.handleSubmit(onSubmit, (errors) => {
console.log(errors);
})}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
defaultValue={project.name}
/>
<div className="-mb-2 flex gap-2 items-center justify-between">
<Label className="mb-0">Domain</Label>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</div>
<AnimateHeight open={hasDomain}>
<Input
placeholder="Domain"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
defaultValue={project.domain ?? ''}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Cors">
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*' ? 'Allow all domains' : tag
}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
<Button
loading={mutation.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceProjectWithClients } from '@openpanel/db';
import type {
IProjectFilterIp,
IProjectFilterProfileId,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
type Props = { project: IServiceProjectWithClients };
const validator = z.object({
ips: z.array(z.string()),
profileIds: z.array(z.string()),
});
type IForm = z.infer<typeof validator>;
export default function EditProjectFilters({ project }: Props) {
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
ips: project.filters
.filter((item): item is IProjectFilterIp => item.type === 'ip')
.map((item) => item.ip),
profileIds: project.filters
.filter(
(item): item is IProjectFilterProfileId => item.type === 'profile_id',
)
.map((item) => item.profileId),
},
});
const mutation = api.project.update.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project filters updated');
},
});
const onSubmit = (values: IForm) => {
mutation.mutate({
id: project.id,
filters: [
...values.ips.map((ip) => ({ type: 'ip' as const, ip })),
...values.profileIds.map((profileId) => ({
type: 'profile_id' as const,
profileId,
})),
],
});
};
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="col gap-2">
<span className="title">Exclude events</span>
<p className="text-muted-foreground">
Exclude events from being tracked by adding filters.
</p>
</WidgetHead>
<WidgetBody>
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<Controller
name="ips"
control={form.control}
render={({ field }) => (
<WithLabel label="IP addresses">
<TagInput
{...field}
id="IP addresses"
error={form.formState.errors.ips?.message}
placeholder="Exclude IP addresses"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Controller
name="profileIds"
control={form.control}
render={({ field }) => (
<WithLabel label="Profile IDs">
<TagInput
{...field}
id="Profile IDs"
error={form.formState.errors.profileIds?.message}
placeholder="Exclude Profile IDs"
value={field.value}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
<Button
loading={mutation.isLoading}
type="submit"
icon={SaveIcon}
className="self-end"
>
Save
</Button>
</form>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,114 +0,0 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { ClientActions } from '@/components/clients/client-actions';
import { ProjectActions } from '@/components/projects/project-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { pushModal } from '@/modals';
import { InfoIcon, PlusIcon, PlusSquareIcon } from 'lucide-react';
import type { IServiceClientWithProject, IServiceProject } from '@openpanel/db';
interface ListProjectsProps {
projects: IServiceProject[];
clients: IServiceClientWithProject[];
}
export default function ListProjects({ projects, clients }: ListProjectsProps) {
return (
<>
<div className="row mb-4 justify-between">
<h1 className="text-2xl font-bold">Projects</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
<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.projectId === 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" />
</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.cors &&
item.cors !== '*' &&
`Website: ${item.cors}`}
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
</div>
);
})}
<button
type="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>
</>
);
}

View File

@@ -1,29 +1,40 @@
import { Padding } from '@/components/ui/padding';
import {
db,
getClientsByOrganizationId,
getProjectWithClients,
getProjectsByOrganizationId,
} from '@openpanel/db';
import ListProjects from './list-projects';
import { notFound } from 'next/navigation';
import EditProjectDetails from './edit-project-details';
import EditProjectFilters from './edit-project-filters';
import ProjectClients from './project-clients';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
};
}
export default async function Page({
params: { organizationSlug: organizationId },
}: PageProps) {
const [projects, clients] = await Promise.all([
getProjectsByOrganizationId(organizationId),
getClientsByOrganizationId(organizationId),
]);
export default async function Page({ params: { projectId } }: PageProps) {
const project = await getProjectWithClients(projectId);
if (!project) {
notFound();
}
return (
<Padding>
<ListProjects projects={projects} clients={clients} />
<div className="col gap-4">
<div className="row justify-between items-center">
<h1 className="text-2xl font-bold">{project.name}</h1>
</div>
<EditProjectDetails project={project} />
<EditProjectFilters project={project} />
<ProjectClients project={project} />
</div>
</Padding>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { ClientsTable } from '@/components/clients/table';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { pushModal } from '@/modals';
import type {
IServiceClientWithProject,
IServiceProjectWithClients,
} from '@openpanel/db';
import { PlusIcon } from 'lucide-react';
import { omit } from 'ramda';
type Props = { project: IServiceProjectWithClients };
export default function ProjectClients({ project }: Props) {
return (
<Widget className="max-w-screen-md w-full overflow-hidden">
<WidgetHead className="flex items-center justify-between">
<span className="title">Clients</span>
<Button
variant="outline"
icon={PlusIcon}
className="-my-1"
onClick={() => pushModal('AddClient')}
>
New client
</Button>
</WidgetHead>
<WidgetBody className="p-0 [&>div]:border-none [&>div]:rounded-none">
<ClientsTable
// @ts-expect-error
query={{
data: project.clients.map((item) => ({
...item,
project: omit(['clients'], item),
})) as unknown as IServiceClientWithProject[],
isFetching: false,
isLoading: false,
}}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,10 +1,11 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullWidthNavbar from '@/components/full-width-navbar';
import ProjectCard from '@/components/projects/project-card';
import SignOutButton from '@/components/sign-out-button';
import { redirect } from 'next/navigation';
import SettingsToggle from '@/components/settings-toggle';
import { getCurrentOrganizations, getCurrentProjects } from '@openpanel/db';
import LayoutProjectSelector from './[projectId]/layout-project-selector';
interface PageProps {
params: {
@@ -21,7 +22,6 @@ export default async function Page({
]);
const organization = organizations.find((org) => org.id === organizationId);
console.log(organizations, organizationId, projects);
if (!organization) {
return (
@@ -42,10 +42,16 @@ export default async function Page({
return (
<div>
<FullWidthNavbar>
<SignOutButton />
<div className="row gap-4">
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
</FullWidthNavbar>
<div className="mx-auto flex flex-col gap-4 p-4 pt-20 md:max-w-[95vw] lg:max-w-[80vw] ">
<h1 className="text-xl font-medium">Select project</h1>
<div className="grid gap-4 md:grid-cols-2">
{projects.map((item) => (
<ProjectCard key={item.id} {...item} />

View File

@@ -1,55 +0,0 @@
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ACTIONS } from '../data-table';
import { ClientActions } from './client-actions';
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return (
<div>
<div>{row.original.name}</div>
<div className=" text-muted-foreground">
{row.original.project?.name ?? 'No project'}
</div>
</div>
);
},
},
{
accessorKey: 'id',
header: 'Client ID',
},
{
accessorKey: 'cors',
header: 'Cors',
},
{
accessorKey: 'secret',
header: 'Secret',
cell: (info) =>
info.getValue() ? (
<div className="italic text-muted-foreground">Hidden</div>
) : (
'None'
),
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return formatDate(date);
},
},
{
id: ACTIONS,
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];

View File

@@ -0,0 +1,56 @@
import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { TooltipComplete } from '@/components/tooltip-complete';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import { ACTIONS } from '@/components/data-table';
import type { IServiceClientWithProject, IServiceEvent } from '@openpanel/db';
import { ClientActions } from '../client-actions';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
return <div className="font-medium">{row.original.name}</div>;
},
},
{
accessorKey: 'id',
header: 'Client ID',
cell: ({ row }) => <div className="font-mono">{row.original.id}</div>,
},
// {
// accessorKey: 'secret',
// header: 'Secret',
// cell: (info) =>
// <div className="italic text-muted-foreground"></div>
// },
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
id: ACTIONS,
header: 'Actions',
cell: ({ row }) => <ClientActions {...row.original} />,
},
];
return columns;
}

View File

@@ -0,0 +1,67 @@
import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon, PlusIcon } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type { IServiceClientWithProject } from '@openpanel/db';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useColumns } from './columns';
type Props = {
query: UseQueryResult<IServiceClientWithProject[]>;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
};
export const ClientsTable = ({ query, ...props }: Props) => {
const columns = useColumns();
const { data, isFetching, isLoading } = query;
if (isLoading) {
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {
return (
<FullPageEmptyState title="No clients here" icon={GanttChartIcon}>
<p>Could not find any clients</p>
<div className="row gap-4 mt-4">
{'cursor' in props && props.cursor !== 0 && (
<Button
className="mt-8"
variant="outline"
onClick={() => props.setCursor((p) => p - 1)}
>
Go to previous page
</Button>
)}
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
Add client
</Button>
</div>
</FullPageEmptyState>
);
}
return (
<>
<DataTable data={data ?? []} columns={columns} />
{'cursor' in props && (
<Pagination
className="mt-2"
setCursor={props.setCursor}
cursor={props.cursor}
count={Number.POSITIVE_INFINITY}
take={50}
loading={isFetching}
/>
)}
</>
);
};

View File

@@ -14,6 +14,7 @@ type Props = {
className?: string;
onChange: (value: string[]) => void;
renderTag?: (tag: string) => string;
id?: string;
};
const TagInput = ({
@@ -22,6 +23,7 @@ const TagInput = ({
renderTag,
placeholder,
error,
id,
}: Props) => {
const value = (
Array.isArray(propValue) ? propValue : propValue ? [propValue] : []
@@ -34,7 +36,7 @@ const TagInput = ({
const [scope, animate] = useAnimate();
const appendTag = (tag: string) => {
onChange([...value, tag]);
onChange([...value, tag.trim()]);
};
const removeTag = (tag: string) => {
@@ -141,6 +143,7 @@ const TagInput = ({
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
id={id}
/>
</div>
);

View File

@@ -2,7 +2,7 @@
import { cn } from '@/utils/cn';
import { Logo } from './logo';
import { Logo, LogoSquare } from './logo';
type Props = {
children: React.ReactNode;
@@ -13,7 +13,7 @@ const FullWidthNavbar = ({ children, className }: Props) => {
return (
<div className={cn('border-b border-border bg-card', className)}>
<div className="mx-auto flex h-14 w-full items-center justify-between px-4 md:max-w-[95vw] lg:max-w-[80vw]">
<Logo />
<LogoSquare className="size-8" />
{children}
</div>
</div>

View File

@@ -5,30 +5,48 @@ import { escape } from 'sqlstring';
import type { IServiceProject } from '@openpanel/db';
import { TABLE_NAMES, chQuery } from '@openpanel/db';
import { SettingsIcon } from 'lucide-react';
import Link from 'next/link';
import { ChartSSR } from '../chart-ssr';
import { FadeIn } from '../fade-in';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { LinkButton } from '../ui/button';
function ProjectCard({ id, name, organizationId }: IServiceProject) {
function ProjectCard({ id, domain, name, organizationId }: IServiceProject) {
// For some unknown reason I get when navigating back to this page when using <Link />
// Should be solved: https://github.com/vercel/next.js/issues/61336
// But still get the error
return (
<a
href={`/${organizationId}/${id}`}
className="card inline-flex flex-col gap-2 p-4 transition-transform hover:-translate-y-1"
>
<div className="font-medium">{name}</div>
<div className="-mx-4 aspect-[15/1]">
<Suspense>
<ProjectChart id={id} />
</Suspense>
</div>
<div className="flex justify-end gap-4 ">
<Suspense>
<ProjectMetrics id={id} />
</Suspense>
</div>
</a>
<div className="relative card hover:-translate-y-px hover:shadow-sm">
<a
href={`/${organizationId}/${id}`}
className="col p-4 transition-transform"
>
<div className="font-medium flex items-center gap-2 text-lg pb-2">
<div className="row gap-2 flex-1">
{domain && <SerieIcon name={domain ?? ''} />}
{name}
</div>
</div>
<div className="-mx-4 aspect-[8/1]">
<Suspense>
<ProjectChart id={id} />
</Suspense>
</div>
<div className="flex justify-end gap-4 h-9 md:h-4">
<Suspense>
<ProjectMetrics id={id} />
</Suspense>
</div>
</a>
<LinkButton
variant="ghost"
href={`/${organizationId}/${id}/settings/projects`}
className="text-muted-foreground absolute top-2 right-2"
>
<SettingsIcon size={16} />
</LinkButton>
</div>
);
}

View File

@@ -17,6 +17,8 @@ import { CheckIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
import { useTheme } from 'next-themes';
import * as React from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import { useAuth } from '@clerk/nextjs';
import { ProjectLink } from './links';
interface Props {
@@ -25,6 +27,8 @@ interface Props {
export default function SettingsToggle({ className }: Props) {
const { setTheme, theme } = useTheme();
const { projectId } = useAppParams();
const auth = useAuth();
return (
<DropdownMenu>
@@ -35,37 +39,47 @@ export default function SettingsToggle({ className }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem asChild>
<ProjectLink href="/reports">
Create report
<DropdownMenuShortcut>
<PlusIcon className="h-4 w-4" />
</DropdownMenuShortcut>
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/organization">Organization</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/projects">Projects</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/profile">Your profile</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/references">References</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/notifications">
Notifications
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/integrations">Integrations</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
{projectId && (
<>
<DropdownMenuItem asChild>
<ProjectLink href="/reports">
Create report
<DropdownMenuShortcut>
<PlusIcon className="h-4 w-4" />
</DropdownMenuShortcut>
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/organization">
Organization
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/projects">
Project & Clients
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/profile">Your profile</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/references">References</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/notifications">
Notifications
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/integrations">
Integrations
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex w-full items-center justify-between">
Theme
@@ -87,7 +101,14 @@ export default function SettingsToggle({ className }: Props) {
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">Logout</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => {
auth.signOut();
}}
>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -52,7 +52,7 @@ const CheckboxInput = React.forwardRef<
)}
>
<Checkbox ref={ref} {...props} className="relative top-0.5" />
<div className=" font-medium">{props.children}</div>
<div className="font-medium leading-5">{props.children}</div>
</label>
));
CheckboxInput.displayName = 'CheckboxInput';

View File

@@ -14,6 +14,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import { PopoverPortal } from '@radix-ui/react-popover';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronsUpDown } from 'lucide-react';
import VirtualList from 'rc-virtual-list';
@@ -98,67 +99,69 @@ export function Combobox<T extends string>({
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-full max-w-md p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search item..."
value={search}
onValueChange={setSearch}
/>
)}
{typeof onCreate === 'function' && search ? (
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search as T);
setSearch('');
setOpen(false);
}}
>
Create &quot;{search}&quot;
</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<VirtualList
height={Math.min(items.length * 32, 300)}
data={items.filter((item) => {
if (search === '') return true;
return item.label.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="min-w-60"
>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0',
)}
/>
{item.label}
</CommandItem>
<PopoverPortal>
<PopoverContent
className="w-full max-w-md p-0"
align={align}
portal={portal}
>
<Command shouldFilter={false}>
{searchable === true && (
<CommandInput
placeholder="Search item..."
value={search}
onValueChange={setSearch}
/>
)}
</VirtualList>
</Command>
</PopoverContent>
{typeof onCreate === 'function' && search ? (
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search as T);
setSearch('');
setOpen(false);
}}
>
Create &quot;{search}&quot;
</Button>
</CommandEmpty>
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<VirtualList
height={Math.min(items.length * 32, 300)}
data={items.filter((item) => {
if (search === '') return true;
return item.label.toLowerCase().includes(search.toLowerCase());
})}
itemHeight={32}
itemKey="value"
className="min-w-60"
>
{(item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0',
)}
/>
{item.label}
</CommandItem>
)}
</VirtualList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -9,7 +9,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
return (
<div
className={cn(
'border-b border-border p-4 [&_.title]:whitespace-nowrap [&_.title]:font-medium',
'border-b border-border p-4 [&_.title]:whitespace-nowrap [&_.title]:font-semibold [&_.title]:text-lg',
className,
)}
>

View File

@@ -27,31 +27,21 @@ import { ModalContent, ModalHeader } from './Modal/Container';
const validation = z.object({
name: z.string().min(1),
cors: z.string().min(1).or(z.literal('')),
projectId: z.string(),
type: z.enum(['read', 'write', 'root']),
crossDomain: z.boolean().optional(),
});
type IForm = z.infer<typeof validation>;
interface Props {
projectId: string;
}
export default function AddClient(props: Props) {
export default function AddClient() {
const { organizationId, projectId } = useAppParams();
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
name: '',
cors: '',
projectId: props.projectId ?? projectId,
type: 'write',
crossDomain: undefined,
},
});
const [hasDomain, setHasDomain] = useState(true);
const mutation = api.client.create.useMutation({
onError: handleError,
onSuccess() {
@@ -63,19 +53,11 @@ export default function AddClient(props: Props) {
organizationId,
});
const onSubmit: SubmitHandler<IForm> = (values) => {
if (hasDomain && values.cors === '') {
return form.setError('cors', {
type: 'required',
message: 'Please add a domain',
});
}
mutation.mutate({
name: values.name,
cors: hasDomain ? values.cors : null,
projectId: values.projectId,
organizationId,
type: values.type,
crossDomain: values.crossDomain,
projectId,
organizationId,
});
};
@@ -106,33 +88,6 @@ export default function AddClient(props: Props) {
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Controller
control={form.control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
className="w-full"
onChange={(value) => {
field.onChange(value);
}}
items={
query.data?.map((item) => ({
value: item.id,
label: item.name,
})) ?? []
}
placeholder="Select a project"
/>
</div>
);
}}
/>
</div>
<div>
<Label>Client name</Label>
<Input
@@ -142,67 +97,6 @@ export default function AddClient(props: Props) {
/>
</div>
<div>
<Label className="flex items-center justify-between">
<span>Domain(s)</span>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</Label>
<AnimateHeight open={hasDomain}>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value?.split(',') ?? []}
renderTag={(tag) =>
tag === '*' ? 'Allow all domains' : tag
}
onChange={(newValue) => {
field.onChange(
newValue
.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
})
.join(','),
);
}}
/>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
</div>
<div>
<Controller
control={form.control}
@@ -233,7 +127,7 @@ export default function AddClient(props: Props) {
]}
placeholder="Select a project"
/>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
{field.value === 'write' &&
'Write: Is the default client type and is used for ingestion of data'}
{field.value === 'read' &&

View File

@@ -1,67 +1,164 @@
'use client';
import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { CheckboxInput } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppParams } from '@/hooks/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import type { IServiceProjectWithClients } from '@openpanel/db';
import { zProject } from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import type { z } from 'zod';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1),
});
type Props = { project: IServiceProjectWithClients };
const validator = zProject.pick({
name: true,
domain: true,
cors: true,
crossDomain: true,
});
type IForm = z.infer<typeof validator>;
export default function AddProject() {
const { organizationId } = useAppParams();
const router = useRouter();
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess() {
router.refresh();
toast('Success', {
description: 'Project created! Lets create a client for it 🤘',
});
popModal();
},
});
const { register, handleSubmit, formState } = useForm<IForm>({
const [hasDomain, setHasDomain] = useState(true);
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: '',
domain: '',
cors: [],
crossDomain: false,
},
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project created');
},
});
const onSubmit = (values: IForm) => {
if (hasDomain) {
let error = false;
if (values.cors.length === 0) {
form.setError('cors', {
type: 'required',
message: 'Please add at least one cors domain',
});
error = true;
}
if (!values.domain) {
form.setError('domain', {
type: 'required',
message: 'Please add a domain',
});
error = true;
}
if (error) {
return;
}
}
mutation.mutate({
...(hasDomain ? values : { ...values, cors: [], domain: null }),
organizationId,
});
};
return (
<ModalContent>
<ModalHeader title="Create project" />
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
organizationId,
});
onSubmit={form.handleSubmit(onSubmit, (errors) => {
console.log(errors);
})}
className="col gap-4"
>
<div className="flex flex-col gap-4">
<InputWithLabel
label="Name"
placeholder="Name"
{...register('name')}
/>
<InputWithLabel label="Name" {...form.register('name')} />
<div className="-mb-2 flex gap-2 items-center justify-between">
<Label className="mb-0">Domain</Label>
<Switch checked={hasDomain} onCheckedChange={setHasDomain} />
</div>
<AnimateHeight open={hasDomain}>
<Input
placeholder="Domain"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Cors">
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}
renderTag={(tag) => (tag === '*' ? 'Allow all domains' : tag)}
onChange={(newValue) => {
field.onChange(
newValue.map((item) => {
const trimmed = item.trim();
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed === '*'
) {
return trimmed;
}
return `https://${trimmed}`;
}),
);
}}
/>
</WithLabel>
)}
/>
<Controller
name="crossDomain"
control={form.control}
render={({ field }) => {
return (
<CheckboxInput
className="mt-4"
ref={field.ref}
onBlur={field.onBlur}
defaultChecked={field.value}
onCheckedChange={field.onChange}
>
<div>Enable cross domain support</div>
<div className="font-normal text-muted-foreground">
This will let you track users across multiple domains
</div>
</CheckboxInput>
);
}}
/>
</AnimateHeight>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
<Button loading={mutation.isLoading} type="submit" icon={SaveIcon}>
Save
</Button>
</ButtonContainer>
</form>