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} :
+
+ )}
+
+ (
+
+ )}
+ />
+
+ {errorMessage && (
+
+
+ {errorMessage}
+
+
+ )}
+
+ );
+};
diff --git a/web/src/components/form/form-wrapper.tsx b/web/src/components/form/form-wrapper.tsx
new file mode 100644
index 00000000..d3e92c95
--- /dev/null
+++ b/web/src/components/form/form-wrapper.tsx
@@ -0,0 +1,28 @@
+import * as Form from '@radix-ui/react-form';
+import { FormProvider, UseFormReturn } from 'react-hook-form';
+import { z } from 'zod';
+import { FormConfig } from '../../types/form.types';
+
+interface FormWrapperProps
+ extends Omit, 'mode'> {
+ children: React.ReactNode;
+ onSubmit: (data: z.infer) => void;
+ methods: UseFormReturn>;
+}
+
+export const FormWrapper = ({
+ children,
+ onSubmit,
+ methods,
+}: FormWrapperProps) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/web/src/lib/helper.ts b/web/src/lib/helper.ts
new file mode 100644
index 00000000..fe1e45f7
--- /dev/null
+++ b/web/src/lib/helper.ts
@@ -0,0 +1,3 @@
+export const getNestedValue = (obj: any, path: string) => {
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj);
+};
diff --git a/web/src/pages/helm-template/helm-template.tsx b/web/src/pages/helm-template/helm-template.tsx
index 4c0dd291..f008ef3d 100644
--- a/web/src/pages/helm-template/helm-template.tsx
+++ b/web/src/pages/helm-template/helm-template.tsx
@@ -1,4 +1,4 @@
-import { FC, FormEvent, useState } from 'react';
+import { FC, useState } from 'react';
import { ChevronDown, Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { usePost } from '@/core/react-query';
@@ -6,15 +6,21 @@ import { API } from '@/enums/api.enums';
import {
HelmTemplateBody,
HelmTemplateResponse,
+ HelmTemplateSchema,
+ helmTemplateSchema,
helmTemplateValidationError,
- Pod,
} from './helm-template.types';
import { toast } from 'sonner';
import { isAxiosError } from 'axios';
-import Select from 'react-select';
import { accessModesOptions, sizeOptions } from './data/select-options';
-import { selectStyle } from './styles/helm-template.style';
-import { useDownload, useStyle } from '@/hooks';
+import { useDownload } from '@/hooks';
+import { useFieldArray, useForm, useFormContext } from 'react-hook-form';
+
+import { FormWrapper } from '@/components/form/form-wrapper';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { FormInput } from '@/components/form/form-input';
+import { FormCheckbox } from '@/components/form/form-checkbox';
+import { FormSelect } from '@/components/form/form-select';
const HelmTemplate: FC = () => {
const { mutateAsync: helmTemplateMutate, isPending: helmTemplatePending } =
@@ -22,147 +28,77 @@ const HelmTemplate: FC = () => {
API.HelmTemplate,
'helm-template',
);
+
const { download, isPending: downloadPending } = useDownload({
downloadFileName: 'helm-template',
source: 'helm',
folderName: 'MyHelm',
});
- const [openPod, setOpenPod] = useState(0);
- const [apiVersion, setApiVersion] = useState('');
- const [pods, setPods] = useState([
- {
- name: '',
- image: '',
- target_port: null,
- replicas: null,
- persistance: {
- accessModes: '',
- size: '',
- },
- environment: [
- {
- name: '',
- value: '',
- },
- ],
- stateless: false,
- ingress: {
- enabled: false,
- host: '',
+ const defaultValues = {
+ api_version: '',
+ pods: [
+ {
+ name: '',
+ image: '',
+ target_port: null,
+ replicas: "",
+ persistance: {},
+ environment: [
+ {
+ name: '',
+ value: '',
+ },
+ ],
+ stateless: false,
+ ingress: {},
},
- },
- ]);
- const { darkMode } = useStyle();
-
- const handleAddEnvironment = (podIndex: number) => {
- const newPods = [...pods];
- newPods[podIndex].environment.push({
- name: '',
- value: '',
- });
- setPods(newPods);
+ ],
};
- const handleRemoveEnvironment = (podIndex: number, envIndex: number) => {
- setPods((prev) => {
- const newPods = [...prev];
- newPods[podIndex].environment.splice(envIndex, 1);
- return newPods;
- });
- };
-
- const handleEnvironmentChange = (
- podIndex: number,
- envIndex: number,
- field: 'name' | 'value',
- value: string,
- ) => {
- setPods((prev) => {
- const newPods = [...prev];
- newPods[podIndex].environment[envIndex][field] = value;
- return newPods;
- });
- };
-
- const updatePod = (index: number, path: string[], value: any) => {
- setPods((prevPods) => {
- const updatedPods = [...prevPods];
- let current: any = updatedPods[index];
-
- for (let i = 0; i < path.length - 1; i++) {
- current = current[path[i]];
- }
+ const methods = useForm({
+ resolver: zodResolver(helmTemplateSchema),
+ defaultValues,
+ });
- current[path[path.length - 1]] = value;
+ const { control } = methods;
- return updatedPods;
- });
- };
-
- const handleUpdatePersistence = (
- currentValue: string,
- newPart: string | number,
- type: 'number' | 'unit' | 'accessModes',
- item: 'size' | 'accessModes',
- podIndex: number,
- ) => {
- let newValue;
- if (type === 'number') {
- const currentUnit = currentValue.match(/\D+$/)?.[0] || '';
- newValue = `${newPart}${currentUnit}`;
- } else if (type === 'unit') {
- const currentNumber = currentValue.replace(/\D+$/, '');
- newValue = `${currentNumber}${newPart}`;
- } else if (type === 'accessModes') {
- newValue = newPart;
- }
+ const {
+ fields: pods,
+ append,
+ remove,
+ } = useFieldArray({
+ control,
+ name: 'pods',
+ });
- updatePod(podIndex, ['persistance', item], newValue);
- };
+ const [openPod, setOpenPod] = useState(0);
const handleAddPod = () => {
- setPods((prev) => {
- return [
- ...prev,
- {
- name: '',
- image: '',
- target_port: null,
- replicas: null,
- persistance: {
- accessModes: '',
- size: '',
- },
- environment: [
- {
- name: '',
- value: '',
- },
- ],
- stateless: false,
- ingress: {
- enabled: false,
- host: '',
- },
- },
- ];
- });
+ append(defaultValues.pods[0]);
};
const handleRemovePod = (index: number) => {
- setPods((prev) => {
- return prev.filter((_, i) => i !== index);
- });
+ remove(index);
};
- const handleForm = async (e: FormEvent) => {
- e.preventDefault();
-
+ const handleSubmit = async (data: HelmTemplateSchema) => {
try {
+ const body_data = data.pods.map((data) => {
+ const { mode, accessModes, size } = data.persistance;
+
+ return {
+ ...data,
+ persistance: {
+ size: `${size}${mode.value}`,
+ accessModes: accessModes.value,
+ },
+ };
+ });
+
const body: HelmTemplateBody = {
- api_version: parseInt(apiVersion),
- pods,
+ api_version: parseInt(data.api_version),
+ pods: body_data,
};
await helmTemplateMutate(body);
@@ -180,301 +116,240 @@ const HelmTemplate: FC = () => {
};
return (
-