feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,66 @@
import Syntax from '@/components/syntax';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button, LinkButton } from '@/components/ui/button';
import {
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { ExternalLinkIcon, XIcon } from 'lucide-react';
import type { IServiceClient } from '@openpanel/db';
import type { frameworks } from '@openpanel/sdk-info';
import { popModal } from '.';
type Props = {
client: IServiceClient | null;
framework: (typeof frameworks)[number];
};
const Header = ({ framework }: Pick<Props, 'framework'>) => (
<SheetHeader>
<SheetTitle>Instructions for {framework.name}</SheetTitle>
</SheetHeader>
);
const Footer = ({ framework }: Pick<Props, 'framework'>) => (
<SheetFooter className="absolute bottom-0 left-0 right-0 p-4">
<Button
variant={'secondary'}
className="flex-1"
onClick={() => popModal()}
icon={XIcon}
>
Close
</Button>
<LinkButton
target="_blank"
href={framework.href}
className="flex-1"
icon={ExternalLinkIcon}
>
More details
</LinkButton>
</SheetFooter>
);
const Instructions = ({ framework }: Props) => {
return (
<iframe
className="w-full h-full"
src={framework.href}
title={framework.name}
/>
);
};
export default function InstructionsWithModalContent(props: Props) {
return (
<SheetContent className="p-0">
<Instructions {...props} />
<Footer {...props} />
</SheetContent>
);
}

View File

@@ -0,0 +1,60 @@
import { Button } from '@/components/ui/button';
import { DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/utils/cn';
import type { DialogContentProps } from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { popModal } from '..';
interface ModalContentProps extends DialogContentProps {
children: React.ReactNode;
}
export function ModalContent({ children, ...props }: ModalContentProps) {
return <DialogContent {...props}>{children}</DialogContent>;
}
interface ModalHeaderProps {
title: string | React.ReactNode;
text?: string | React.ReactNode;
onClose?: (() => void) | false;
className?: string;
}
export function ModalHeader({
title,
text,
onClose,
className,
}: ModalHeaderProps) {
return (
<div
className={cn(
'relative -m-6 mb-4 flex justify-between rounded-t-lg p-6 pb-0',
className,
)}
>
<div className="row relative w-full justify-between gap-4">
<div className="col flex-1 gap-2">
<DialogTitle>{title}</DialogTitle>
{!!text && (
<div className="text-lg text-muted-foreground leading-normal">
{text}
</div>
)}
</div>
{onClose !== false && (
<Button
variant="ghost"
size="sm"
onClick={() => (onClose ? onClose() : popModal())}
className="-mt-2"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { CreateClientSuccess } from '@/components/clients/create-client-success';
import { Button, buttonVariants } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon } from 'lucide-react';
import type { SubmitHandler } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validation = z.object({
name: z.string().min(1),
type: z.enum(['read', 'write', 'root']),
});
type IForm = z.infer<typeof validation>;
export default function AddClient() {
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
name: '',
type: 'write',
},
});
const queryClient = useQueryClient();
const trpc = useTRPC();
const mutation = useMutation(
trpc.client.create.mutationOptions({
onSuccess() {
toast.success('Client created successfully');
queryClient.invalidateQueries(
trpc.project.getProjectWithClients.pathFilter(),
);
},
onError: handleError,
}),
);
const onSubmit: SubmitHandler<IForm> = (values) => {
mutation.mutate({
name: values.name,
type: values.type,
projectId,
organizationId,
});
};
return (
<ModalContent>
{mutation.isSuccess ? (
<>
<ModalHeader title="Success" text={'Your client is created'} />
<CreateClientSuccess {...mutation.data} />
<div className="mt-4 flex gap-4">
<a
className={cn(buttonVariants({ variant: 'secondary' }), 'flex-1')}
href="https://openpanel.dev/docs"
target="_blank"
rel="noreferrer"
>
Read docs
</a>
<Button className="flex-1" onClick={() => popModal()}>
Close
</Button>
</div>
</>
) : (
<>
<ModalHeader title="Create a client" />
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div>
<Label>Client name</Label>
<Input
placeholder="Eg. My App Client"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
</div>
<div>
<Controller
control={form.control}
name="type"
render={({ field }) => {
return (
<div>
<Label>Type of client</Label>
<Combobox
{...field}
className="w-full"
onChange={(value) => {
field.onChange(value);
}}
items={[
{
value: 'write',
label: 'Write (for ingestion)',
},
{
value: 'read',
label: 'Read (access export API)',
},
{
value: 'root',
label: 'Root (access export API)',
},
]}
placeholder="Select a project"
/>
<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' &&
'Read: You can access the current projects data from the export API'}
{field.value === 'root' &&
'Root: You can access any projects data from the export API'}
</p>
</div>
);
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => popModal()}
>
Cancel
</Button>
<Button
type="submit"
icon={SaveIcon}
loading={mutation.isPending}
>
Create
</Button>
</DialogFooter>
</form>
</>
)}
</ModalContent>
);
}

View File

@@ -0,0 +1,85 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1, 'Required'),
});
type IForm = z.infer<typeof validator>;
export default function AddDashboard() {
const { projectId, organizationId } = useAppParams();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { register, handleSubmit, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: '',
},
});
const mutation = useMutation(
trpc.dashboard.create.mutationOptions({
onSuccess(res) {
router.navigate({
to: '/$organizationId/$projectId/dashboards/$dashboardId',
params: {
organizationId,
projectId,
dashboardId: res.id,
},
});
toast('Success', {
description: 'Dashboard created.',
});
queryClient.invalidateQueries(trpc.dashboard.pathFilter());
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Add dashboard" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ name }) => {
mutation.mutate({
name,
projectId,
});
})}
>
<InputWithLabel
label="Name"
placeholder="Name of the dashboard"
{...register('name')}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,101 @@
import { useTRPC } from '@/integrations/trpc/react';
import { DiscordIntegrationForm } from '@/components/integrations/forms/discord-integration';
import { SlackIntegrationForm } from '@/components/integrations/forms/slack-integration';
import { WebhookIntegrationForm } from '@/components/integrations/forms/webhook-integration';
import { IntegrationCardContent } from '@/components/integrations/integration-card';
import { INTEGRATIONS } from '@/components/integrations/integrations';
import { SheetContent } from '@/components/ui/sheet';
import type { IIntegrationConfig } from '@openpanel/validation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
interface Props {
id?: string;
type: IIntegrationConfig['type'];
}
export default function AddIntegration(props: Props) {
const trpc = useTRPC();
const query = useQuery(
trpc.integration.get.queryOptions(
{
id: props.id ?? '',
},
{
enabled: !!props.id,
},
),
);
const integration = INTEGRATIONS.find((i) => i.type === props.type);
const renderCard = () => {
if (!integration) {
return null;
}
return (
<div className="card bg-def-100">
<IntegrationCardContent {...integration} />
</div>
);
};
const [tab, setTab] = useQueryState('tab', {
shallow: false,
});
const client = useQueryClient();
const handleSuccess = () => {
toast.success('Integration created');
popModal();
client.invalidateQueries(trpc.integration.list.queryFilter());
client.invalidateQueries(
trpc.integration.get.queryFilter({ id: props.id }),
);
if (tab !== undefined) {
setTab('installed');
}
};
const renderForm = () => {
if (props.id && query.isLoading) {
return null;
}
switch (integration?.type) {
case 'webhook':
return (
<WebhookIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
case 'discord':
return (
<DiscordIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
case 'slack':
return (
<SlackIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
default:
return null;
}
};
return (
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title="Create an integration" />
{renderCard()}
{renderForm()}
</SheetContent>
);
}

View File

@@ -0,0 +1,352 @@
import type { RouterOutputs } from '@/trpc/client';
import { SheetContent } from '@/components/ui/sheet';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
import { ColorSquare } from '@/components/color-square';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Textarea } from '@/components/ui/textarea';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useTRPC } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { shortId } from '@openpanel/common';
import { zCreateNotificationRule } from '@openpanel/validation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { FilterIcon, PlusIcon, SaveIcon, TrashIcon } from 'lucide-react';
import {
Controller,
type SubmitHandler,
type UseFormReturn,
useFieldArray,
useForm,
useWatch,
} from 'react-hook-form';
import type { z } from 'zod';
interface Props {
rule?: RouterOutputs['notification']['rules'][number];
}
type IForm = z.infer<typeof zCreateNotificationRule>;
export default function AddNotificationRule({ rule }: Props) {
const client = useQueryClient();
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
resolver: zodResolver(zCreateNotificationRule),
defaultValues: {
id: rule?.id ?? '',
name: rule?.name ?? '',
sendToApp: rule?.sendToApp ?? false,
sendToEmail: rule?.sendToEmail ?? false,
integrations:
rule?.integrations.map((integration) => integration.id) ?? [],
projectId,
template: rule?.template ?? '',
config: rule?.config ?? {
type: 'events',
events: [
{
name: '',
segment: 'event',
filters: [],
},
],
},
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.notification.createOrUpdateRule.mutationOptions({
onSuccess() {
toast.success(
rule ? 'Notification rule updated' : 'Notification rule created',
);
client.refetchQueries(
trpc.notification.rules.queryFilter({
projectId,
}),
);
popModal();
},
}),
);
const integrationsQuery = useQuery(
trpc.integration.list.queryOptions({
organizationId: organizationId!,
}),
);
const eventsArray = useFieldArray({
control: form.control,
name: 'config.events',
});
const onSubmit: SubmitHandler<IForm> = (data) => {
if (!data.config.events[0]?.name) {
toast.error('At least one event is required');
return;
}
mutation.mutate(data);
};
const integrations = integrationsQuery.data ?? [];
return (
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title={rule ? 'Edit rule' : 'Create rule'} />
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
label="Rule name"
placeholder="Eg. Sign ups on android"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
<WithLabel
label="Type"
// @ts-expect-error
error={form.formState.errors.config?.type.message}
>
<Controller
control={form.control}
name="config.type"
render={({ field }) => (
<Combobox
{...field}
className="w-full"
placeholder="Select type"
// @ts-expect-error
error={form.formState.errors.config?.type.message}
items={[
{
label: 'Events',
value: 'events',
},
{
label: 'Funnel',
value: 'funnel',
},
]}
/>
)}
/>
</WithLabel>
<WithLabel label="Events">
<div className="col gap-2">
{eventsArray.fields.map((field, index) => {
return (
<EventField
key={field.id}
form={form}
index={index}
remove={() => eventsArray.remove(index)}
/>
);
})}
<Button
className="self-start"
variant={'outline'}
icon={PlusIcon}
onClick={() =>
eventsArray.append({
name: '',
filters: [],
segment: 'event',
})
}
>
Add event
</Button>
</div>
</WithLabel>
<WithLabel
label="Template"
info={
<div className="prose dark:prose-invert">
<p>
Customize your notification message. You can grab any property
from your event.
</p>
<ul>
<li>
<code>{'{{name}}'}</code> - The name of the event
</li>
<li>
<code>{'{{rule_name}}'}</code> - The name of the rule
</li>
<li>
<code>{'{{properties.your.property}}'}</code> - Get the value
of a custom property
</li>
<li>
<code>{'{{profile.firstName}}'}</code> - Get the value of a
profile property
</li>
<li>
<div className="flex gap-x-2 flex-wrap">
And many more...
<code>profileId</code>
<code>createdAt</code>
<code>country</code>
<code>city</code>
<code>os</code>
<code>osVersion</code>
<code>browser</code>
<code>browserVersion</code>
<code>device</code>
<code>brand</code>
<code>model</code>
<code>path</code>
<code>origin</code>
<code>referrer</code>
<code>referrerName</code>
<code>referrerType</code>
</div>
</li>
</ul>
</div>
}
>
<Textarea
{...form.register('template')}
placeholder="You received a new '$EVENT_NAME' event"
/>
</WithLabel>
<Controller
control={form.control}
name="integrations"
render={({ field }) => (
<WithLabel label="Integrations">
<ComboboxAdvanced
{...field}
value={field.value ?? []}
className="w-full"
placeholder="Pick integrations"
items={integrations.map((integration) => ({
label: integration.name,
value: integration.id,
}))}
/>
</WithLabel>
)}
/>
<Button type="submit" icon={SaveIcon}>
{rule ? 'Update' : 'Create'}
</Button>
</form>
</SheetContent>
);
}
function EventField({
form,
index,
remove,
}: {
form: UseFormReturn<IForm>;
index: number;
remove: () => void;
}) {
const { projectId } = useAppParams();
const eventNames = useEventNames({ projectId });
const filtersArray = useFieldArray({
control: form.control,
name: `config.events.${index}.filters`,
});
const eventName = useWatch({
control: form.control,
name: `config.events.${index}.name`,
});
const properties = useEventProperties({ projectId });
return (
<div className="border bg-def-100 rounded">
<div className="row gap-2 items-center p-2">
<ColorSquare>{index + 1}</ColorSquare>
<Controller
control={form.control}
name={`config.events.${index}.name`}
render={({ field }) => (
<ComboboxEvents
searchable
className="flex-1"
value={field.value}
placeholder="Select event"
onChange={field.onChange}
items={eventNames}
/>
)}
/>
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties.map((item) => ({
label: item,
value: item,
}))}
onChange={(value) => {
filtersArray.append({
id: shortId(),
name: value,
operator: 'is',
value: [],
});
}}
>
<Button variant={'outline'} icon={FilterIcon} size={'icon'} />
</Combobox>
<Button
onClick={() => {
remove();
}}
variant={'outline'}
className="text-destructive"
icon={TrashIcon}
size={'icon'}
/>
</div>
{filtersArray.fields.map((filter, index) => {
return (
<div key={filter.id} className="p-2 border-t">
<PureFilterItem
eventName={eventName}
filter={filter}
onRemove={() => {
filtersArray.remove(index);
}}
onChangeValue={(value) => {
filtersArray.update(index, {
...filter,
value,
});
}}
onChangeOperator={(operator) => {
filtersArray.update(index, {
...filter,
operator,
});
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,240 @@
import AnimateHeight from '@/components/animate-height';
import { ButtonContainer } from '@/components/button-container';
import { CreateClientSuccess } from '@/components/clients/create-client-success';
import { CheckboxItem } from '@/components/forms/checkbox-item';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import TagInput from '@/components/forms/tag-input';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { zOnboardingProject } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import {
MonitorIcon,
SaveIcon,
ServerIcon,
SmartphoneIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zOnboardingProject;
type IForm = z.infer<typeof validator>;
export default function AddProject() {
const { organizationId } = useAppParams();
const navigate = useNavigate();
const form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
organizationId,
timezone: '', // Not used
project: '',
domain: '',
cors: [],
website: false,
app: false,
backend: false,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.project.create.mutationOptions({
onError: handleError,
onSuccess: (res) => {
toast.success('Project created', {
description: `${res.name}`,
action: {
label: 'View project',
onClick: () =>
navigate({
to: '/$organizationId/$projectId',
params: {
organizationId,
projectId: res.id,
},
}),
},
});
},
}),
);
const onSubmit = (values: IForm) => {
mutation.mutate(values);
};
const isWebsite = useWatch({
name: 'website',
control: form.control,
});
const isApp = useWatch({
name: 'app',
control: form.control,
});
const isBackend = useWatch({
name: 'backend',
control: form.control,
});
useEffect(() => {
if (!isWebsite) {
form.setValue('domain', null);
form.setValue('cors', []);
}
}, [isWebsite, form]);
useEffect(() => {
form.clearErrors();
}, [isWebsite, isApp, isBackend]);
return (
<ModalContent>
{mutation.isSuccess ? (
<>
<ModalHeader title="Success" text={'Your project is created'} />
{mutation.data.client && (
<CreateClientSuccess {...mutation.data.client} />
)}
<ButtonContainer className="justify-end">
<Button className="flex-1" onClick={() => popModal()}>
Close
</Button>
</ButtonContainer>
</>
) : (
<>
<ModalHeader title="Create project" />
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
label="Project name"
placeholder="Eg. My music site"
{...form.register('project')}
error={form.formState.errors.project?.message}
/>
<div className="flex flex-col divide-y">
<Controller
name="website"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.website?.message}
Icon={MonitorIcon}
label="Website"
disabled={isApp}
description="Track events and conversion for your website"
{...field}
>
<AnimateHeight open={isWebsite && !isApp}>
<div className="p-4 pl-14">
<InputWithLabel
label="Domain"
placeholder="Your website address"
{...form.register('domain')}
className="mb-4"
error={form.formState.errors.domain?.message}
onBlur={(e) => {
const value = e.target.value.trim();
if (
value.includes('.') &&
form.getValues().cors.length === 0 &&
!form.formState.errors.domain
) {
form.setValue('cors', [value]);
}
}}
/>
<Controller
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Allowed domains">
<TagInput
{...field}
error={form.formState.errors.cors?.message}
placeholder="Accept events from these domains"
value={field.value ?? []}
renderTag={(tag) =>
tag === '*'
? 'Accept events from any 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>
)}
/>
</div>
</AnimateHeight>
</CheckboxItem>
)}
/>
<Controller
name="app"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.app?.message}
disabled={isWebsite}
Icon={SmartphoneIcon}
label="App"
description="Track events and conversion for your app"
{...field}
/>
)}
/>
<Controller
name="backend"
control={form.control}
render={({ field }) => (
<CheckboxItem
error={form.formState.errors.backend?.message}
Icon={ServerIcon}
label="Backend / API"
description="Track events and conversion for your backend / API"
{...field}
/>
)}
/>
</div>
<ButtonContainer className="justify-end">
<Button
loading={mutation.isPending}
type="submit"
icon={SaveIcon}
>
Create project
</Button>
</ButtonContainer>
</form>
</>
)}
</ModalContent>
);
}

View File

@@ -0,0 +1,75 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zCreateReference } from '@openpanel/validation';
import { InputDateTime } from '@/components/ui/input-date-time';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type IForm = z.infer<typeof zCreateReference>;
export default function AddReference() {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const { register, handleSubmit, formState, control } = useForm<IForm>({
resolver: zodResolver(zCreateReference),
defaultValues: {
title: '',
description: '',
projectId,
datetime: new Date().toISOString(),
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.reference.create.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(trpc.reference.pathFilter());
toast('Success', {
description: 'Reference created.',
});
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Add reference" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => mutation.mutate(values))}
>
<InputWithLabel label="Title" {...register('title')} />
<InputWithLabel label="Description" {...register('description')} />
<Controller
control={control}
name="datetime"
render={({ field }) => (
<InputDateTime {...field} label="Date and time" />
)}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Create
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,45 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
export type ConfirmProps = {
title: string;
text: string;
onConfirm: () => void;
onCancel?: () => void;
};
export default function Confirm({
title,
text,
onConfirm,
onCancel,
}: ConfirmProps) {
return (
<ModalContent>
<ModalHeader title={title} />
<p className="text-lg -mt-2">{text}</p>
<ButtonContainer>
<Button
variant="outline"
onClick={() => {
popModal('Confirm');
onCancel?.();
}}
>
Cancel
</Button>
<Button
onClick={() => {
popModal('Confirm');
onConfirm();
}}
>
Yes
</Button>
</ButtonContainer>
</ModalContent>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
closeSheet,
} from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { useTRPC } from '@/integrations/trpc/react';
import type { IServiceProject } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
type IForm = z.infer<typeof zInviteUser>;
export default function CreateInvite() {
const { organizationId } = useAppParams();
const trpc = useTRPC();
const projectsQuery = useQuery(
trpc.project.list.queryOptions({
organizationId,
}),
);
const projects = projectsQuery.data ?? [];
const queryClient = useQueryClient();
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationId,
access: [],
role: 'org:member',
},
});
const mutation = useMutation(
trpc.organization.inviteUser.mutationOptions({
onSuccess() {
toast.success('User has been invited');
reset();
queryClient.invalidateQueries(
trpc.organization.invitations.queryFilter({ organizationId }),
);
},
onError(error) {
toast.error('Failed to invite user', {
description: error.message,
});
},
}),
);
return (
<>
{mutation.isSuccess ? (
<SheetContent>
<SheetHeader>
<SheetTitle>User has been invited</SheetTitle>
</SheetHeader>
<div className="prose dark:prose-invert">
{mutation.data.type === 'is_member' ? (
<>
<p>
Since the user already has an account we have added him/her to
your organization. This means you will not see this user in
the list of invites.
</p>
<p>We have also notified the user by email about this.</p>
</>
) : (
<p>
We have sent an email with instructions to join the
organization.
</p>
)}
<div className="row gap-4 mt-8">
<Button onClick={() => mutation.reset()}>
Invite another user
</Button>
<Button variant="outline" onClick={() => closeSheet()}>
Close
</Button>
</div>
</div>
</SheetContent>
) : (
<SheetContent>
<SheetHeader>
<div>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email
will instructions.
</SheetDescription>
</div>
</SheetHeader>
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex flex-col gap-8"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
error={formState.errors.email?.message}
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div>
<Label>What role?</Label>
<Controller
name="role"
control={control}
render={({ field }) => (
<RadioGroup
defaultValue={field.value}
onChange={field.onChange}
ref={field.ref}
onBlur={field.onBlur}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:member" id="member" />
<Label className="mb-0" htmlFor="member">
Member
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:admin" id="admin" />
<Label className="mb-0" htmlFor="admin">
Admin
</Label>
</div>
</RadioGroup>
)}
/>
</div>
<Controller
name="access"
control={control}
render={({ field }) => (
<div>
<Label>Restrict access</Label>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={field.value}
onChange={field.onChange}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
<p className="mt-1 text-sm text-muted-foreground">
Leave empty to give access to all projects
</p>
</div>
)}
/>
<SheetFooter>
<Button
icon={SendIcon}
type="submit"
loading={mutation.isPending}
>
Invite user
</Button>
</SheetFooter>
</form>
</SheetContent>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { subMonths } from 'date-fns';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { formatDate } from '@/utils/date';
import { CheckIcon, XIcon } from 'lucide-react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
onChange: (payload: { startDate: Date; endDate: Date }) => void;
startDate?: Date;
endDate?: Date;
};
export default function DateRangerPicker({
onChange,
startDate: initialStartDate,
endDate: initialEndDate,
}: Props) {
const { isBelowSm } = useBreakpoint('sm');
const [startDate, setStartDate] = useState(initialStartDate);
const [endDate, setEndDate] = useState(initialEndDate);
return (
<ModalContent className="p-4 md:p-8 min-w-fit">
<Calendar
initialFocus
mode="range"
defaultMonth={subMonths(
startDate ? new Date(startDate) : new Date(),
isBelowSm ? 0 : 1,
)}
selected={{
from: startDate,
to: endDate,
}}
toDate={new Date()}
onSelect={(range) => {
if (range?.from) {
setStartDate(range.from);
}
if (range?.to) {
setEndDate(range.to);
}
}}
numberOfMonths={isBelowSm ? 1 : 2}
className="mx-auto min-h-[310px] [&_table]:mx-auto [&_table]:w-auto p-0"
/>
<div className="col flex-col-reverse md:row gap-2">
<Button
type="button"
variant="outline"
onClick={() => popModal()}
icon={XIcon}
>
Cancel
</Button>
{startDate && endDate && (
<Button
type="button"
className="md:ml-auto"
onClick={() => {
popModal();
if (startDate && endDate) {
onChange({
startDate: startDate,
endDate: endDate,
});
}
}}
icon={startDate && endDate ? CheckIcon : XIcon}
>
{startDate && endDate
? `Select ${formatDate(startDate)} - ${formatDate(endDate)}`
: 'Cancel'}
</Button>
)}
</div>
</ModalContent>
);
}

View File

@@ -0,0 +1,193 @@
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { CalendarIcon, ClockIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
// Utility function to round date to nearest 5-minute interval
function roundToNearestFiveMinutes(date: Date): Date {
const roundedDate = new Date(date);
const minutes = roundedDate.getMinutes();
const remainder = minutes % 5;
if (remainder === 0) {
return roundedDate;
}
// Round to nearest 5-minute interval
if (remainder >= 2.5) {
// Round up
roundedDate.setMinutes(minutes + (5 - remainder));
} else {
// Round down
roundedDate.setMinutes(minutes - remainder);
}
// Reset seconds and milliseconds
roundedDate.setSeconds(0);
roundedDate.setMilliseconds(0);
return roundedDate;
}
type Props = {
onChange: (date: Date) => void;
initialDate?: Date;
title?: string;
};
export default function DateTimePicker({
onChange,
initialDate,
title = 'Select Date & Time',
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
roundToNearestFiveMinutes(initialDate || new Date()),
);
// Generate all time options with 5-minute intervals
const generateTimeOptions = () => {
const times = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 5) {
const timeString = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const displayTime = format(new Date(2000, 0, 1, hour, minute), 'HH:mm');
times.push({ value: timeString, label: displayTime });
}
}
return times;
};
const timeOptions = generateTimeOptions();
function handleDateSelect(date: Date | undefined) {
if (date) {
// Preserve the existing time when changing date
const newDate = new Date(date);
newDate.setHours(selectedDate.getHours());
newDate.setMinutes(selectedDate.getMinutes());
// Round to nearest 5-minute interval
setSelectedDate(roundToNearestFiveMinutes(newDate));
}
}
function handleTimeSelect(timeValue: string) {
const [hours, minutes] = timeValue.split(':').map(Number);
const newDate = new Date(selectedDate);
newDate.setHours(hours);
newDate.setMinutes(minutes);
// Ensure alignment to 5-minute intervals (safety measure)
setSelectedDate(roundToNearestFiveMinutes(newDate));
}
const currentTimeValue = `${selectedDate.getHours().toString().padStart(2, '0')}:${selectedDate.getMinutes().toString().padStart(2, '0')}`;
// Scroll to selected time when modal opens
useEffect(() => {
const buttonSize = 32;
const buttonMargin = 2;
const containerPadding = 4;
const scrollContainer = scrollRef.current;
const buttonIndex = timeOptions.findIndex(
(time) => time.value === currentTimeValue,
);
const calculatedScrollTo =
Math.max(0, buttonIndex - 4) * (buttonSize + buttonMargin) +
containerPadding;
if (scrollContainer) {
scrollContainer.scrollTo({
top: calculatedScrollTo,
behavior: 'instant',
});
}
}, []); // Empty dependency array to run only on mount
return (
<ModalContent className="max-w-[400px]">
<ModalHeader title={title} />
<div className="space-y-4">
{/* Selected Date/Time Display */}
<div className="rounded-lg border border-dashed bg-muted/50 p-4">
<div className="flex items-center justify-center space-x-2 text-sm">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{format(selectedDate, 'EEEE, MMMM d, yyyy')}
</span>
<div className="h-4 w-px bg-border" />
<ClockIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium font-mono">
{format(selectedDate, 'HH:mm')}
</span>
</div>
</div>
{/* Calendar Section */}
<div className="row gap-2 h-[333px]">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
initialFocus
className="[&_table]:mx-auto [&_table]:w-auto border rounded-lg"
/>
<ScrollArea
className="h-full w-full border rounded-lg bg-background/50"
ref={scrollRef}
>
<div className="flex flex-col p-1">
{timeOptions.map((time) => (
<Button
key={time.value}
size="sm"
data-value={time.value}
variant={
currentTimeValue === time.value ? 'default' : 'ghost'
}
className={cn(
'w-full mb-0.5 h-8 text-xs font-mono transition-all duration-200 justify-start',
currentTimeValue === time.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'hover:bg-muted',
)}
onClick={() => handleTimeSelect(time.value)}
>
{time.label}
</Button>
))}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => popModal()}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={() => {
popModal();
onChange(selectedDate);
}}
>
Confirm Selection
</Button>
</div>
</div>
</ModalContent>
);
}

View File

@@ -0,0 +1,69 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceClient } from '@openpanel/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditClientProps = IServiceClient;
const validator = z.object({
id: z.string().min(1),
name: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
export default function EditClient({ id, name }: EditClientProps) {
const queryClient = useQueryClient();
const { register, handleSubmit, reset, formState, control, setError } =
useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id,
name,
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.client.update.mutationOptions({
onError: handleError,
onSuccess() {
reset();
toast('Success', {
description: 'Client updated.',
});
popModal();
queryClient.invalidateQueries(trpc.client.list.pathFilter());
},
}),
);
return (
<ModalContent>
<ModalHeader title="Edit client" />
<form onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<InputWithLabel label="Name" placeholder="Name" {...register('name')} />
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Save
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,76 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceDashboard } from '@openpanel/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditDashboardProps = Exclude<IServiceDashboard, null>;
const validator = z.object({
id: z.string().min(1),
name: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
export default function EditDashboard({ id, name }: EditDashboardProps) {
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id,
name,
},
});
const queryClient = useQueryClient();
const trpc = useTRPC();
const mutation = useMutation(
trpc.dashboard.update.mutationOptions({
onSuccess() {
reset();
toast('Success', {
description: 'Dashboard updated.',
});
popModal();
queryClient.invalidateQueries(trpc.dashboard.list.pathFilter());
},
}),
);
return (
<ModalContent>
<ModalHeader title="Edit dashboard" />
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<InputWithLabel
label="Name"
placeholder="Name"
{...register('name')}
defaultValue={name}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,214 @@
import {
EventIconColors,
EventIconMapper,
EventIconRecords,
} from '@/components/events/event-icon';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { PaintBucketIcon, UndoIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface Props {
id: string;
}
export default function EditEvent({ id }: Props) {
const { projectId } = useAppParams();
const trpc = useTRPC();
const client = useQueryClient();
const { data: event } = useQuery(
trpc.event.byId.queryOptions({ id, projectId }),
);
const [selectedIcon, setIcon] = useState<string | null>(null);
const [selectedColor, setColor] = useState(EventIconRecords.default!.color);
const [conversion, setConversion] = useState(false);
const [step, setStep] = useState<'icon' | 'color'>('icon');
useEffect(() => {
if (event?.meta?.icon) {
setIcon(event.meta.icon);
}
if (event?.meta?.color) {
setColor(event.meta.color);
}
if (event?.meta?.conversion) {
setConversion(event.meta.conversion);
}
}, [event]);
const SelectedIcon = selectedIcon ? EventIconMapper[selectedIcon] : null;
const mutation = useMutation(
trpc.event.updateEventMeta.mutationOptions({
onSuccess() {
toast('Event updated');
client.invalidateQueries(trpc.event.pathFilter());
popModal();
},
}),
);
const getBg = (color: string) => `bg-${color}-200`;
const getText = (color: string) => `text-${color}-700`;
const iconGrid = 'grid grid-cols-10 gap-4';
const [search, setSearch] = useState('');
return (
<ModalContent>
<ModalHeader
title={`Edit: ${event?.name}`}
text={`Changes here will affect all "${event?.name}" events`}
/>
<div className="col gap-4">
<div>
<Label className="mb-4 block">Conversion</Label>
<label className="flex cursor-pointer select-none items-center gap-4 rounded-md border border-border p-4">
<Checkbox
checked={conversion}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') return;
setConversion(checked);
}}
/>
<div>
<span>Yes, this event is important!</span>
</div>
</label>
</div>
<AnimatePresence mode="wait">
{step === 'icon' ? (
<motion.div
key="icon-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.15 }}
>
<div className="row mb-4 items-center justify-between">
<div className="font-medium leading-none">Pick an icon</div>
{
<button type="button" onClick={() => setStep('color')}>
<Badge variant="outline">
Select color
<PaintBucketIcon className="ml-1 h-3 w-3" />
</Badge>
</button>
}
</div>
<Input
className="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search for an icon"
/>
<div className={iconGrid}>
{Object.entries(EventIconMapper)
.filter(([name]) =>
name.toLowerCase().includes(search.toLowerCase()),
)
.map(([name, Icon]) => (
<button
type="button"
key={name}
onClick={() => {
setIcon(name);
setStep('color');
}}
className={cn(
'inline-flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-md bg-def-200 transition-all',
name === selectedIcon
? 'scale-110 ring-1 ring-black'
: '[&_svg]:opacity-50',
)}
>
<Icon size={16} />
</button>
))}
</div>
</motion.div>
) : (
<motion.div
key="color-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.15 }}
>
<div className="row mb-4 items-center justify-between">
<div className="font-medium leading-none">Pick a color</div>
<button type="button" onClick={() => setStep('icon')}>
<Badge variant="outline">
Select icon
<UndoIcon className="ml-1 h-3 w-3" />
</Badge>
</button>
</div>
<div className={iconGrid}>
{EventIconColors.map((color) => (
<button
type="button"
key={color}
onClick={() => {
setColor(color);
}}
className={cn(
'flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-md transition-all',
color === selectedColor ? 'ring-1 ring-black' : '',
getBg(color),
)}
>
{SelectedIcon ? (
<SelectedIcon size={16} className={getText(color)} />
) : (
<svg
className={`${getText(color)} opacity-70`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12.1" cy="12.1" r="4" />
</svg>
)}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<Button
className="mt-8 w-full"
disabled={mutation.isPending || !event}
onClick={() =>
mutation.mutate({
projectId,
name: event!.name,
icon: selectedIcon ?? EventIconRecords.default!.icon,
color: selectedColor ?? EventIconRecords.default!.color,
conversion,
})
}
>
Update event
</Button>
</div>
</ModalContent>
);
}

View File

@@ -0,0 +1,86 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';
import type { IServiceMember } from '@openpanel/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditMemberProps = IServiceMember;
export default function EditMember(member: EditMemberProps) {
const queryClient = useQueryClient();
const trpc = useTRPC();
const [access, setAccess] = useState<string[]>(
member.access?.map((a) => a.projectId) ?? [],
);
const projectsQuery = useQuery(
trpc.project.list.queryOptions({ organizationId: member.organizationId }),
);
const mutation = useMutation(
trpc.organization.updateMemberAccess.mutationOptions({
onError(error) {
handleError(error);
setAccess(member.access?.map((a) => a.projectId) ?? []);
},
onSuccess() {
toast.success('Access updated');
// Refresh members list so access column reflects changes
queryClient.invalidateQueries(trpc.organization.members.pathFilter());
popModal();
},
}),
);
const projects = projectsQuery.data ?? [];
return (
<ModalContent>
<ModalHeader
title={
member.user
? `Edit access for ${[member.user.firstName, member.user.lastName]
.filter(Boolean)
.join(' ')}`
: 'Edit member access'
}
/>
<div className="col gap-4">
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={access}
onChange={(newAccess) => setAccess(newAccess as string[])}
items={projects.map((p) => ({ label: p.name, value: p.id }))}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button
onClick={() =>
mutation.mutate({
userId: member.user!.id,
organizationId: member.organizationId,
access: access,
})
}
disabled={mutation.isPending}
>
Save
</Button>
</ButtonContainer>
</div>
</ModalContent>
);
}

View File

@@ -0,0 +1,96 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { InputDateTime } from '@/components/ui/input-date-time';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceReference } from '@openpanel/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
id: z.string().min(1),
title: z.string().min(1),
description: z.string().nullish(),
datetime: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
type EditReferenceProps = Pick<
IServiceReference,
'id' | 'title' | 'description' | 'date'
>;
export default function EditReference({
id,
title,
description,
date,
}: EditReferenceProps) {
const trpc = useTRPC();
const { handleSubmit, register, control, formState, reset } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
id,
title: title ?? '',
description: description ?? undefined,
datetime: new Date(date).toISOString(),
},
});
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.reference.update.mutationOptions({
onSuccess() {
toast('Success', { description: 'Reference updated.' });
reset();
queryClient.invalidateQueries(
trpc.reference.getReferences.pathFilter(),
);
queryClient.invalidateQueries(
trpc.reference.getChartReferences.pathFilter(),
);
popModal();
},
onError: handleError,
}),
);
return (
<ModalContent>
<ModalHeader title="Edit reference" />
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<InputWithLabel label="Title" {...register('title')} />
<InputWithLabel label="Description" {...register('description')} />
<Controller
control={control}
name="datetime"
render={({ field }) => (
<InputDateTime {...field} label="Date and time" />
)}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,45 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = z.object({
name: z.string().min(1),
});
type IForm = z.infer<typeof validator>;
type EditReportProps = {
form: IForm;
onSubmit: SubmitHandler<IForm>;
};
export default function EditReport({ form, onSubmit }: EditReportProps) {
const { register, handleSubmit, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: form,
});
return (
<ModalContent>
<ModalHeader title="Edit report" />
<form onSubmit={handleSubmit(onSubmit)}>
<InputWithLabel label="Name" placeholder="Name" {...register('name')} />
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty}>
Update
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,457 @@
import { ReportChartShortcut } from '@/components/report-chart/shortcut';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import { ProjectLink } from '@/components/links';
import {
WidgetButtons,
WidgetHead,
} from '@/components/overview/overview-widget';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Button } from '@/components/ui/button';
import { FieldValue, KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget, WidgetBody } from '@/components/widget';
import { fancyMinutes } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import {
ArrowLeftIcon,
ArrowRightIcon,
FilterIcon,
Loader2Icon,
XIcon,
} from 'lucide-react';
import { omit } from 'ramda';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent } from './Modal/Container';
interface Props {
id: string;
createdAt?: Date;
projectId: string;
}
const filterable: Partial<Record<keyof IServiceEvent, keyof IClickhouseEvent>> =
{
name: 'name',
referrer: 'referrer',
referrerName: 'referrer_name',
referrerType: 'referrer_type',
brand: 'brand',
model: 'model',
browser: 'browser',
browserVersion: 'browser_version',
os: 'os',
osVersion: 'os_version',
city: 'city',
region: 'region',
country: 'country',
device: 'device',
properties: 'properties',
path: 'path',
origin: 'origin',
};
export default function EventDetails({ id, createdAt, projectId }: Props) {
const [, setEvents] = useEventQueryNamesFilter();
const [, setFilter] = useEventQueryFilters();
const TABS = {
essentials: {
id: 'essentials',
title: 'Essentials',
},
detailed: {
id: 'detailed',
title: 'Detailed',
},
};
const [widget, setWidget] = useState(TABS.essentials);
const trpc = useTRPC();
const query = useQuery(
trpc.event.details.queryOptions({
id,
projectId,
createdAt,
}),
);
if (!query.data) {
return <EventDetailsSkeleton />;
}
const { event, session } = query.data;
const profile = event.profile;
const data = (() => {
const data: {
name: keyof IServiceEvent | string;
value: any;
event: IServiceEvent;
}[] = [
{
name: 'createdAt',
value: event.createdAt,
},
{
name: 'name',
value: event.name,
},
{
name: 'origin',
value: event.origin,
},
{
name: 'path',
value: event.path,
},
{
name: 'country',
value: event.country,
},
{
name: 'region',
value: event.region,
},
{
name: 'city',
value: event.city,
},
{
name: 'referrer',
value: event.referrer,
},
{
name: 'referrerName',
value: event.referrerName,
},
{
name: 'referrerType',
value: event.referrerType,
},
{
name: 'brand',
value: event.brand,
},
{
name: 'model',
value: event.model,
},
].map((item) => ({ ...item, event }));
if (widget.id === TABS.detailed.id) {
data.length = 0;
Object.entries(omit(['properties', 'profile', 'meta'], event)).forEach(
([name, value]) => {
if (!name.startsWith('__')) {
data.push({
name: name as keyof IServiceEvent,
value: value as any,
event,
});
}
},
);
}
return data.filter((item) => {
if (widget.id === TABS.essentials.id) {
return !!item.value;
}
return true;
});
})();
const properties = Object.entries(event.properties)
.filter(([name]) => !name.startsWith('__'))
.map(([name, value]) => ({
name,
value,
event,
}));
return (
<ModalContent className="!p-0">
<Widget className="bg-transparent border-0 min-w-0">
<WidgetHead>
<div className="row items-center justify-between">
<div className="title">{event.name}</div>
<div className="row items-center gap-2 pr-2">
{/* <Button
size="icon"
variant={'ghost'}
onClick={() => {
const event = new KeyboardEvent('keydown', {
key: 'ArrowLeft',
});
dispatchEvent(event);
}}
>
<ArrowLeftIcon className="size-4" />
</Button>
<Button
size="icon"
variant={'ghost'}
onClick={() => {
const event = new KeyboardEvent('keydown', {
key: 'ArrowRight',
});
dispatchEvent(event);
}}
>
<ArrowRightIcon className="size-4" />
</Button> */}
<Button size="icon" variant={'ghost'} onClick={() => popModal()}>
<XIcon className="size-4" />
</Button>
</div>
</div>
<WidgetButtons>
{Object.entries(TABS).map(([, tab]) => (
<button
key={tab.id}
type="button"
onClick={() => setWidget(tab)}
className={cn(tab.id === widget.id && 'active')}
>
{tab.title}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="col gap-4 bg-def-100">
{profile && (
<ProjectLink
onClick={() => popModal()}
href={`/profiles/${profile.id}`}
className="card p-4 py-2 col gap-2 hover:bg-def-100"
>
<div className="row items-center gap-2 justify-between">
<div className="row items-center gap-2 min-w-0">
{profile.avatar && (
<img
className="size-4 bg-border rounded-full"
src={profile.avatar}
/>
)}
<div className="font-medium truncate">
{getProfileName(profile, false)}
</div>
</div>
<div className="row items-center gap-2 shrink-0">
<div className="row gap-1 items-center">
<SerieIcon name={event.country} />
<SerieIcon name={event.os} />
<SerieIcon name={event.browser} />
</div>
<div className="text-muted-foreground truncate max-w-40">
{event.referrerName || event.referrer}
</div>
</div>
</div>
{!!session && (
<div className="text-sm">
This session has {session.screenViewCount} screen views and{' '}
{session.eventCount} events. Visit duration is{' '}
{fancyMinutes(session.duration / 1000)}.
</div>
)}
</ProjectLink>
)}
{properties.length > 0 && (
<section>
<div className="mb-2 flex justify-between font-medium">
<div>Properties</div>
</div>
<KeyValueGrid
columns={1}
data={properties}
renderValue={(item) => (
<div className="flex items-center gap-2">
<span className="font-mono">{String(item.value)}</span>
<FilterIcon className="size-3 shrink-0" />
</div>
)}
onItemClick={(item) => {
popModal();
setFilter(`properties.${item.name}`, item.value as any);
}}
/>
</section>
)}
<section>
<div className="mb-2 flex justify-between font-medium">
<div>Information</div>
</div>
<KeyValueGrid
columns={1}
data={data}
renderValue={(item) => {
const isFilterable =
item.value && (filterable as any)[item.name];
if (isFilterable) {
return (
<div className="flex items-center gap-2">
<FieldValue
name={item.name}
value={item.value}
event={event}
/>
<FilterIcon className="size-3 shrink-0" />
</div>
);
}
return (
<FieldValue
name={item.name}
value={item.value}
event={event}
/>
);
}}
onItemClick={(item) => {
const isFilterable =
item.value && (filterable as any)[item.name];
if (isFilterable) {
popModal();
setFilter(item.name as keyof IServiceEvent, item.value);
}
}}
/>
</section>
<section>
<div className="mb-2 flex justify-between font-medium">
<div>All events for {event.name}</div>
<button
type="button"
className="text-muted-foreground hover:underline"
onClick={() => {
setEvents([event.name]);
popModal();
}}
>
Show all
</button>
</div>
<div className="card p-4">
<ReportChartShortcut
projectId={event.projectId}
chartType="linear"
events={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
},
]}
/>
</div>
</section>
</WidgetBody>
</Widget>
</ModalContent>
);
}
function EventDetailsSkeleton() {
return (
<ModalContent className="!p-0">
<Widget className="bg-transparent border-0 min-w-0">
<WidgetHead>
<div className="row items-center justify-between">
<div className="h-6 w-32 bg-muted animate-pulse rounded" />
<div className="row items-center gap-2 pr-2">
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
<div className="h-8 w-8 bg-muted animate-pulse rounded" />
</div>
</div>
<WidgetButtons>
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
</WidgetButtons>
</WidgetHead>
<WidgetBody className="col gap-4 bg-def-100">
{/* Profile skeleton */}
<div className="card p-4 py-2 col gap-2">
<div className="row items-center gap-2 justify-between">
<div className="row items-center gap-2 min-w-0">
<div className="size-4 bg-muted animate-pulse rounded-full" />
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
</div>
<div className="row items-center gap-2 shrink-0">
<div className="row gap-1 items-center">
<div className="size-4 bg-muted animate-pulse rounded" />
<div className="size-4 bg-muted animate-pulse rounded" />
<div className="size-4 bg-muted animate-pulse rounded" />
</div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</div>
</div>
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
</div>
{/* Properties skeleton */}
<section>
<div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-20 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i.toString()}
className="flex items-center justify-between p-3 bg-muted/50 rounded"
>
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</div>
))}
</div>
</section>
{/* Information skeleton */}
<section>
<div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-24 bg-muted animate-pulse rounded" />
</div>
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i.toString()}
className="flex items-center justify-between p-3 bg-muted/50 rounded"
>
<div className="h-4 w-20 bg-muted animate-pulse rounded" />
<div className="h-4 w-28 bg-muted animate-pulse rounded" />
</div>
))}
</div>
</section>
{/* Chart skeleton */}
<section>
<div className="mb-2 flex justify-between font-medium">
<div className="h-5 w-40 bg-muted animate-pulse rounded" />
<div className="h-4 w-16 bg-muted animate-pulse rounded" />
</div>
<div className="card p-4">
<div className="h-32 w-full bg-muted animate-pulse rounded" />
</div>
</section>
</WidgetBody>
</Widget>
</ModalContent>
);
}

View File

@@ -0,0 +1,69 @@
import { createPushModal } from 'pushmodal';
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
import Instructions from './Instructions';
import AddClient from './add-client';
import AddDashboard from './add-dashboard';
import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule';
import AddProject from './add-project';
import AddReference from './add-reference';
import Confirm from './confirm';
import type { ConfirmProps } from './confirm';
import CreateInvite from './create-invite';
import DateRangerPicker from './date-ranger-picker';
import DateTimePicker from './date-time-picker';
import EditClient from './edit-client';
import EditDashboard from './edit-dashboard';
import EditEvent from './edit-event';
import EditMember from './edit-member';
import EditReference from './edit-reference';
import EditReport from './edit-report';
import EventDetails from './event-details';
import OnboardingTroubleshoot from './onboarding-troubleshoot';
import OverviewChartDetails from './overview-chart-details';
import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report';
import ShareOverviewModal from './share-overview-modal';
const modals = {
OverviewTopPagesModal: OverviewTopPagesModal,
OverviewTopGenericModal: OverviewTopGenericModal,
RequestPasswordReset: RequestPasswordReset,
EditEvent: EditEvent,
EditMember: EditMember,
EventDetails: EventDetails,
EditClient: EditClient,
AddProject: AddProject,
AddClient: AddClient,
Confirm: Confirm,
SaveReport: SaveReport,
AddDashboard: AddDashboard,
EditDashboard: EditDashboard,
EditReport: EditReport,
EditReference: EditReference,
ShareOverviewModal: ShareOverviewModal,
AddReference: AddReference,
Instructions: Instructions,
OnboardingTroubleshoot: OnboardingTroubleshoot,
DateRangerPicker: DateRangerPicker,
DateTimePicker: DateTimePicker,
OverviewChartDetails: OverviewChartDetails,
AddIntegration: AddIntegration,
AddNotificationRule: AddNotificationRule,
CreateInvite: CreateInvite,
};
export const {
pushModal,
popModal,
replaceWithModal,
popAllModals,
ModalProvider,
useOnPushModal,
} = createPushModal({
modals,
});
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);

View File

@@ -0,0 +1,51 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
import { ModalContent, ModalHeader } from './Modal/Container';
export default function OnboardingTroubleshoot() {
return (
<ModalContent>
<ModalHeader
title="Troubleshoot"
text="Hmm, you have troubles? Well, let's solve them together."
/>
<div className="flex flex-col gap-4">
<Alert>
<UserIcon size={16} />
<AlertTitle>Wrong client ID</AlertTitle>
<AlertDescription>
Make sure your <code>clientId</code> is correct
</AlertDescription>
</Alert>
<Alert>
<GlobeIcon size={16} />
<AlertTitle>Wrong domain on web</AlertTitle>
<AlertDescription>
For web apps its important that the domain is correctly configured.
We authenticate the requests based on the domain.
</AlertDescription>
</Alert>
<Alert>
<KeyIcon size={16} />
<AlertTitle>Wrong client secret</AlertTitle>
<AlertDescription>
For app and backend events it&apos;s important that you have correct{' '}
<code>clientId</code> and <code>clientSecret</code>
</AlertDescription>
</Alert>
</div>
<p className="mt-4 ">
Still have issues? Join our{' '}
<a href="https://go.openpanel.dev/discord" className="underline">
discord channel
</a>{' '}
give us an email at{' '}
<a href="mailto:hello@openpanel.dev" className="underline">
hello@openpanel.dev
</a>{' '}
and we&apos;ll help you out.
</p>
</ModalContent>
);
}

View File

@@ -0,0 +1,31 @@
import { ReportChart } from '@/components/report-chart';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IChartProps } from '@openpanel/validation';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
chart: IChartProps;
};
const OverviewChartDetails = (props: Props) => {
return (
<ModalContent>
<ModalHeader title={props.chart.name} />
<ScrollArea className="-m-6 max-h-[calc(100vh-200px)]">
<div className="p-6">
<ReportChart
report={{
...props.chart,
limit: 999,
chartType: 'bar',
}}
/>
</div>
</ScrollArea>
</ModalContent>
);
};
export default OverviewChartDetails;

View File

@@ -0,0 +1,76 @@
import { Button } from '@/components/ui/button';
import { DialogFooter } from '@/components/ui/dialog';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from '@tanstack/react-router';
import { SendIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { useTRPC } from '@/integrations/trpc/react';
import { zRequestResetPassword } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validation = zRequestResetPassword;
type IForm = z.infer<typeof validation>;
type Props = {
email?: string;
};
export default function RequestPasswordReset({ email }: Props) {
const router = useRouter();
const form = useForm<IForm>({
resolver: zodResolver(validation),
defaultValues: {
email: email ?? '',
},
});
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.requestResetPassword.mutationOptions({
onSuccess() {
toast.success('You should receive an email shortly!');
popModal();
},
onError: handleError,
}),
);
const onSubmit = form.handleSubmit((values) => {
mutation.mutate({
email: values.email,
});
});
return (
<ModalContent>
<ModalHeader title="Request password reset" />
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<InputWithLabel
label="Email"
placeholder="Your email address"
error={form.formState.errors.email?.message}
{...form.register('email')}
/>
<DialogFooter>
<Button
type="button"
variant={'secondary'}
onClick={() => popModal()}
>
Cancel
</Button>
<Button type="submit" icon={SendIcon} loading={mutation.isPending}>
Continue
</Button>
</DialogFooter>
</form>
</ModalContent>
);
}

View File

@@ -0,0 +1,299 @@
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter, useSearch } from '@tanstack/react-router';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IChartProps } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftIcon, PlusIcon, SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type SaveReportProps = {
report: IChartProps;
disableRedirect?: boolean;
};
const validator = z.object({
name: z.string().min(1, 'Required'),
dashboardId: z.string().min(1, 'Required'),
});
type IForm = z.infer<typeof validator>;
export default function SaveReport({
report,
disableRedirect,
}: SaveReportProps) {
const router = useRouter();
const queryClient = useQueryClient();
const { organizationId, projectId } = useAppParams();
const searchParams = useSearch({
from: '/_app/$organizationId/$projectId_/reports',
shouldThrow: false,
});
const dashboardId = searchParams?.dashboardId;
const trpc = useTRPC();
const save = useMutation(
trpc.report.create.mutationOptions({
onError: handleError,
onSuccess(res) {
const goToReport = () => {
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: res.id,
},
search: searchParams,
});
};
toast('Report created', {
description: `${res.name}`,
action: {
label: 'View report',
onClick: () => goToReport(),
},
});
if (!disableRedirect) {
goToReport();
}
popModal();
},
}),
);
const dashboardMutation = useMutation(
trpc.dashboard.create.mutationOptions({
onError: handleError,
onSuccess(res) {
setValue('dashboardId', res.id);
dashboardQuery.refetch();
queryClient.invalidateQueries(trpc.report.list.pathFilter());
toast('Success', {
description: 'Dashboard created.',
});
},
}),
);
const dashboardQuery = useQuery(
trpc.dashboard.list.queryOptions({
projectId: projectId!,
}),
);
const { register, handleSubmit, formState, control, setValue } =
useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: report.name,
dashboardId,
},
});
const dashboards = (dashboardQuery.data ?? []).map((item) => ({
value: item.id,
label: item.name,
}));
return (
<ModalContent>
<ModalHeader title="Create report" />
<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="dashboardId"
render={({ field }) => {
return (
<SelectDashboard
value={field.value}
onChange={field.onChange}
projectId={projectId!}
/>
);
}}
/>
<ButtonContainer>
<Button
type="button"
variant="outline"
onClick={() => popModal()}
size="default"
>
Cancel
</Button>
<Button type="submit" disabled={!formState.isValid} size="default">
Save
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}
function SelectDashboard({
value,
onChange,
projectId,
}: {
value: string;
onChange: (value: string) => void;
projectId: string;
}) {
const trpc = useTRPC();
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [newDashboardName, setNewDashboardName] = useState('');
const form = useForm({
resolver: zodResolver(z.object({ name: z.string().min(1, 'Required') })),
defaultValues: {
name: '',
},
});
const dashboardQuery = useQuery(
trpc.dashboard.list.queryOptions({
projectId: projectId!,
}),
);
const dashboardMutation = useMutation(
trpc.dashboard.create.mutationOptions({
onError: handleError,
async onSuccess(res) {
await dashboardQuery.refetch();
onChange(res.id);
setIsCreatingNew(false);
setNewDashboardName('');
form.reset();
},
}),
);
const handleSelectChange = (selectedValue: string) => {
if (selectedValue === 'create-new') {
setIsCreatingNew(true);
onChange(''); // Clear the current selection
} else {
setIsCreatingNew(false);
onChange(selectedValue);
}
};
const handleCreateDashboard = () => {
if (newDashboardName.trim()) {
dashboardMutation.mutate({
name: newDashboardName.trim(),
projectId,
});
}
};
const selectedDashboard = dashboardQuery.data?.find((d) => d.id === value);
return (
<div className="space-y-3">
<Label>Dashboard</Label>
{!isCreatingNew ? (
<div className="row gap-2 flex-wrap">
{dashboardQuery.data?.map((dashboard) => (
<Button
type="button"
key={dashboard.id}
variant={value === dashboard.id ? 'default' : 'outline'}
onClick={() => onChange(dashboard.id)}
>
{dashboard.name}
</Button>
))}
<Button
type="button"
variant="outline"
onClick={() => {
setIsCreatingNew(true);
onChange('');
}}
icon={PlusIcon}
>
Create new dashboard
</Button>
</div>
) : (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="icon"
icon={ArrowLeftIcon}
onClick={() => {
setIsCreatingNew(false);
setNewDashboardName('');
form.reset();
}}
/>
<Input
placeholder="Enter dashboard name"
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateDashboard();
}
}}
/>
<Button
type="button"
onClick={handleCreateDashboard}
disabled={!newDashboardName.trim() || dashboardMutation.isPending}
variant="outline"
icon={SaveIcon}
>
{dashboardMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zShareOverview } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zShareOverview;
type IForm = z.infer<typeof validator>;
export default function ShareOverviewModal() {
const { projectId, organizationId } = useAppParams();
const { register, handleSubmit } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: '',
projectId,
organizationId,
},
});
const trpc = useTRPC();
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.share.createOverview.mutationOptions({
onError: handleError,
onSuccess(res) {
queryClient.invalidateQueries(trpc.share.overview.pathFilter());
toast('Success', {
description: `Your overview is now ${
res.public ? 'public' : 'private'
}`,
});
popModal();
},
}),
);
return (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private."
/>
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Input
{...register('password')}
placeholder="Enter your password"
size="large"
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isPending}>
Make it public
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}