diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..5cf8041b --- /dev/null +++ b/web/.env.example @@ -0,0 +1 @@ +VITE_API_CLIENT_BASE_URL="" \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 9e1dacca..041bf623 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,9 @@ "name": "devops-gpt", "version": "2.0.0", "dependencies": { + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", @@ -19,12 +21,14 @@ "lucide-react": "^0.462.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", "react-router": "^7.0.1", "react-select": "^5.8.3", "react-spinners": "^0.14.1", "sonner": "^1.7.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", "zustand": "^5.0.1" }, "devDependencies": { @@ -1066,6 +1070,15 @@ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1417,6 +1430,49 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.0.tgz", + "integrity": "sha512-1/oVYPDjbFILOLIarcGcMKo+y6SbTVT/iUKVEw59CF4offwZgBgC3ZOeSBewjqU0vdA6FWTPWTN63obj55S/tQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-label": "2.1.0", + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1435,6 +1491,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -4694,6 +4773,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.53.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.2.tgz", + "integrity": "sha512-YVel6fW5sOeedd1524pltpHX+jgU2u3DSDtXEaBORNdqiNrsX/nUI/iGXONegttg0mJVnfrIkiV0cmTU6Oo2xw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5755,6 +5850,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.1.tgz", diff --git a/web/package.json b/web/package.json index 9c592e3e..9a044ba5 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", @@ -21,12 +23,14 @@ "lucide-react": "^0.462.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", "react-router": "^7.0.1", "react-select": "^5.8.3", "react-spinners": "^0.14.1", "sonner": "^1.7.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/web/src/components/form/form-checkbox.tsx b/web/src/components/form/form-checkbox.tsx new file mode 100644 index 00000000..789287d1 --- /dev/null +++ b/web/src/components/form/form-checkbox.tsx @@ -0,0 +1,55 @@ + +import * as Form from '@radix-ui/react-form'; +import { useFormContext } from 'react-hook-form'; +import { FormFieldProps } from '../../types/form.types'; +import { cn } from '@/lib/utils'; + +interface FormCheckboxProps extends FormFieldProps { + labelPosition?: 'left' | 'right'; + checkboxClassName?: string; +} + +export const FormCheckbox = ({ + name, + label, + error, + labelPosition = 'right', + checkboxClassName, + ...props +}: FormCheckboxProps) => { + const { + register, + formState: { errors }, + } = useFormContext(); + + const fieldError = errors[name]; + const errorMessage = fieldError?.message as string; + + return ( + +
+ + + + {label} +
+ {errorMessage && ( + + {errorMessage} + + )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/form/form-input.tsx b/web/src/components/form/form-input.tsx new file mode 100644 index 00000000..50eb4c90 --- /dev/null +++ b/web/src/components/form/form-input.tsx @@ -0,0 +1,44 @@ +import * as Form from '@radix-ui/react-form'; +import { useFormContext } from 'react-hook-form'; +import { FormFieldProps } from '../../types/form.types'; +import { getNestedValue } from '@/lib/helper'; +import { cn } from '@/lib/utils'; + +export const FormInput = ({ name, label, error, ...props }: FormFieldProps) => { + const { + register, + formState: { errors }, + } = useFormContext(); + + const fieldError = getNestedValue(errors, name); + const errorMessage = fieldError?.message as string; + + return ( + + {label && ( +
+ {label} : +
+ )} + + + + {errorMessage && ( +
+ + {errorMessage} + +
+ )} +
+ ); +}; diff --git a/web/src/components/form/form-select.tsx b/web/src/components/form/form-select.tsx new file mode 100644 index 00000000..25b0b513 --- /dev/null +++ b/web/src/components/form/form-select.tsx @@ -0,0 +1,78 @@ +// components/form/FormSelect.tsx +import * as Form from '@radix-ui/react-form'; +import { Controller, useFormContext } from 'react-hook-form'; +import { FormFieldProps } from '../../types/form.types'; +import Select from 'react-select'; +import { getNestedValue } from '@/lib/helper'; +import { selectStyle } from '@/pages/helm-template/styles/helm-template.style'; +import { useStyle } from '@/hooks'; +import { cn } from '@/lib/utils'; + +interface OptionType { + value: string; + label: string; +} + +interface FormSelectProps extends FormFieldProps { + options: OptionType[]; + placeholder?: string; + isSearchable?: boolean; +} + +export const FormSelect = ({ + name, + label, + error, + options, + placeholder = 'Select...', + isSearchable = true, + ...props +}: FormSelectProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + const { darkMode } = useStyle(); + + const fieldError = getNestedValue(errors, name); + const errorMessage = fieldError?.message as string; + + return ( + + {label && ( +
+ {label} : +
+ )} + + ( + setApiVersion(e.target.value)} - className="dark:bg-black-1 order w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> - -
-

Pods

- -
-
- {pods.map((pod, index) => ( -
+
+
+ +
+ +
+
+

Pods

+ +
+
+ {pods.map((pod, index) => (
-

Pod #{index + 1}

-
- {index > 0 && ( - +
setOpenPod(openPod === index ? -1 : index)} - /> -
-
-
-
- - updatePod(index, ['name'], e.target.value)} - className="dark:bg-black-1 w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> -
-
- - - updatePod(index, ['image'], e.target.value) - } - className="dark:bg-black-1 w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> -
-
- - - updatePod(index, ['target_port'], e.target.value) - } - className="dark:bg-black-1 w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> -
-
- - - updatePod(index, ['replicas'], e.target.value) - } - className="dark:bg-black-1 w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> + > +

Pod #{index + 1}

+
+ {index > 0 && ( + + )} + setOpenPod(openPod === index ? -1 : index)} + /> +
-

Persistence

-
-

Size

-
- - handleUpdatePersistence( - pod.persistance.size, - e.target.value, - 'number', - 'size', - index, - ) - } - className="dark:bg-black-1 w-full gap-2 rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" +
+
+ - - handleUpdatePersistence( - pod.persistance.accessModes, - e?.value as string, - 'accessModes', - 'accessModes', - index, - ) - } - styles={selectStyle(darkMode)} - /> -
-
-

Environments

- -
-
- {pod.environment.map((env, envIdx) => ( -
- - handleEnvironmentChange( - index, - envIdx, - 'name', - e.target.value, - ) - } - className="h-12 w-full rounded-s-md px-2 outline-none" +
+ +
+
+ +
+

Persistence

+
+

Size

+
+ - - handleEnvironmentChange( - index, - envIdx, - 'value', - e.target.value, - ) - } - className={cn( - 'dark:bg-black-1 h-12 w-full px-2 outline-none', - { - 'rounded-e-md': index === 0, - }, - )} + + - {envIdx > 0 && ( - - )}
- ))} -
-
- - - updatePod(index, ['stateless'], !pod.stateless) - } - /> -
-

Ingress

-
- - - updatePod( - index, - ['ingress', 'enabled'], - !pod.ingress.enabled, - ) - } - /> -
-
- - - updatePod(index, ['ingress', 'host'], e.target.value) - } - className="w-full rounded-md border border-gray-200 px-3 py-2 outline-none dark:border-none" - /> +
+
+ + +
+ + +
+ + +
+

Ingress

+
+ + +
+
+ +
-
- ))} -
+ ))} +
+ + +
+
+ ); +}; + +export default HelmTemplate; + +interface PodEnvironmentFieldsProps { + podIndex: number; +} + +export const PodEnvironmentFields: React.FC = ({ + podIndex, +}) => { + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: `pods.${podIndex}.environment`, + }); + + return ( +
+
+

Environments

+ - +
+
+ {fields.map((field, envIdx) => ( +
+ + + {envIdx > 0 && ( + + )} +
+ ))} +
); }; - -export default HelmTemplate; diff --git a/web/src/pages/helm-template/helm-template.types.ts b/web/src/pages/helm-template/helm-template.types.ts index a475c8cb..e565cc07 100644 --- a/web/src/pages/helm-template/helm-template.types.ts +++ b/web/src/pages/helm-template/helm-template.types.ts @@ -1,3 +1,5 @@ +import { z as zod } from 'zod'; + export interface HelmTemplateBody { api_version: number; pods: Pod[]; @@ -21,8 +23,8 @@ export interface helmTemplateValidationError { export interface Pod { name: string; image: string; - target_port: number | null; - replicas: number | null; + target_port: string | null; + replicas: string | null; persistance: { size: string; accessModes: string; @@ -38,3 +40,43 @@ export interface Pod { host: string; }; } + +const environmentSchema = zod.object({ + name: zod.string().min(1, 'Name is required'), + value: zod.string().min(1, 'Value is required'), +}); + +const labelValueSchema = zod.object({ + label: zod.string(), + value: zod.string(), +}); + +const persistanceSchema = zod.object({ + mode: labelValueSchema, + size: zod.string().min(1, 'Size is required'), + accessModes: labelValueSchema, +}); + +const ingressSchema = zod.object({ + enabled: zod.boolean(), + host: zod.string().min(1, 'Host is required'), +}); + +const podSchema = zod.object({ + name: zod.string().min(1, 'Name is required'), + image: zod.string().min(1, 'Image is required'), + target_port: zod.string().nullable(), + replicas: zod.string().min(1 ,"Replicas is required"), + persistance: persistanceSchema, + environment: zod + .array(environmentSchema) + .min(1, 'At least one environment variable is required'), + stateless: zod.boolean(), + ingress: ingressSchema, +}); + +export const helmTemplateSchema = zod.object({ + api_version: zod.string().min(1, 'API version is required'), + pods: zod.array(podSchema).min(1, 'At least one pod is required'), +}); +export type HelmTemplateSchema = zod.infer; diff --git a/web/src/types/form.types.ts b/web/src/types/form.types.ts new file mode 100644 index 00000000..26c29590 --- /dev/null +++ b/web/src/types/form.types.ts @@ -0,0 +1,17 @@ +// types/form.types.ts +import { z } from 'zod'; +import { ComponentPropsWithoutRef } from 'react'; +import * as Form from '@radix-ui/react-form'; + +export type FormFieldProps = { + name: string; + label: string; + error?: string; + placeholder?: string; +} & ComponentPropsWithoutRef; + +export type FormConfig = { + defaultValues?: z.infer; + schema?: T; + mode?: 'onSubmit' | 'onChange' | 'onBlur'; +};