group validation

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-16 20:38:51 +01:00
parent fa78e63bc8
commit 995f32c5d8
4 changed files with 131 additions and 72 deletions

View File

@@ -1,21 +1,22 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateGroup } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label'; import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { handleError, useTRPC } from '@/integrations/trpc/react'; import { handleError, useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface IForm { const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
id: string; properties: z.array(z.object({ key: z.string(), value: z.string() })),
type: string; });
name: string; type IForm = z.infer<typeof zForm>;
properties: { key: string; value: string }[];
}
export default function AddGroup() { export default function AddGroup() {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
@@ -23,6 +24,7 @@ export default function AddGroup() {
const trpc = useTRPC(); const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({ const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: { defaultValues: {
id: '', id: '',
type: '', type: '',
@@ -45,7 +47,7 @@ export default function AddGroup() {
popModal(); popModal();
}, },
onError: handleError, onError: handleError,
}), })
); );
return ( return (
@@ -57,30 +59,46 @@ export default function AddGroup() {
const props = Object.fromEntries( const props = Object.fromEntries(
properties properties
.filter((p) => p.key.trim() !== '') .filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]), .map((p) => [p.key.trim(), String(p.value)])
); );
mutation.mutate({ projectId, ...values, properties: props }); mutation.mutate({ projectId, ...values, properties: props });
})} })}
> >
<InputWithLabel label="ID" placeholder="acme-corp" {...register('id', { required: true })} autoFocus /> <InputWithLabel
<InputWithLabel label="Name" placeholder="Acme Corp" {...register('name', { required: true })} /> label="ID"
<InputWithLabel label="Type" placeholder="company" {...register('type', { required: true })} /> placeholder="acme-corp"
{...register('id')}
autoFocus
error={formState.errors.id?.message}
/>
<InputWithLabel
label="Name"
placeholder="Acme Corp"
{...register('name')}
error={formState.errors.name?.message}
/>
<InputWithLabel
label="Type"
placeholder="company"
{...register('type')}
error={formState.errors.type?.message}
/>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span> <span className="font-medium text-sm">Properties</span>
<Button <Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button" type="button"
variant="outline" variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
> >
<PlusIcon className="mr-1 size-3" /> <PlusIcon className="mr-1 size-3" />
Add Add
</Button> </Button>
</div> </div>
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="flex gap-2"> <div className="flex gap-2" key={field.id}>
<input <input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key" placeholder="key"
@@ -92,11 +110,11 @@ export default function AddGroup() {
{...register(`properties.${index}.value`)} {...register(`properties.${index}.value`)}
/> />
<Button <Button
type="button"
variant="ghost"
size="icon"
className="shrink-0" className="shrink-0"
onClick={() => remove(index)} onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
> >
<Trash2Icon className="size-4" /> <Trash2Icon className="size-4" />
</Button> </Button>
@@ -105,10 +123,13 @@ export default function AddGroup() {
</div> </div>
<ButtonContainer> <ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}> <Button onClick={() => popModal()} type="button" variant="outline">
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}> <Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Create Create
</Button> </Button>
</ButtonContainer> </ButtonContainer>

View File

@@ -1,35 +1,51 @@
import { ButtonContainer } from '@/components/button-container'; import { zodResolver } from '@hookform/resolvers/zod';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import type { IServiceGroup } from '@openpanel/db'; import type { IServiceGroup } from '@openpanel/db';
import { zUpdateGroup } from '@openpanel/validation';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, Trash2Icon } from 'lucide-react'; import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
interface IForm { const zForm = zUpdateGroup
type: string; .omit({ id: true, projectId: true, properties: true })
name: string; .extend({
properties: { key: string; value: string }[]; properties: z.array(z.object({ key: z.string(), value: z.string() })),
} });
type IForm = z.infer<typeof zForm>;
type EditGroupProps = Pick<IServiceGroup, 'id' | 'projectId' | 'name' | 'type' | 'properties'>; type EditGroupProps = Pick<
IServiceGroup,
'id' | 'projectId' | 'name' | 'type' | 'properties'
>;
export default function EditGroup({ id, projectId, name, type, properties }: EditGroupProps) { export default function EditGroup({
id,
projectId,
name,
type,
properties,
}: EditGroupProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const trpc = useTRPC(); const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({ const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: { defaultValues: {
type, type,
name, name,
properties: Object.entries(properties as Record<string, string>).map(([key, value]) => ({ properties: Object.entries(properties as Record<string, string>).map(
key, ([key, value]) => ({
value: String(value), key,
})), value: String(value),
})
),
}, },
}); });
@@ -48,7 +64,7 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
popModal(); popModal();
}, },
onError: handleError, onError: handleError,
}), })
); );
return ( return (
@@ -60,29 +76,37 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
const props = Object.fromEntries( const props = Object.fromEntries(
formProps formProps
.filter((p) => p.key.trim() !== '') .filter((p) => p.key.trim() !== '')
.map((p) => [p.key.trim(), String(p.value)]), .map((p) => [p.key.trim(), String(p.value)])
); );
mutation.mutate({ id, projectId, ...values, properties: props }); mutation.mutate({ id, projectId, ...values, properties: props });
})} })}
> >
<InputWithLabel label="Name" {...register('name', { required: true })} /> <InputWithLabel
<InputWithLabel label="Type" {...register('type', { required: true })} /> label="Name"
{...register('name')}
error={formState.errors.name?.message}
/>
<InputWithLabel
label="Type"
{...register('type')}
error={formState.errors.type?.message}
/>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Properties</span> <span className="font-medium text-sm">Properties</span>
<Button <Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button" type="button"
variant="outline" variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
> >
<PlusIcon className="mr-1 size-3" /> <PlusIcon className="mr-1 size-3" />
Add Add
</Button> </Button>
</div> </div>
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="flex gap-2"> <div className="flex gap-2" key={field.id}>
<input <input
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="key" placeholder="key"
@@ -94,11 +118,11 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
{...register(`properties.${index}.value`)} {...register(`properties.${index}.value`)}
/> />
<Button <Button
type="button"
variant="ghost"
size="icon"
className="shrink-0" className="shrink-0"
onClick={() => remove(index)} onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
> >
<Trash2Icon className="size-4" /> <Trash2Icon className="size-4" />
</Button> </Button>
@@ -107,10 +131,13 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
</div> </div>
<ButtonContainer> <ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}> <Button onClick={() => popModal()} type="button" variant="outline">
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}> <Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Update Update
</Button> </Button>
</ButtonContainer> </ButtonContainer>

View File

@@ -14,6 +14,7 @@ import {
toNullIfDefaultMinDate, toNullIfDefaultMinDate,
updateGroup, updateGroup,
} from '@openpanel/db'; } from '@openpanel/db';
import { zCreateGroup, zUpdateGroup } from '@openpanel/validation';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { z } from 'zod'; import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc'; import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -55,29 +56,13 @@ export const groupRouter = createTRPCRouter({
}), }),
create: protectedProcedure create: protectedProcedure
.input( .input(zCreateGroup)
z.object({
id: z.string().min(1),
projectId: z.string(),
type: z.string().min(1),
name: z.string().min(1),
properties: z.record(z.string()).default({}),
})
)
.mutation(({ input }) => { .mutation(({ input }) => {
return createGroup(input); return createGroup(input);
}), }),
update: protectedProcedure update: protectedProcedure
.input( .input(zUpdateGroup)
z.object({
id: z.string().min(1),
projectId: z.string(),
type: z.string().min(1).optional(),
name: z.string().min(1).optional(),
properties: z.record(z.string()).optional(),
})
)
.mutation(({ input: { id, projectId, ...data } }) => { .mutation(({ input: { id, projectId, ...data } }) => {
return updateGroup(id, projectId, data); return updateGroup(id, projectId, data);
}), }),

View File

@@ -540,6 +540,32 @@ export const zCheckout = z.object({
}); });
export type ICheckout = z.infer<typeof zCheckout>; export type ICheckout = z.infer<typeof zCheckout>;
export const zGroupId = z
.string()
.min(1)
.regex(
/^[a-z0-9_-]+$/,
'ID must only contain lowercase letters, digits, hyphens, or underscores',
);
export const zCreateGroup = z.object({
id: zGroupId,
projectId: z.string(),
type: z.string().min(1),
name: z.string().min(1),
properties: z.record(z.string()).default({}),
});
export type ICreateGroup = z.infer<typeof zCreateGroup>;
export const zUpdateGroup = z.object({
id: z.string().min(1),
projectId: z.string(),
type: z.string().min(1).optional(),
name: z.string().min(1).optional(),
properties: z.record(z.string()).optional(),
});
export type IUpdateGroup = z.infer<typeof zUpdateGroup>;
export const zEditOrganization = z.object({ export const zEditOrganization = z.object({
id: z.string().min(2), id: z.string().min(2),
name: z.string().min(2), name: z.string().min(2),