From c8a9234c9302b5bc730f5c9e2bc20d254811143c Mon Sep 17 00:00:00 2001
From: "C.Nelhomme" <64921289+D3ller@users.noreply.github.com>
Date: Mon, 5 Oct 2026 17:13:14 +0200
Subject: [PATCH 1/4] feat(ui): add generic TabsBar and KVCard components (#11)
Add reusable and domain-agnostic UI components under `@components/ui`:
- **`TabsBar`**: Generic tabs bar component for tab navigation and
filtering.
- **`KVCard`**: Key-value card display component with Iconify icon
support.
---
frontend/src/components/ui/Card/KVCard.tsx | 17 ++++++
frontend/src/components/ui/TabsBar.tsx | 68 ++++++++++++++++++++++
2 files changed, 85 insertions(+)
diff --git a/frontend/src/components/ui/Card/KVCard.tsx b/frontend/src/components/ui/Card/KVCard.tsx
index e69de29..e1bffcc 100644
--- a/frontend/src/components/ui/Card/KVCard.tsx
+++ b/frontend/src/components/ui/Card/KVCard.tsx
@@ -0,0 +1,17 @@
+import { Icon } from "@iconify/react";
+
+interface KVCardProps {
+ icon?: string;
+ myKey: string;
+ value: string;
+}
+
+export const KVCard = ({icon, myKey, value}: KVCardProps) => {
+ return (
+
+ {icon &&
}
+ {myKey}
+ {value}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/ui/TabsBar.tsx b/frontend/src/components/ui/TabsBar.tsx
index e69de29..d5503a4 100644
--- a/frontend/src/components/ui/TabsBar.tsx
+++ b/frontend/src/components/ui/TabsBar.tsx
@@ -0,0 +1,68 @@
+import { Link, type LinkProps } from "react-router";
+import { type ReactNode } from "react";
+
+type Tab = {
+ label: string;
+ value: T;
+ to?: LinkProps["to"];
+}
+
+interface TabsBarProps {
+ variant?: "underline" | "segmented";
+ items: Tab[];
+ current: T;
+ onChange?: (value: T) => void;
+ children?: ReactNode;
+}
+
+
+export const TabsBar = ({
+ items,
+ onChange,
+ current,
+ children,
+ variant = "segmented"
+ }: TabsBarProps) => {
+
+ let tabsClass = "";
+ let tabItemClass = "";
+ let tabItemActiveClass = "";
+
+ switch (variant) {
+ case 'segmented':
+ tabsClass = "bg-[#e5e5e5] rounded-md";
+ tabItemClass = "rounded-lg px-4 py-2 md:py-3 md:px-6"
+ tabItemActiveClass = "bg-white";
+ break;
+ case 'underline':
+ tabsClass = "pt-3 bg-white";
+ tabItemClass = "pb-3 mr-3.5 text-sm text-gray-500"
+ tabItemActiveClass = "border-b-2 border-black text-black!"
+ break;
+ }
+
+ return (
+
+
+
+ {children}
+
+ )
+}
\ No newline at end of file
From 0b87d4e88ba04f37559c71f34904f1835194eba5 Mon Sep 17 00:00:00 2001
From: d3ller
Date: Mon, 5 Oct 2026 17:35:09 +0200
Subject: [PATCH 2/4] ci: guard main source branch
---
.github/workflows/main-source-branch.yml | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 .github/workflows/main-source-branch.yml
diff --git a/.github/workflows/main-source-branch.yml b/.github/workflows/main-source-branch.yml
new file mode 100644
index 0000000..bfca9c7
--- /dev/null
+++ b/.github/workflows/main-source-branch.yml
@@ -0,0 +1,23 @@
+name: Main source branch guard
+
+on:
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ validate-source-branch:
+ name: Source branch must be develop
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check pull request source branch
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ run: |
+ if [ "$HEAD_REF" != "develop" ]; then
+ echo "Pull requests to main must come from develop."
+ exit 1
+ fi
From df9dfa16f244c854f943cf76a9885d0c90dc6117 Mon Sep 17 00:00:00 2001
From: "C.Nelhomme" <64921289+D3ller@users.noreply.github.com>
Date: Wed, 7 Oct 2026 10:57:41 +0200
Subject: [PATCH 3/4] feat/form hook (#13)
- **refactor(form-hook): update error handling and types**
- **feat(form): add uncontrolled form hook with validation and
submission handling**
---
frontend/src/utils/hooks/useControlledForm.ts | 297 ++++++++++--------
.../src/utils/hooks/useUncontrolledForm.ts | 65 ++++
2 files changed, 236 insertions(+), 126 deletions(-)
create mode 100644 frontend/src/utils/hooks/useUncontrolledForm.ts
diff --git a/frontend/src/utils/hooks/useControlledForm.ts b/frontend/src/utils/hooks/useControlledForm.ts
index 088505b..3228c6f 100644
--- a/frontend/src/utils/hooks/useControlledForm.ts
+++ b/frontend/src/utils/hooks/useControlledForm.ts
@@ -1,141 +1,186 @@
-import { z, type ZodObject } from "zod";
-import { useReducer } from "react";
-
-type UseFormOptions = {
- initialValues: z.infer;
- schema: TSchema;
- onSubmit: (values: z.infer) => Promise;
- onSuccess?: (data: unknown) => void;
- onError?: (error: unknown) => void;
-};
-
-type UseFormReducerAction =
- | {
- type: "SET_FIELD";
- key: keyof TSchema;
- value: TSchema[keyof TSchema];
-}
- | { type: "FORM_SUBMIT" }
- | { type: "FORM_ERROR"; errors: Record }
- | { type: "FORM_SUCCESS" }
- | { type: "CLEAR_ERROR" }
-
-type UseFormState = {
- values: TSchema,
- errors: Record,
- isSubmitting: boolean,
-}
-export const useForm = ({
- initialValues,
- schema,
- onSubmit,
- onSuccess,
- onError,
- }: UseFormOptions) => {
-
- type Values = z.infer;
-
- function formReducer(
- state: UseFormState,
- action: UseFormReducerAction
- ): UseFormState {
- switch (action.type) {
- case "SET_FIELD":
- return {
- ...state,
- values: {
- ...state.values,
- [action.key]: action.value,
- },
- };
- case "FORM_SUBMIT":
- return {
- ...state,
- isSubmitting: true,
- };
+ import { z, type ZodObject } from "zod";
+ import { useReducer } from "react";
+
+ type UseFormOptions = {
+ initialValues: z.infer;
+ schema: TSchema;
+ onSubmit: (values: z.infer) => Promise;
+ onSuccess?: (data: unknown) => void;
+ onError?: (error: unknown) => void;
+ };
- case "FORM_SUCCESS":
- return {
- ...state,
- isSubmitting: false,
- };
+ type FormErrorKey = keyof T | "global";
- case "FORM_ERROR":
- return {
- ...state,
- isSubmitting: false,
- errors: action.errors,
- };
+ type FormErrors = {
+ [K in keyof T]?: string;
+ } & {
+ global?: string;
+ };
- case "CLEAR_ERROR":
- return {
- ...state,
- errors: {},
- };
- }
+ type UseFormReducerAction =
+ |
+ { type: "SET_FIELD"; key: keyof TSchema; value: TSchema[keyof TSchema]; }
+ | { type: "FORM_SUBMIT" }
+ | { type: "FORM_ERROR"; errors: FormErrors }
+ | { type: "FORM_SUCCESS" }
+ | { type: "CLEAR_ERRORS" }
+ | { type: "FORM_RESET" }
+ | { type: "CLEAR_ERROR", key: FormErrorKey };
+
+
+ type UseFormState = {
+ values: TSchema,
+ errors: FormErrors,
+ isSubmitting: boolean,
}
-
- const [state, dispatch] = useReducer(formReducer, {
- values: initialValues,
- errors: {},
- isSubmitting: false,
- });
-
- const submit = async () => {
- const result = schema.safeParse(state.values);
-
- if (!result.success) {
- const errors: Record = {};
- result.error.issues.forEach((issue) => {
- const field = issue.path[0];
- if (typeof field === "string") {
- errors[field] = issue.message;
- }
- })
- dispatch({type: "FORM_ERROR", errors});
- return;
+ export const useControlledForm = ({
+ initialValues,
+ schema,
+ onSubmit,
+ onSuccess,
+ onError,
+ }: UseFormOptions) => {
+
+ type Values = z.infer;
+
+ function formReducer(
+ state: UseFormState,
+ action: UseFormReducerAction
+ ): UseFormState {
+ switch (action.type) {
+ case "SET_FIELD":
+ return {
+ ...state,
+ values: {
+ ...state.values,
+ [action.key]: action.value,
+ },
+ };
+ case "FORM_SUBMIT":
+ return {
+ ...state,
+ isSubmitting: true,
+ };
+ case "FORM_SUCCESS":
+ return {
+ ...state,
+ isSubmitting: false,
+ };
+ case "FORM_ERROR":
+ return {
+ ...state,
+ isSubmitting: false,
+ errors: action.errors,
+ };
+ case "CLEAR_ERROR":
+ const errors = {...state.errors};
+ delete errors[action.key as string];
+
+ return {
+ ...state,
+ errors,
+ };
+ case "CLEAR_ERRORS":
+ return {
+ ...state,
+ errors: {}
+ }
+ case "FORM_RESET":
+ return {
+ ...state,
+ values: initialValues,
+ errors: {},
+ isSubmitting: false,
+ };
+ }
}
- dispatch({type: "FORM_SUBMIT"});
- try {
- const data = await onSubmit(state.values);
+ const [state, dispatch] = useReducer(formReducer, {
+ values: initialValues,
+ errors: {},
+ isSubmitting: false,
+ });
+
+ const submit = async () => {
+ const result = schema.safeParse(state.values);
+
+ if (!result.success) {
+ const errors: Record = {global: ""};
+
+ result.error.issues.forEach((issue) => {
+ const field = issue.path[0];
+
+ if (typeof field === "string") {
+ errors[field] = issue.message;
+ }
+ });
+
+ dispatch({
+ type: "FORM_ERROR",
+ errors: errors as FormErrors,
+ });
+
+ return;
+ }
+
+ dispatch({type: "FORM_SUBMIT"});
+ try {
+ const data = await onSubmit(result.data);
+
+ dispatch({type: "FORM_SUCCESS"});
+ onSuccess?.(data);
+ } catch (error) {
- dispatch({type: "FORM_SUCCESS"});
- onSuccess?.(data);
- } catch (error) {
+ const errors: FormErrors = {
+ global: "Une erreur est survenue",
+ };
+
+ dispatch({
+ type: "FORM_ERROR",
+ errors
+ });
+
+ onError?.(error);
+ }
+ };
+
+ const setValue = (
+ key: K,
+ value: Values[K]
+ ) => {
dispatch({
- type: "FORM_ERROR",
- errors: {
- form: "Une erreur est survenue",
- },
+ type: "SET_FIELD",
+ key,
+ value,
});
+ };
- onError?.(error);
+ const clearErrors = () => {
+ dispatch({
+ type: "CLEAR_ERRORS"
+ });
}
- };
- const setValue = (
- key: K,
- value: Values[K]
- ) => {
- dispatch({
- type: "SET_FIELD",
- key,
- value,
- });
- };
+ const clearError = (key: FormErrorKey) => {
+ dispatch({
+ type: "CLEAR_ERROR",
+ key,
+ })
+ }
- const clearError = () => {
- dispatch({
- type: "CLEAR_ERROR"
- });
- }
+ const reset = () => {
+ dispatch({
+ type: "FORM_RESET"
+ })
+ }
- return {
- ...state,
- setValue,
- clearError,
- submit
- }
+ return {
+ ...state,
+ reset,
+ setValue,
+ clearError,
+ clearErrors,
+ submit
+ }
-}
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/frontend/src/utils/hooks/useUncontrolledForm.ts b/frontend/src/utils/hooks/useUncontrolledForm.ts
new file mode 100644
index 0000000..71892af
--- /dev/null
+++ b/frontend/src/utils/hooks/useUncontrolledForm.ts
@@ -0,0 +1,65 @@
+import { type SubmitEvent, useState } from "react";
+import { z, type ZodObject } from "zod";
+
+interface uncontrolledFormArgs {
+ schema: T,
+ onSubmit: (values: z.infer) => Promise | void;
+ onError: (
+ error: unknown,
+ actions: {
+ setFieldError: (field: string, message: string) => void
+ }
+ ) => void;
+}
+
+export const useUncontrolledForm = ({schema, onError, onSubmit}: uncontrolledFormArgs) => {
+
+ const [errors, setError] = useState>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ function setFieldError(field: string, message: string) {
+ setError(prev => ({
+ ...prev,
+ [field]: message
+ }));
+ }
+
+ async function validateForm(formData: FormData) {
+ const result = schema.safeParse(Object.fromEntries(formData));
+
+ if (!result.success) {
+ const errors: Record = {};
+ result.error.issues.forEach((issue) => {
+ const field = issue.path[0];
+ if (typeof field === "string") {
+ errors[field] = issue.message;
+ }
+ })
+ setError(errors);
+ return;
+ }
+
+ setError({});
+
+ try {
+ setIsSubmitting(true)
+ await onSubmit(result.data);
+ } catch (e) {
+ onError(e, { setFieldError });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function onFormSubmit(event: SubmitEvent) {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+ await validateForm(formData);
+ }
+
+ return {
+ errors,
+ isSubmitting,
+ onFormSubmit
+ }
+}
\ No newline at end of file
From 0b3fd2c1b47b836cfde5b69be73f92507a4cd805 Mon Sep 17 00:00:00 2001
From: "C.Nelhomme" <64921289+D3ller@users.noreply.github.com>
Date: Wed, 7 Oct 2026 12:08:59 +0200
Subject: [PATCH 4/4] feat/connexion form (#14)
- **unify the style of the register and login, and add a schema to
register**
- **refactor(auth): migrate login form to use uncontrolled form hook**
- **feat(register): setup the form using uncontrolled hook**
- **chore(type): change type from setFieldError**
- **feat(login): add error catching handler**
---
frontend/src/features/auth/pages/Login.tsx | 68 ++++++---------
frontend/src/features/auth/pages/Register.tsx | 86 +++++++++++++++++++
.../src/utils/hooks/useUncontrolledForm.ts | 2 +-
frontend/src/utils/schema.ts | 6 ++
4 files changed, 121 insertions(+), 41 deletions(-)
diff --git a/frontend/src/features/auth/pages/Login.tsx b/frontend/src/features/auth/pages/Login.tsx
index 77e1bc9..c934a90 100644
--- a/frontend/src/features/auth/pages/Login.tsx
+++ b/frontend/src/features/auth/pages/Login.tsx
@@ -1,62 +1,46 @@
import HwLogo from "@components/Icon.tsx";
import HButton from "@ui/HButton.tsx";
import HInput from "@ui/HInput.tsx";
+
import FormField from "@ui/Form/FormField.tsx";
import FormLabel from "@ui/Form/FormLabel.tsx";
-import { type SubmitEvent, useState } from "react";
import FormError from "@ui/Form/FormError.tsx";
-import { loginSchema } from "@utils/schema.ts";
-import { useAuth } from "@features/auth/provider/AuthProvider.tsx";
-const Login = () => {
+import { useAuth } from "@features/auth/provider/AuthProvider.tsx";
+import { loginSchema } from "@utils/schema.ts";
- const { login } = useAuth();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [errors, setErrorState] = useState>({})
+import { Link } from "react-router";
- async function onFormLoginSubmit(event: SubmitEvent) {
- event.preventDefault();
- const formData = new FormData(event.currentTarget)
- const result = loginSchema.safeParse(Object.fromEntries(formData))
+import { useUncontrolledForm } from "@utils/hooks/useUncontrolledForm.ts";
+import { FetchError } from "ofetch";
- if (!result.success) {
- const errors: Record = {};
- result.error.issues.map((issue) => {
- const field = issue.path[0];
- if (typeof field === "string") {
- errors[field] = issue.message;
- }
- })
- setErrorState(errors);
- return;
- }
+const Login = () => {
- setErrorState({});
+ const { login } = useAuth();
- try {
- setIsSubmitting(true)
- await login(result.data);
- } catch (e) {
- console.log(e)
- } finally {
- setIsSubmitting(false);
+ const {errors, onFormSubmit, isSubmitting} = useUncontrolledForm({
+ schema: loginSchema,
+ onSubmit: async (values) => {
+ await login(values);
+ },
+ onError: (error, {setFieldError}) => {
+ if (error instanceof FetchError) {
+ if(!error.status) setFieldError("email", "Une erreur est survenue veuillez réessayée");
+ }
}
- }
+ })
return (