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 { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
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 {
id: string;
type: string;
name: string;
properties: { key: string; value: string }[];
}
const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
properties: z.array(z.object({ key: z.string(), value: z.string() })),
});
type IForm = z.infer<typeof zForm>;
export default function AddGroup() {
const { projectId } = useAppParams();
@@ -23,6 +24,7 @@ export default function AddGroup() {
const trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: {
id: '',
type: '',
@@ -45,7 +47,7 @@ export default function AddGroup() {
popModal();
},
onError: handleError,
}),
})
);
return (
@@ -57,30 +59,46 @@ export default function AddGroup() {
const props = Object.fromEntries(
properties
.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 });
})}
>
<InputWithLabel label="ID" placeholder="acme-corp" {...register('id', { required: true })} autoFocus />
<InputWithLabel label="Name" placeholder="Acme Corp" {...register('name', { required: true })} />
<InputWithLabel label="Type" placeholder="company" {...register('type', { required: true })} />
<InputWithLabel
label="ID"
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 items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<span className="font-medium text-sm">Properties</span>
<Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<div className="flex gap-2" key={field.id}>
<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"
placeholder="key"
@@ -92,11 +110,11 @@ export default function AddGroup() {
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
@@ -105,10 +123,13 @@ export default function AddGroup() {
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
<Button onClick={() => popModal()} type="button" variant="outline">
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
<Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Create
</Button>
</ButtonContainer>

View File

@@ -1,35 +1,51 @@
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';
import { zodResolver } from '@hookform/resolvers/zod';
import type { IServiceGroup } from '@openpanel/db';
import { zUpdateGroup } 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 { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { handleError, useTRPC } from '@/integrations/trpc/react';
interface IForm {
type: string;
name: string;
properties: { key: string; value: string }[];
}
const zForm = zUpdateGroup
.omit({ id: true, projectId: true, properties: true })
.extend({
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 trpc = useTRPC();
const { register, handleSubmit, control, formState } = useForm<IForm>({
resolver: zodResolver(zForm),
defaultValues: {
type,
name,
properties: Object.entries(properties as Record<string, string>).map(([key, value]) => ({
key,
value: String(value),
})),
properties: Object.entries(properties as Record<string, string>).map(
([key, value]) => ({
key,
value: String(value),
})
),
},
});
@@ -48,7 +64,7 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
popModal();
},
onError: handleError,
}),
})
);
return (
@@ -60,29 +76,37 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
const props = Object.fromEntries(
formProps
.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 });
})}
>
<InputWithLabel label="Name" {...register('name', { required: true })} />
<InputWithLabel label="Type" {...register('type', { required: true })} />
<InputWithLabel
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 items-center justify-between">
<span className="text-sm font-medium">Properties</span>
<span className="font-medium text-sm">Properties</span>
<Button
onClick={() => append({ key: '', value: '' })}
size="sm"
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: '', value: '' })}
>
<PlusIcon className="mr-1 size-3" />
Add
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<div className="flex gap-2" key={field.id}>
<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"
placeholder="key"
@@ -94,11 +118,11 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
{...register(`properties.${index}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => remove(index)}
size="icon"
type="button"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
@@ -107,10 +131,13 @@ export default function EditGroup({ id, projectId, name, type, properties }: Edi
</div>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
<Button onClick={() => popModal()} type="button" variant="outline">
Cancel
</Button>
<Button type="submit" disabled={!formState.isDirty || mutation.isPending}>
<Button
disabled={!formState.isDirty || mutation.isPending}
type="submit"
>
Update
</Button>
</ButtonContainer>

View File

@@ -14,6 +14,7 @@ import {
toNullIfDefaultMinDate,
updateGroup,
} from '@openpanel/db';
import { zCreateGroup, zUpdateGroup } from '@openpanel/validation';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -55,29 +56,13 @@ export const groupRouter = createTRPCRouter({
}),
create: protectedProcedure
.input(
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({}),
})
)
.input(zCreateGroup)
.mutation(({ input }) => {
return createGroup(input);
}),
update: protectedProcedure
.input(
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(),
})
)
.input(zUpdateGroup)
.mutation(({ input: { 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 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({
id: z.string().min(2),
name: z.string().min(2),