improve(dashboard): the flow of creating a project

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-06-11 22:10:09 +02:00
parent 5e023d0227
commit 82239a7d9a
7 changed files with 268 additions and 183 deletions

View File

@@ -108,12 +108,11 @@ export default function EditProjectDetails({ project }: Props) {
control={form.control}
render={({ field }) => (
<WithLabel
label="Cors"
label="Allowed domains"
error={form.formState.errors.cors?.message}
>
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Add a domain"
value={field.value ?? []}

View File

@@ -203,10 +203,9 @@ export const OnboardingCreateProject = ({
name="cors"
control={form.control}
render={({ field }) => (
<WithLabel label="Cors">
<WithLabel label="Allowed domains">
<TagInput
{...field}
id="Cors"
error={form.formState.errors.cors?.message}
placeholder="Accept events from these domains"
value={field.value ?? []}

View File

@@ -5,7 +5,7 @@ import type { IServiceClient } from '@openpanel/db';
import CopyInput from '../forms/copy-input';
type Props = IServiceClient;
type Props = { id: string; secret: string };
export function CreateClientSuccess({ id, secret }: Props) {
return (

View File

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

View File

@@ -0,0 +1,222 @@
'use client';
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/useAppParams';
import { api, handleError } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zOnboardingProject } from '@openpanel/validation';
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 form = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
organizationId,
timezone: '', // Not used
project: '',
domain: '',
cors: [],
website: false,
app: false,
backend: false,
},
});
const mutation = api.project.create.useMutation({
onError: handleError,
onSuccess: () => {
toast.success('Project created');
},
});
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.isLoading}
type="submit"
icon={SaveIcon}
>
Create project
</Button>
</ButtonContainer>
</form>
</>
)}
</ModalContent>
);
}

View File

@@ -38,7 +38,7 @@ const modals = {
EditClient: dynamic(() => import('./EditClient'), {
loading: Loading,
}),
AddProject: dynamic(() => import('./AddProject'), {
AddProject: dynamic(() => import('./add-project'), {
loading: Loading,
}),
AddClient: dynamic(() => import('./AddClient'), {

View File

@@ -1,6 +1,10 @@
import { z } from 'zod';
import crypto from 'node:crypto';
import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import {
type Prisma,
db,
getClientById,
getClientByIdCached,
@@ -8,12 +12,10 @@ import {
getProjectByIdCached,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { stripTrailingSlash } from '@openpanel/common';
import { zProject } from '@openpanel/validation';
import { zOnboardingProject, zProject } from '@openpanel/validation';
import { addDays, addHours } from 'date-fns';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const projectRouter = createTRPCRouter({
@@ -81,25 +83,50 @@ export const projectRouter = createTRPCRouter({
return res;
}),
create: protectedProcedure
.input(
zProject.omit({ id: true }).merge(
z.object({
organizationId: z.string(),
}),
),
)
.input(zOnboardingProject)
.mutation(async ({ input }) => {
return db.project.create({
if (!input.organizationId) {
throw TRPCBadRequestError('Organization is required');
}
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const data: Prisma.ClientCreateArgs['data'] = {
organizationId: input.organizationId,
name: 'First client',
type: 'write',
secret: await hashPassword(secret),
};
const project = await db.project.create({
data: {
id: await getId('project', input.name),
id: await getId('project', input.project),
organizationId: input.organizationId,
name: input.name,
name: input.project,
domain: input.domain,
cors: input.cors,
crossDomain: input.crossDomain,
crossDomain: false,
filters: [],
clients: {
create: data,
},
},
include: {
clients: {
select: {
id: true,
},
},
},
});
return {
...project,
client: project.clients[0]
? {
id: project.clients[0].id,
secret,
}
: null,
};
}),
delete: protectedProcedure
.input(