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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
66
apps/start/src/modals/Instructions.tsx
Normal file
66
apps/start/src/modals/Instructions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/start/src/modals/Modal/Container.tsx
Normal file
60
apps/start/src/modals/Modal/Container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/start/src/modals/add-client.tsx
Normal file
161
apps/start/src/modals/add-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/start/src/modals/add-dashboard.tsx
Normal file
85
apps/start/src/modals/add-dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/start/src/modals/add-integration.tsx
Normal file
101
apps/start/src/modals/add-integration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
352
apps/start/src/modals/add-notification-rule.tsx
Normal file
352
apps/start/src/modals/add-notification-rule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
apps/start/src/modals/add-project.tsx
Normal file
240
apps/start/src/modals/add-project.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/modals/add-reference.tsx
Normal file
75
apps/start/src/modals/add-reference.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/start/src/modals/confirm.tsx
Normal file
45
apps/start/src/modals/confirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
apps/start/src/modals/create-invite.tsx
Normal file
187
apps/start/src/modals/create-invite.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/start/src/modals/date-ranger-picker.tsx
Normal file
85
apps/start/src/modals/date-ranger-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
apps/start/src/modals/date-time-picker.tsx
Normal file
193
apps/start/src/modals/date-time-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/start/src/modals/edit-client.tsx
Normal file
69
apps/start/src/modals/edit-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/start/src/modals/edit-dashboard.tsx
Normal file
76
apps/start/src/modals/edit-dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
apps/start/src/modals/edit-event.tsx
Normal file
214
apps/start/src/modals/edit-event.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/start/src/modals/edit-member.tsx
Normal file
86
apps/start/src/modals/edit-member.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/start/src/modals/edit-reference.tsx
Normal file
96
apps/start/src/modals/edit-reference.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/start/src/modals/edit-report.tsx
Normal file
45
apps/start/src/modals/edit-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
457
apps/start/src/modals/event-details.tsx
Normal file
457
apps/start/src/modals/event-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/start/src/modals/index.tsx
Normal file
69
apps/start/src/modals/index.tsx
Normal 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);
|
||||
51
apps/start/src/modals/onboarding-troubleshoot.tsx
Normal file
51
apps/start/src/modals/onboarding-troubleshoot.tsx
Normal 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'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'll help you out.
|
||||
</p>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
31
apps/start/src/modals/overview-chart-details.tsx
Normal file
31
apps/start/src/modals/overview-chart-details.tsx
Normal 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;
|
||||
76
apps/start/src/modals/request-reset-password.tsx
Normal file
76
apps/start/src/modals/request-reset-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
apps/start/src/modals/save-report.tsx
Normal file
299
apps/start/src/modals/save-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/start/src/modals/share-overview-modal.tsx
Normal file
80
apps/start/src/modals/share-overview-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user