diff --git a/backend/src/entities/GroupMember.ts b/backend/src/entities/GroupMember.ts
index b37a0c1..a45830b 100644
--- a/backend/src/entities/GroupMember.ts
+++ b/backend/src/entities/GroupMember.ts
@@ -43,9 +43,13 @@ export class GroupMember extends BaseEntity {
@Field()
lastTempstampVu: Date;
+ @Field(() => User)
@ManyToOne(
() => User,
(user) => user.groupMember,
+ {
+ onDelete: "CASCADE",
+ },
)
@JoinColumn({ name: "userId" })
user: User;
@@ -53,6 +57,9 @@ export class GroupMember extends BaseEntity {
@ManyToOne(
() => Group,
(group) => group.groupMember,
+ {
+ onDelete: "CASCADE",
+ },
)
@JoinColumn({ name: "groupId" })
group: Group;
diff --git a/backend/src/entities/Message.ts b/backend/src/entities/Message.ts
index 9f4ac6d..fa398ee 100644
--- a/backend/src/entities/Message.ts
+++ b/backend/src/entities/Message.ts
@@ -51,6 +51,9 @@ export class Message extends BaseEntity {
@ManyToOne(
() => Group,
(group) => group.messages,
+ {
+ onDelete: "CASCADE",
+ },
)
@Field(() => Group)
group: Group;
diff --git a/backend/src/resolvers/GroupResolver.ts b/backend/src/resolvers/GroupResolver.ts
index 7851212..8e5c938 100644
--- a/backend/src/resolvers/GroupResolver.ts
+++ b/backend/src/resolvers/GroupResolver.ts
@@ -12,12 +12,14 @@ import {
Root,
UseMiddleware,
} from "type-graphql";
+
import Group from "../entities/Group";
import { GroupMember } from "../entities/GroupMember";
import { Message } from "../entities/Message";
import User from "../entities/User";
import { getVariableEnv } from "../lib/envManager/envManager";
import { RoleMiddleware } from "../middleware/RoleMiddleware";
+import { addMembersToGroup, removeMembersFromGroup } from "../services/groupMemberService";
import type { ContextType } from "../types/context";
@InputType()
@@ -49,6 +51,36 @@ class AddFundsInput {
amount!: number;
}
+@InputType()
+class UpdateGroupInput {
+ @Field()
+ name!: string;
+
+ @Field()
+ event_type!: string;
+
+ @Field()
+ piggy_bank!: number;
+
+ @Field()
+ deadline!: Date;
+
+ @Field(() => [String], { nullable: true })
+ users?: string[];
+
+ @Field({ nullable: true })
+ user_beneficiary?: string;
+}
+
+@InputType()
+class RemoveMembersInput {
+ @Field(() => [Number], { nullable: true })
+ userIds?: number[];
+
+ @Field(() => [String], { nullable: true })
+ userEmails?: string[];
+}
+
@ObjectType()
export class MyGroupsResponse {
@Field(() => [Group])
@@ -72,7 +104,13 @@ export default class GroupResolver {
user: { id: ctx.user?.id },
},
},
- relations: { groupMember: true, user_admin: true, user_beneficiary: true },
+ relations: {
+ groupMember: {
+ user: true, // đ THIS is the key
+ },
+ user_admin: true,
+ user_beneficiary: true,
+ },
order: { id: "DESC" },
});
@@ -83,10 +121,27 @@ export default class GroupResolver {
return { groups, groupToken };
}
+ @Query(() => Group)
+ async getGroupById(@Arg("id") id: number) {
+ const group = await Group.findOne({
+ where: { id: id },
+ relations: {
+ user_admin: true,
+ user_beneficiary: true,
+ groupMember: {
+ user: true,
+ },
+ },
+ });
+ if (!group) throw new Error("Groupe non trouvé");
+ return group;
+ }
+
@FieldResolver(() => [GroupMember])
async groupMember(@Root() group: Group) {
const groupMembers = await GroupMember.find({
where: { groupId: group.id },
+ relations: { user: true },
});
return groupMembers || []; // >>> not null
@@ -195,4 +250,149 @@ export default class GroupResolver {
return group;
}
+
+ @UseMiddleware(RoleMiddleware())
+ @Mutation(() => Group)
+ async updateGroup(@Arg("id") id: number, @Arg("data") data: UpdateGroupInput) {
+ const group = await Group.findOne({ where: { id: id } });
+ if (!group) throw new Error("Groupe non trouvé");
+ group.name = data.name;
+ group.event_type = data.event_type;
+ group.piggy_bank = data.piggy_bank;
+ group.deadline = data.deadline;
+ await group.save();
+
+ if (data.users?.length) {
+ await addMembersToGroup({
+ userEmails: data.users,
+ groupId: group.id,
+ });
+ }
+
+ return group;
+ }
+
+ @UseMiddleware(RoleMiddleware())
+ @Mutation(() => String)
+ async deleteGroup(@Arg("id") id: number, @Ctx() ctx: ContextType): Promise {
+ if (!ctx.user) {
+ throw new Error("Utilisateur non connecté");
+ }
+
+ const group = await Group.findOne({
+ where: {
+ id,
+ user_admin: { id: ctx.user.id },
+ },
+ relations: { user_admin: true },
+ });
+
+ if (!group) {
+ throw new Error("Groupe introuvable ou accÚs refusé");
+ }
+
+ if (!group.user_admin || group.user_admin.id !== ctx.user.id) {
+ throw new Error("Il faut ĂȘtre administrateur du groupe pour pouvoir le supprimer");
+ }
+
+ try {
+ await Group.remove(group);
+ return "Le groupe a été supprimé";
+ } catch (err) {
+ console.error("deleteGroup error:", err);
+ throw new Error("Une erreur est survenue lors de la suppression du groupe");
+ }
+ }
+
+ @UseMiddleware(RoleMiddleware())
+ @Mutation(() => String)
+ async removeMembersFromGroup(
+ @Arg("groupId", () => Number) groupId: number,
+ @Arg("data") data: RemoveMembersInput,
+ @Ctx() ctx: ContextType,
+ ): Promise {
+ if (!ctx.user) {
+ throw new Error("Utilisateur non connecté");
+ }
+
+ // Ensure at least one input is provided
+ if ((!data.userIds || data.userIds.length === 0) && (!data.userEmails || data.userEmails.length === 0)) {
+ throw new Error("Vous devez fournir au moins un identifiant utilisateur ou un email");
+ }
+
+ const group = await Group.findOne({
+ where: {
+ id: groupId,
+ },
+ relations: { user_admin: true },
+ });
+
+ if (!group) {
+ throw new Error("Groupe introuvable ou accÚs refusé");
+ }
+
+ const currentUserId = ctx.user.id;
+ const isAdmin = ctx.user.id === group.user_admin.id;
+
+ // Convert emails to user IDs if provided
+ let userIdsToRemove: number[] = [];
+
+ if (data.userEmails && data.userEmails.length > 0) {
+ await Promise.all(
+ data.userEmails.map(async (email) => {
+ const user = await User.findOne({ where: { email } });
+ if (user) {
+ userIdsToRemove.push(user.id);
+ }
+ }),
+ );
+ }
+
+ // Add direct user IDs if provided
+ if (data.userIds && data.userIds.length > 0) {
+ userIdsToRemove.push(...data.userIds);
+ }
+
+ // Remove duplicates
+ userIdsToRemove = [...new Set(userIdsToRemove)];
+
+ if (userIdsToRemove.length === 0) {
+ throw new Error("Aucun utilisateur valide trouvé");
+ }
+
+ // Admin cannot remove itself from a group
+ if (isAdmin && userIdsToRemove.includes(group.user_admin.id)) {
+ throw new Error("L'administrateur du groupe ne peut pas ĂȘtre supprimĂ©. Supprimez plutĂŽt le groupe");
+ }
+
+ // Remove myself from a group
+ if (!isAdmin && userIdsToRemove.length === 1 && userIdsToRemove[0] === currentUserId) {
+ try {
+ await removeMembersFromGroup({
+ userIds: [currentUserId],
+ groupId,
+ });
+ return "SuccĂšs! Vous ne faites plus partie du groupe!";
+ } catch (err) {
+ console.error("removeMembersFromGroup error:", err);
+ throw new Error("Une erreur est survenue, nous n'avons pas pu vous supprimer du groupe");
+ }
+ }
+
+ // Admin removes users from a group
+ if (isAdmin && userIdsToRemove.length > 0 && !userIdsToRemove.includes(group.user_admin.id)) {
+ try {
+ await removeMembersFromGroup({
+ userIds: userIdsToRemove,
+ groupId,
+ });
+ return "SuccÚs! Les utilisateurs ont été supprimés du groupe!";
+ } catch (err) {
+ console.error("removeMembersFromGroup error:", err);
+ throw new Error("Une erreur est survenue, nous n'avons pas pu supprimer les utilisateurs du groupe");
+ }
+ }
+
+ throw new Error("Vous n'avez pas les permissions nécessaires pour effectuer cette action");
+ }
}
diff --git a/backend/src/resolvers/MessageResolver.ts b/backend/src/resolvers/MessageResolver.ts
index fc082da..f8a6fbc 100644
--- a/backend/src/resolvers/MessageResolver.ts
+++ b/backend/src/resolvers/MessageResolver.ts
@@ -131,7 +131,7 @@ export default class MessageResolver {
if (!userId) throw new Error("Utilisateur non authentifié");
- const group = await GroupMember.findOne({ where: { user: { id: userId }, group: { id: groupId } } });
+ const group = await GroupMember.findOne({ where: { userId: userId, groupId: groupId } });
// verifier que l'utilisateur fait bien partie du groupe
if (!group) throw new Error("Groupe non trouvé");
diff --git a/backend/src/services/groupMemberService.ts b/backend/src/services/groupMemberService.ts
index 22aff8b..1dc99c6 100644
--- a/backend/src/services/groupMemberService.ts
+++ b/backend/src/services/groupMemberService.ts
@@ -7,6 +7,11 @@ type AddMembersInput = {
groupId: number;
};
+type RemoveMembersInput = {
+ userIds: number[];
+ groupId: number;
+};
+
/**
* Add users to a group from a list of emails.
* - Silently skips emails that do not match a user.
@@ -17,7 +22,8 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput
await Promise.all(
userEmails.map(async (userEmail) => {
const userToAdd = await User.findOne({ where: { email: userEmail } });
- if (!userToAdd) {
+ const userPending = await PendingInvitation.findOne({ where: { userEmail: userEmail } });
+ if (!userToAdd && !userPending) {
// if the user does not exist in the db, add it to the pendinginvitationList
const pendingInvitation = PendingInvitation.create({
userEmail: userEmail,
@@ -29,7 +35,7 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput
}
const groupMember = GroupMember.create({
- userId: userToAdd.id,
+ userId: userToAdd?.id,
groupId,
});
@@ -37,3 +43,25 @@ export async function addMembersToGroup({ userEmails, groupId }: AddMembersInput
}),
);
}
+
+export async function removeMembersFromGroup({ userIds, groupId }: RemoveMembersInput) {
+ if (!userIds || userIds.length === 0) return;
+
+ // Remove all group members
+ await Promise.all(
+ userIds.map(async (userId) => {
+ const groupMember = await GroupMember.findOne({
+ where: { userId: userId, groupId: groupId },
+ });
+
+ if (!groupMember) return;
+
+ try {
+ await GroupMember.remove(groupMember);
+ } catch (err) {
+ console.error("removeMembersFromGroup error:", err);
+ throw new Error("Une erreur est survenue lors de la suppression de l'utilisateur du groupe");
+ }
+ }),
+ );
+}
diff --git a/frontend/src/components/forms/CreateGroupForm.tsx b/frontend/src/components/forms/CreateGroupForm.tsx
deleted file mode 100644
index d9f05f9..0000000
--- a/frontend/src/components/forms/CreateGroupForm.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import type React from "react";
-import { useState } from "react";
-import { useCreateGroupMutation } from "../../graphql/generated/graphql-types";
-import { GET_ALL_MY_GROUPS } from "../../graphql/operations/groupOperations";
-import { groupCreationFormValidation } from "../../hooks/formValidationRules";
-import { useSanitizedForm } from "../../hooks/useSanitizedForm";
-import Icon from "../utils/Icon";
-import Input from "../utils/Input";
-import InputWithToggle from "../utils/InputWithToggle";
-import ResponsiveImage from "../utils/ResponsiveImage";
-import SearchSelectInput from "../utils/SearchSelectInput";
-import Subtitle from "../utils/Subtitle";
-import GroupLink from "./GroupLink";
-
-type CreateGroupFormProps = {
- onSuccess?: () => void;
- onCancel?: () => void;
-};
-
-export default function CreateGroupForm({ onSuccess, onCancel }: CreateGroupFormProps) {
- const options = [
- {
- label: "Anniversaire",
- value: "Anniversaire",
- },
- {
- label: "Marriage",
- value: "Marriage",
- },
- {
- label: "Naissance",
- value: "Naissance",
- },
- {
- label: "Pot de départ",
- value: "Pot de départ",
- },
- {
- label: "Noël",
- value: "Noël",
- },
- ];
-
- // const [query, setQuery] = useState("");
- const [checked, setChecked] = useState(false);
-
- const { formData, handleChange, getSanitizedData, errors, isValid, setFormData, isEmpty } =
- useSanitizedForm(
- {
- name: "",
- event_type: "",
- piggy_bank: 0,
- deadline: "",
- users: [] as string[],
- user_beneficiary: "",
- },
- groupCreationFormValidation,
- );
-
- const [error, setError] = useState("");
-
- const [createGroup] = useCreateGroupMutation({
- awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: GET_ALL_MY_GROUPS,
- },
- ],
- });
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (isEmpty) {
- console.info("Form is empty.");
- setError("Ătre bref c'est bien, mais il faut quand mĂȘme remplir le formulaire");
- return;
- }
- if (isValid) {
- try {
- const sanitizedData = getSanitizedData();
- if (!sanitizedData) {
- console.error("Sanitized data is null or undefined.");
- return;
- }
-
- const response = await createGroup({
- variables: {
- data: {
- ...sanitizedData,
- piggy_bank: Number(sanitizedData?.piggy_bank),
- deadline: new Date(sanitizedData?.deadline),
- },
- },
- });
-
- console.info("Group created successfully:", response.data);
- setFormData({
- name: "",
- event_type: "",
- piggy_bank: 0,
- deadline: "",
- users: [],
- user_beneficiary: "", //Do not reset users here instead show the list of existing users
- });
-
- setChecked(false);
-
- // Close the parent modal if provided
- if (onSuccess) onSuccess();
- } catch (error: unknown) {
- console.error("Error creating group:", error);
- if (error instanceof Error) {
- setError(error.message);
- } else {
- setError("Une erreur est survenue");
- }
- }
- }
-
- console.error("Form has errors, cannot submit.", errors);
- }
-
- return (
-
- );
-}
diff --git a/frontend/src/components/forms/GroupLink.tsx b/frontend/src/components/forms/GroupLink.tsx
deleted file mode 100644
index c245646..0000000
--- a/frontend/src/components/forms/GroupLink.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import Input from "../utils/Input";
-
-export default function GroupLink() {
- // La logique pour récupérer un lien ici
- return (
- {}}
- />
- );
-}
diff --git a/frontend/src/components/auth/AuthFooter.tsx b/frontend/src/components/forms/auth/AuthFooter.tsx
similarity index 100%
rename from frontend/src/components/auth/AuthFooter.tsx
rename to frontend/src/components/forms/auth/AuthFooter.tsx
diff --git a/frontend/src/components/auth/AuthFormTemplate.tsx b/frontend/src/components/forms/auth/AuthFormTemplate.tsx
similarity index 95%
rename from frontend/src/components/auth/AuthFormTemplate.tsx
rename to frontend/src/components/forms/auth/AuthFormTemplate.tsx
index 1f21098..9a7aaf8 100644
--- a/frontend/src/components/auth/AuthFormTemplate.tsx
+++ b/frontend/src/components/forms/auth/AuthFormTemplate.tsx
@@ -1,4 +1,4 @@
-import Title from "../utils/Title";
+import Title from "../../utils/Title";
type FormProps = {
title: string;
diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/forms/auth/LoginForm.tsx
similarity index 89%
rename from frontend/src/components/auth/LoginForm.tsx
rename to frontend/src/components/forms/auth/LoginForm.tsx
index 6586f33..eb2667b 100644
--- a/frontend/src/components/auth/LoginForm.tsx
+++ b/frontend/src/components/forms/auth/LoginForm.tsx
@@ -1,10 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router";
-import { useLoginMutation } from "../../graphql/generated/graphql-types";
-import consoleErrorDev from "../../hooks/erreurMod";
-import { useMyProfileStore } from "../../zustand/myProfileStore";
-import Button from "../utils/Button";
-import Input from "../utils/Input";
+import { useLoginMutation } from "../../../graphql/generated/graphql-types";
+import consoleErrorDev from "../../../hooks/erreurMod";
+import { useMyProfileStore } from "../../../zustand/myProfileStore";
+import Button from "../../utils/Button";
+import Input from "../../utils/Input";
import AuthFooter from "./AuthFooter";
import AuthFormTemplate from "./AuthFormTemplate";
diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/forms/auth/RegisterForm.tsx
similarity index 95%
rename from frontend/src/components/auth/RegisterForm.tsx
rename to frontend/src/components/forms/auth/RegisterForm.tsx
index a2e1397..51def5c 100644
--- a/frontend/src/components/auth/RegisterForm.tsx
+++ b/frontend/src/components/forms/auth/RegisterForm.tsx
@@ -1,10 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router";
-import { useSignupMutation } from "../../graphql/generated/graphql-types";
-import consoleErrorDev from "../../hooks/erreurMod";
-import { useMyProfileStore } from "../../zustand/myProfileStore";
-import Button from "../utils/Button";
-import Input from "../utils/Input";
+import { useSignupMutation } from "../../../graphql/generated/graphql-types";
+import consoleErrorDev from "../../../hooks/erreurMod";
+import { useMyProfileStore } from "../../../zustand/myProfileStore";
+import Button from "../../utils/Button";
+import Input from "../../utils/Input";
import AuthFooter from "./AuthFooter";
import AuthFormTemplate from "./AuthFormTemplate";
diff --git a/frontend/src/components/auth/auth.css b/frontend/src/components/forms/auth/auth.css
similarity index 100%
rename from frontend/src/components/auth/auth.css
rename to frontend/src/components/forms/auth/auth.css
diff --git a/frontend/src/components/forms/groups/GroupForm.tsx b/frontend/src/components/forms/groups/GroupForm.tsx
new file mode 100644
index 0000000..e6e4e48
--- /dev/null
+++ b/frontend/src/components/forms/groups/GroupForm.tsx
@@ -0,0 +1,105 @@
+import type React from "react";
+import Icon from "../../utils/Icon";
+import Input from "../../utils/Input";
+import InputWithToggle from "../../utils/InputWithToggle";
+import SearchSelectInput from "../../utils/SearchSelectInput";
+import Subtitle from "../../utils/Subtitle";
+import { options } from "../groups/eventOptions.ts";
+
+type GroupFormProps = {
+ onSuccess?: () => void;
+ /** When provided, the form works in edit mode and pre-populates with the group data */
+ groupId?: number;
+ formData: any;
+ errors: any;
+ isEdit: boolean;
+ handleChange: any;
+ checked: boolean;
+ setChecked: any;
+ isAdmin: boolean;
+};
+
+export default function GroupForm({
+ formData,
+ errors,
+ checked,
+ setChecked,
+ isEdit,
+ handleChange,
+ isAdmin,
+}: GroupFormProps) {
+ const disabled = isEdit && !isAdmin;
+
+ return (
+ <>
+
+ {isEdit ? "Modifier le groupe" : "Créer un groupe"}
+
+
+
+
+
+
+
+
+ handleChange({
+ target: { name: "event_type", value: val },
+ } as React.ChangeEvent)
+ }
+ placeholder="Quel est l'événement ?"
+ error={errors.event_type}
+ icon="gift"
+ options={options}
+ theme="light"
+ />
+
+
+
+ {
+ setChecked(!checked);
+ }}
+ name="user_beneficiary"
+ value={formData.user_beneficiary ?? ""}
+ onChange={handleChange}
+ label="Le nom du destinataire"
+ question="Voulez-vous ajouter un destinataire? "
+ error={errors.user_beneficiary}
+ />
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/forms/groups/GroupFormTemplate.tsx b/frontend/src/components/forms/groups/GroupFormTemplate.tsx
new file mode 100644
index 0000000..5b82852
--- /dev/null
+++ b/frontend/src/components/forms/groups/GroupFormTemplate.tsx
@@ -0,0 +1,57 @@
+import type React from "react";
+import Button from "../../utils/Button";
+
+type GroupFormTemplateProps = {
+ onSubmit: (e: React.FormEvent) => void;
+ left: React.ReactNode;
+ right?: React.ReactNode;
+ isEdit: boolean;
+ submitError: any;
+ errors: any;
+ onSuccess: () => void;
+ isAdmin: boolean;
+};
+
+export default function GroupFormTemplate({
+ onSubmit,
+ onSuccess,
+ left,
+ right,
+ isEdit,
+ submitError,
+ errors,
+ isAdmin,
+}: GroupFormTemplateProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/forms/groups/UsersForm.tsx b/frontend/src/components/forms/groups/UsersForm.tsx
new file mode 100644
index 0000000..51ef4a2
--- /dev/null
+++ b/frontend/src/components/forms/groups/UsersForm.tsx
@@ -0,0 +1,106 @@
+import type { Dispatch, SetStateAction } from "react";
+import {
+ useDeleteGroupMutation,
+ useRemoveMembersFromGroupMutation,
+} from "../../../graphql/generated/graphql-types";
+import Button from "../../utils/Button";
+import SearchInput from "../../utils/SearchInput";
+
+type handleUsersFormProps = {
+ query: string;
+ setQuery: Dispatch>;
+ setFormData: (updater: (prev: any) => any) => void;
+ errors: any;
+ onAddTag: (email: string) => void;
+ isEdit: boolean;
+ isAdmin: boolean;
+ groupId: number;
+ currentUser: any;
+ items: string[];
+ onSuccess?: () => void;
+ onRemoveMember?: (email: string) => void;
+};
+
+export default function UsersForm({
+ query,
+ setQuery,
+ onAddTag,
+ items,
+ setFormData,
+ errors,
+ isAdmin,
+ isEdit,
+ groupId,
+ currentUser,
+ onSuccess,
+ onRemoveMember,
+}: handleUsersFormProps) {
+ const [deleteGroup] = useDeleteGroupMutation();
+ const [removeMembers] = useRemoveMembersFromGroupMutation();
+
+ async function leaveGroup() {
+ if (!currentUser?.id) return;
+ try {
+ await removeMembers({
+ variables: {
+ groupId: groupId,
+ data: {
+ userIds: [Number(currentUser.id)],
+ },
+ },
+ });
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error("Error leaving group:", error);
+ }
+ }
+
+ async function deleteMyGroup() {
+ try {
+ await deleteGroup({
+ variables: {
+ deleteGroupId: groupId,
+ },
+ });
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error("Error deleting group:", error);
+ }
+ }
+
+ return (
+
+ setQuery(e.target.value)} // â
correct
+ items={items}
+ error={errors.users}
+ onClick={(email: string) => {
+ setFormData((prev: any) => ({
+ ...prev,
+ users: prev?.users?.filter((user: string) => user !== email),
+ }));
+ // Notify parent component about member removal
+ if (onRemoveMember) {
+ onRemoveMember(email);
+ }
+ }}
+ onAddTag={onAddTag}
+ />
+ {!isAdmin && isEdit && (
+
+ Quitter le groupe
+
+ )}
+ {isAdmin && isEdit && (
+
+ Supprimer le groupe
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/forms/groups/eventOptions.ts b/frontend/src/components/forms/groups/eventOptions.ts
new file mode 100644
index 0000000..e8a3846
--- /dev/null
+++ b/frontend/src/components/forms/groups/eventOptions.ts
@@ -0,0 +1,22 @@
+export const options = [
+ {
+ label: "Anniversaire",
+ value: "Anniversaire",
+ },
+ {
+ label: "Marriage",
+ value: "Marriage",
+ },
+ {
+ label: "Naissance",
+ value: "Naissance",
+ },
+ {
+ label: "Pot de départ",
+ value: "Pot de départ",
+ },
+ {
+ label: "Noël",
+ value: "Noël",
+ },
+];
diff --git a/frontend/src/hooks/formValidationRules.ts b/frontend/src/components/forms/groups/formValidationRules.ts
similarity index 89%
rename from frontend/src/hooks/formValidationRules.ts
rename to frontend/src/components/forms/groups/formValidationRules.ts
index 98c0080..16819d8 100644
--- a/frontend/src/hooks/formValidationRules.ts
+++ b/frontend/src/components/forms/groups/formValidationRules.ts
@@ -1,6 +1,6 @@
-import type { CreateGroupInput } from "../graphql/generated/graphql-types";
-import { countdownDate } from "../utils/dateCalculator";
-import { verifyEmail } from "./verifyEmail";
+import type { CreateGroupInput } from "../../../graphql/generated/graphql-types";
+import { verifyEmail } from "../../../hooks/verifyEmail";
+import { countdownDate } from "../../../utils/dateCalculator";
export type GroupFormErrors = Partial> & {
main?: string;
diff --git a/frontend/src/components/forms/groups/index.tsx b/frontend/src/components/forms/groups/index.tsx
new file mode 100644
index 0000000..990c1b6
--- /dev/null
+++ b/frontend/src/components/forms/groups/index.tsx
@@ -0,0 +1,313 @@
+import { useEffect, useState } from "react";
+import {
+ type CreateGroupInput,
+ type Group,
+ useCreateGroupMutation,
+ useGetGroupByIdQuery,
+ useRemoveMembersFromGroupMutation,
+ useUpdateGroupMutation,
+} from "../../../graphql/generated/graphql-types";
+import { GET_ALL_MY_GROUPS } from "../../../graphql/operations/groupOperations";
+import { useSanitizedForm } from "../../../hooks/useSanitizedForm";
+import { useUserPermissions } from "../../../hooks/useUserPermissions";
+import { useMyProfileStore } from "../../../zustand/myProfileStore";
+import { type GroupFormErrors, groupCreationFormValidation } from "../groups/formValidationRules";
+import GroupForm from "./GroupForm";
+import GroupFormTemplate from "./GroupFormTemplate";
+import UsersForm from "./UsersForm";
+
+type GroupFormIndex = {
+ onSuccess: () => void;
+ onCancel: () => void;
+ groupId?: number;
+ isOpen?: boolean;
+};
+
+const EMPTY_FORM_STATE: CreateGroupInput = {
+ name: "",
+ event_type: "",
+ piggy_bank: 0,
+ deadline: "",
+ users: [],
+ user_beneficiary: "",
+};
+
+export default function GroupFormindex({ onSuccess, groupId }: GroupFormIndex) {
+ const [submitError, setSubmitError] = useState("");
+ const [checked, setChecked] = useState(false);
+ const [query, setQuery] = useState("");
+ const { userProfile } = useMyProfileStore();
+ const [group, setGroup] = useState(null);
+ const { currentUser, isAdmin } = useUserPermissions(group || undefined);
+ const isEditMode = !!groupId; //(truthy => true or falsy => false)
+ const [removeMembers] = useRemoveMembersFromGroupMutation({
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: GET_ALL_MY_GROUPS,
+ },
+ ],
+ });
+ const [usersToRemove, setUsersToRemove] = useState([]);
+ const [originalMemberEmails, setOriginalMemberEmails] = useState([]);
+
+ const { formData, handleChange, getSanitizedData, errors, setErrors, isValid, setFormData, isEmpty } =
+ useSanitizedForm(EMPTY_FORM_STATE, groupCreationFormValidation);
+
+ const {
+ data,
+ loading,
+ error: queryError,
+ } = useGetGroupByIdQuery({
+ variables: {
+ // value will be ignored when skip is true
+ id: groupId ?? 0,
+ },
+ skip: !isEditMode,
+ });
+
+ const [createGroup] = useCreateGroupMutation({
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: GET_ALL_MY_GROUPS,
+ },
+ ],
+ });
+
+ const [updateGroup] = useUpdateGroupMutation({
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: GET_ALL_MY_GROUPS,
+ },
+ ],
+ });
+
+ useEffect(() => {
+ if (!isEditMode || !data?.getGroupById) return;
+ const groupData = data.getGroupById;
+ // Set group state with proper type casting
+ setGroup(groupData as Group);
+
+ // Extract emails from existing group members (excluding the current user)
+ const existingMemberEmails = groupData.groupMember
+ .map((member) => member.user?.email)
+ .filter((email): email is string => {
+ // Filter out undefined/null emails and the current user's email
+ return Boolean(email) && email.toLowerCase() !== currentUser?.email?.toLowerCase();
+ });
+
+ // Store original member emails to track removals
+ setOriginalMemberEmails(existingMemberEmails);
+ // Reset users to remove when group data loads
+ setUsersToRemove([]);
+
+ setFormData((prev) => ({
+ ...prev,
+ name: groupData?.name ?? "",
+ event_type: groupData?.event_type ?? "",
+ piggy_bank: groupData?.piggy_bank ?? 0,
+ // Normalise to YYYY-MM-DD for the date input, if possible
+ deadline: groupData?.deadline ? new Date(groupData?.deadline).toISOString().slice(0, 10) : "",
+ // We don't currently have an email here, so use the first name as a display value if present
+ user_beneficiary: groupData?.user_beneficiary?.firstName ?? "",
+ users: existingMemberEmails,
+ }));
+ setChecked(Boolean(groupData?.user_beneficiary));
+ }, [data, isEditMode, currentUser?.email]); //setFromData
+
+ if (isEditMode) {
+ if (loading) return Loading...
;
+ if (queryError) return Error: {queryError.message}
;
+ if (!data?.getGroupById) return Group not found
;
+ }
+
+ //Handling the users:
+
+ const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); // adapt to your auth source
+
+ const handleAddUserByEmail = (email: string) => {
+ const normalizedEmail = email.trim().toLowerCase();
+ if (!normalizedEmail) return;
+
+ if (!isValidEmail(normalizedEmail)) {
+ setErrors((prev) => ({
+ ...prev,
+ users: "Adresse email invalide",
+ }));
+ return;
+ }
+
+ if (userProfile?.email && normalizedEmail === currentUser?.email.toLowerCase()) {
+ setErrors((prev) => ({
+ ...prev,
+ users: "Vous ne pouvez pas vous ajouter vous-mĂȘme",
+ }));
+ return;
+ }
+
+ if (formData.users?.includes(normalizedEmail)) {
+ setErrors((prev) => ({
+ ...prev,
+ users: "Cet utilisateur est déjà ajouté",
+ }));
+ return;
+ }
+
+ setFormData((prev) => ({
+ ...prev,
+ users: [...(prev.users || []), normalizedEmail],
+ }));
+
+ // If this user was marked for removal but is being added back, remove from removal list
+ if (isEditMode && isAdmin && usersToRemove.includes(normalizedEmail)) {
+ setUsersToRemove((prev) => prev.filter((e) => e !== normalizedEmail));
+ }
+
+ setErrors((prev) => {
+ const { ...rest } = prev;
+ return rest;
+ });
+ setQuery("");
+ };
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSubmitError("");
+
+ if (isEmpty) {
+ setSubmitError("Ătre bref c'est bien, mais il faut quand mĂȘme remplir le formulaire");
+ return;
+ }
+
+ if (!isValid) {
+ console.error("Form has errors, cannot submit.", errors);
+ return;
+ }
+
+ try {
+ const sanitizedData = getSanitizedData();
+
+ if (!sanitizedData) {
+ console.error("Sanitized data is null or undefined.");
+ return;
+ }
+
+ const commonVariables = {
+ data: {
+ ...sanitizedData,
+ piggy_bank: Number(sanitizedData.piggy_bank),
+ deadline: new Date(sanitizedData.deadline),
+ users: formData.users,
+ },
+ };
+
+ if (isEditMode && groupId) {
+ await updateGroup({
+ variables: {
+ ...commonVariables,
+ updateGroupId: groupId,
+ },
+ });
+
+ // Remove members that were removed from the form
+ if (usersToRemove.length > 0 && isAdmin) {
+ await removeMembersAsAdmin();
+ }
+
+ // Reset removal tracking after successful update
+ setUsersToRemove([]);
+ } else {
+ const response = await createGroup({
+ variables: commonVariables,
+ });
+
+ console.info("Group created successfully:", response.data);
+
+ setFormData(EMPTY_FORM_STATE);
+ setChecked(false);
+ setUsersToRemove([]);
+ }
+
+ if (onSuccess) onSuccess();
+ } catch (error: unknown) {
+ console.error("Error submitting group form:", error);
+ if (error instanceof Error) {
+ setSubmitError(error.message);
+ } else {
+ setSubmitError("Une erreur est survenue");
+ }
+ }
+ }
+
+ async function removeMembersAsAdmin() {
+ if (!isAdmin || !groupId || usersToRemove.length === 0) return;
+ try {
+ await removeMembers({
+ variables: {
+ groupId: groupId,
+ data: {
+ userEmails: usersToRemove,
+ },
+ },
+ });
+ // Reset the users to remove list after successful removal
+ setUsersToRemove([]);
+ } catch (error) {
+ console.error("Error removing members:", error);
+ throw error; // Re-throw to be caught by handleSubmit
+ }
+ }
+
+ const handleRemoveMember = (email: string) => {
+ // Only track removal if this email was originally a member
+ if (isEditMode && isAdmin && originalMemberEmails.includes(email)) {
+ setUsersToRemove((prev) => {
+ // Avoid duplicates
+ if (!prev.includes(email)) {
+ return [...prev, email];
+ }
+ return prev;
+ });
+ }
+ };
+ return (
+
+ }
+ right={
+
+ }
+ onSubmit={handleSubmit}
+ isEdit={isEditMode}
+ submitError={submitError}
+ errors={errors}
+ />
+ );
+}
diff --git a/frontend/src/components/groups/Groups.tsx b/frontend/src/components/groups/Groups.tsx
index b666753..b8542b4 100644
--- a/frontend/src/components/groups/Groups.tsx
+++ b/frontend/src/components/groups/Groups.tsx
@@ -2,7 +2,7 @@ import type { GetAllMyGroupsQuery } from "../../graphql/generated/graphql-types"
import { useToggle } from "../../hooks/useToggle";
import type { Message } from "../../types/Message";
import { formatDate } from "../../utils/dateCalculator";
-import CreateGroupForm from "../forms/CreateGroupForm";
+import GroupFormindex from "../forms/groups/index";
import Button from "../utils/Button";
import Card from "../utils/Card";
import Container from "../utils/Container";
@@ -83,7 +83,7 @@ export default function Groups({
withPadding={false}
className="p-0 overflow-y-auto max-h-[85vh] max-md:max-h-full"
>
-
+
>
);
diff --git a/frontend/src/components/groups/Messaging/Messaging.tsx b/frontend/src/components/groups/Messaging/Messaging.tsx
index 9f2ec1b..ae553eb 100644
--- a/frontend/src/components/groups/Messaging/Messaging.tsx
+++ b/frontend/src/components/groups/Messaging/Messaging.tsx
@@ -3,10 +3,13 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
import { FaArrowDown, FaLocationArrow } from "react-icons/fa";
import type { GetAllMessageMyGroupsQuery } from "../../../graphql/generated/graphql-types.ts";
import { useGetLazyMessagesLazyQuery } from "../../../graphql/generated/graphql-types.ts";
+import { useToggle } from "../../../hooks/useToggle.ts";
import type { Message as MessageType } from "../../../types/Message";
import { countdownDate, isSameDate } from "../../../utils/dateCalculator.ts";
import { useMyProfileStore } from "../../../zustand/myProfileStore.ts";
+import GroupFormIndex from "../../forms/groups/index.tsx";
import Icon from "../../utils/Icon.tsx";
+import Modal from "../../utils/Modal.tsx";
import Subtitle from "../../utils/Subtitle.tsx";
import Message from "./Message.tsx";
import TimeLigne from "./TimeLigne.tsx";
@@ -45,6 +48,7 @@ export default function Messaging({
const id = useId();
const [messageInput, setMessageInput] = useState("");
const { userProfile } = useMyProfileStore();
+ const groupFormModal = useToggle(false);
// scroll automatique le plus en bas possible
const bottomRef = useRef(null);
@@ -221,8 +225,20 @@ export default function Messaging({
-
+
+
+
+ {groupFormModal.isOpen && (
+
+
+
+ )}
)}
{
placholder?: string;
label?: string;
icon?: IconTypes;
+ disabled?: boolean;
}
export default function Input({
@@ -25,6 +26,7 @@ export default function Input({
className = "",
placeholder,
icon,
+ disabled = false,
...props
}: InputProps) {
const baseStyles =
@@ -35,6 +37,12 @@ export default function Input({
? "border-dark border-[3.5px] text-dark focus:border-blue placeholder:text-dark/50"
: "bg-transparent border-white border-[3.5px] text-white placeholder:text-white/70";
+ const disabledStyles = disabled
+ ? theme === "dark"
+ ? "opacity-60 cursor-not-allowed bg-gray-50 border-gray-300 text-gray-600"
+ : "opacity-60 cursor-not-allowed bg-gray-900/30 border-gray-500 text-gray-300 placeholder-gray-400"
+ : "";
+
const errorStyles = error ? "border-orange focus:border-orange" : "";
const id = useId();
@@ -52,7 +60,8 @@ export default function Input({
type={type}
value={value}
onChange={onChange}
- className={`${baseStyles} ${themeStyles} ${errorStyles} ${className}`}
+ disabled={disabled}
+ className={`${baseStyles} ${themeStyles} ${disabledStyles} ${errorStyles} ${className}`}
name={name}
placeholder={placeholder}
{...props}
@@ -60,7 +69,13 @@ export default function Input({
{icon && (
)}
diff --git a/frontend/src/components/utils/InputWithToggle.tsx b/frontend/src/components/utils/InputWithToggle.tsx
index f4d1da4..76fd5d5 100644
--- a/frontend/src/components/utils/InputWithToggle.tsx
+++ b/frontend/src/components/utils/InputWithToggle.tsx
@@ -7,6 +7,7 @@ interface InputToggleProps extends React.ComponentProps {
label?: string;
question?: string;
onChange: (e: React.ChangeEvent) => void;
+ disabled?: boolean;
}
export default function InputWithToggle({
@@ -16,11 +17,14 @@ export default function InputWithToggle({
theme = "light",
label,
question,
+ disabled = false,
...props
}: InputToggleProps) {
return (
- {checked && }
+ {checked && (
+
+ )}
{!checked && {question} }
diff --git a/frontend/src/components/utils/SearchInput.tsx b/frontend/src/components/utils/SearchInput.tsx
index c53f577..ef2d53a 100644
--- a/frontend/src/components/utils/SearchInput.tsx
+++ b/frontend/src/components/utils/SearchInput.tsx
@@ -1,5 +1,6 @@
import { useId } from "react";
import Icon from "./Icon";
+import Input from "./Input";
import Tag from "./Tag";
interface SearchInputProps extends Omit, "onClick"> {
@@ -14,6 +15,7 @@ interface SearchInputProps extends Omit void;
onAddTag?: (tag: string) => void;
items: string[];
+ disabled?: boolean;
}
export default function SearchInput({
@@ -29,6 +31,7 @@ export default function SearchInput({
type = "text",
error,
className,
+ disabled = false,
...props
}: SearchInputProps) {
const baseStyles =
@@ -44,12 +47,14 @@ export default function SearchInput({
const id = useId();
function handleAddTag() {
+ if (disabled) return;
if (value.trim() !== "" && onAddTag) {
onAddTag(value.trim());
}
}
function handleKeyDown(e: React.KeyboardEvent) {
+ if (disabled) return;
if (e.key === "Enter" && value.trim() !== "") {
e.preventDefault();
handleAddTag();
@@ -64,29 +69,43 @@ export default function SearchInput({
)}
-
-
+
{error && {error}
}
{items.map((item) => (
- onClick(item)} />
+ !disabled && onClick(item)}
+ />
))}
diff --git a/frontend/src/components/utils/SearchSelectInput.tsx b/frontend/src/components/utils/SearchSelectInput.tsx
index b445277..9957f43 100644
--- a/frontend/src/components/utils/SearchSelectInput.tsx
+++ b/frontend/src/components/utils/SearchSelectInput.tsx
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import type { IconTypes } from "./Icon";
import Icon from "./Icon";
+import Input from "./Input";
interface Option {
label: string;
@@ -18,6 +19,7 @@ interface SearchableSelectProps {
theme?: "light" | "dark";
icon?: IconTypes;
className?: string;
+ disabled?: boolean;
}
export default function SearchSelectInput({
@@ -29,8 +31,10 @@ export default function SearchSelectInput({
label,
error,
theme = "light",
+ disabled = false,
icon,
className = "",
+ name,
}: SearchableSelectProps) {
const id = useId();
const [open, setOpen] = useState(false);
@@ -41,8 +45,17 @@ export default function SearchSelectInput({
// Filter options
const filtered = options.filter((opt) => opt.label.toLowerCase().includes(query.toLowerCase()));
+ // Close dropdown if disabled
+ useEffect(() => {
+ if (disabled && open) {
+ setOpen(false);
+ setQuery("");
+ }
+ }, [disabled, open]);
+
// Close dropdown on click outside
useEffect(() => {
+ if (disabled) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
@@ -51,11 +64,11 @@ export default function SearchSelectInput({
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
- }, []);
+ }, [disabled]);
// Keyboard navigation
function handleKeyDown(e: React.KeyboardEvent) {
- if (!open) return;
+ if (disabled || !open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -84,6 +97,12 @@ export default function SearchSelectInput({
? "border-dark border-[3.5px] text-dark focus:border-blue bg-white placeholder:text-dark/50"
: "bg-transparent border-white border-[3.5px] text-white placeholder:text-white/70";
+ const disabledStyles = disabled
+ ? theme === "dark"
+ ? "opacity-60 cursor-not-allowed bg-gray-50 border-gray-300 text-gray-600"
+ : "opacity-60 cursor-not-allowed bg-gray-900/30 border-gray-500 text-gray-300"
+ : "cursor-pointer";
+
const errorInput = error ? "border-orange focus:border-orange" : "";
const dropdownStyles =
@@ -91,11 +110,12 @@ export default function SearchSelectInput({
return (
@@ -110,13 +130,15 @@ export default function SearchSelectInput({
{/* WHEN CLOSED â show regular input */}
{!open && (
setOpen(true)}
+ aria-disabled={disabled}
+ tabIndex={disabled ? -1 : 0}
+ onClick={() => !disabled && setOpen(true)}
onKeyDown={(e) => {
+ if (disabled) return;
if (e.key === "Enter") {
e.preventDefault();
setOpen(true);
@@ -127,13 +149,26 @@ export default function SearchSelectInput({
{value ? options.find((o) => o.value === value)?.label : placeholder}
- {icon && }
+ {icon && (
+
+ )}
)}
{/* WHEN OPEN â show SEARCH BAR in place of the input */}
- {open && (
-
{
@@ -147,7 +182,7 @@ export default function SearchSelectInput({
)}
{/* DROPDOWN LIST */}
- {open && (
+ {open && !disabled && (
void;
+ disabled?: boolean;
}
const tagStyles = {
@@ -14,19 +15,36 @@ const tagStyles = {
dark: "bg-dark text-white",
};
-export default function Tag({ tag, type = "info", onClick, className }: TagProps) {
+const disabledTagStyles = {
+ info: "bg-blue/50 text-white/70",
+ warning: "bg-orange/50 text-white/70",
+ success: "bg-green/50 text-white/70",
+ dark: "bg-dark/50 text-white/70",
+};
+
+export default function Tag({ tag, type = "info", onClick, className, disabled = false }: TagProps) {
+ const baseStyles = disabled ? disabledTagStyles[type] : tagStyles[type];
+
return (
{tag}
-
+
);
diff --git a/frontend/src/graphql/generated/graphql-types.ts b/frontend/src/graphql/generated/graphql-types.ts
index 11c77aa..bbb62c6 100644
--- a/frontend/src/graphql/generated/graphql-types.ts
+++ b/frontend/src/graphql/generated/graphql-types.ts
@@ -103,6 +103,7 @@ export type GroupMember = {
isGroupAdmin: Scalars['Boolean']['output'];
joined_at: Scalars['DateTimeISO']['output'];
lastTempstampVu: Scalars['DateTimeISO']['output'];
+ user: User;
userId: Scalars['Float']['output'];
};
@@ -164,15 +165,18 @@ export type Mutation = {
banUser: BanUserResponse;
createGroup: Group;
deleteGift: Scalars['Int']['output'];
+ deleteGroup: Scalars['String']['output'];
deleteMyProfile: DeleteUserResponse;
deleteUser: DeleteUserResponse;
login: User;
logout: Scalars['Boolean']['output'];
+ removeMembersFromGroup: Scalars['String']['output'];
sendMessage: Message;
setLastMessageVu: SetLastMessageVuOutput;
signup: User;
unbanUser: BanUserResponse;
updateGift: Gift;
+ updateGroup: Group;
};
@@ -212,6 +216,11 @@ export type MutationDeleteGiftArgs = {
};
+export type MutationDeleteGroupArgs = {
+ id: Scalars['Float']['input'];
+};
+
+
export type MutationDeleteUserArgs = {
userId: Scalars['Float']['input'];
};
@@ -222,6 +231,12 @@ export type MutationLoginArgs = {
};
+export type MutationRemoveMembersFromGroupArgs = {
+ data: RemoveMembersInput;
+ groupId: Scalars['Float']['input'];
+};
+
+
export type MutationSendMessageArgs = {
data: NewMessageInput;
};
@@ -247,6 +262,12 @@ export type MutationUpdateGiftArgs = {
id: Scalars['Int']['input'];
};
+
+export type MutationUpdateGroupArgs = {
+ data: UpdateGroupInput;
+ id: Scalars['Float']['input'];
+};
+
export type MyGroupsResponse = {
__typename?: 'MyGroupsResponse';
groupToken: Scalars['String']['output'];
@@ -279,6 +300,7 @@ export type Query = {
getAllUsers: Array
;
getAllUsersAdmin: Array;
getAllUsersForAdmin: Array;
+ getGroupById: Group;
getLazyMessages: GetLazyMessagesOutput;
getMyProfile: User;
groupWishlistItems: GroupWishlistItems;
@@ -289,6 +311,11 @@ export type Query = {
};
+export type QueryGetGroupByIdArgs = {
+ id: Scalars['Float']['input'];
+};
+
+
export type QueryGetLazyMessagesArgs = {
data: GetLazyMessagesInput;
};
@@ -298,6 +325,11 @@ export type QueryGroupWishlistItemsArgs = {
groupId: Scalars['Int']['input'];
};
+export type RemoveMembersInput = {
+ userEmails?: InputMaybe>;
+ userIds?: InputMaybe>;
+};
+
export type SetLastMessageVuInput = {
groupId: Scalars['Float']['input'];
};
@@ -322,6 +354,15 @@ export type UpdateGiftInput = {
url?: InputMaybe;
};
+export type UpdateGroupInput = {
+ deadline: Scalars['DateTimeISO']['input'];
+ event_type: Scalars['String']['input'];
+ name: Scalars['String']['input'];
+ piggy_bank: Scalars['Float']['input'];
+ user_beneficiary?: InputMaybe;
+ users?: InputMaybe>;
+};
+
export type UpdateMyProfileInput = {
date_of_birth: Scalars['String']['input'];
email: Scalars['String']['input'];
@@ -362,12 +403,12 @@ export type CreateGroupMutationVariables = Exact<{
}>;
-export type CreateGroupMutation = { __typename?: 'Mutation', createGroup: { __typename?: 'Group', id: string, name: string, event_type: string, piggy_bank: number, deadline: any, createdAt: any, user_beneficiary?: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string } | null, user_admin: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number }> } };
+export type CreateGroupMutation = { __typename?: 'Mutation', createGroup: { __typename?: 'Group', id: string, name: string, event_type: string, piggy_bank: number, deadline: any, createdAt: any, user_beneficiary?: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string } | null, user_admin: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }> } };
export type GetAllMyGroupsQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetAllMyGroupsQuery = { __typename?: 'Query', getAllMyGroups: { __typename?: 'MyGroupsResponse', groupToken: string, groups: Array<{ __typename?: 'Group', id: string, name: string, createdAt: any, updatedAt: any, event_type: string, piggy_bank: number, deadline: any, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number }> }> } };
+export type GetAllMyGroupsQuery = { __typename?: 'Query', getAllMyGroups: { __typename?: 'MyGroupsResponse', groupToken: string, groups: Array<{ __typename?: 'Group', id: string, name: string, createdAt: any, updatedAt: any, event_type: string, piggy_bank: number, deadline: any, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }>, user_admin: { __typename?: 'User', isAdmin: boolean, firstName: string, lastName: string, email: string } }> } };
export type GetAllMessageMyGroupsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -381,6 +422,36 @@ export type GetLazyMessagesQueryVariables = Exact<{
export type GetLazyMessagesQuery = { __typename?: 'Query', getLazyMessages: { __typename?: 'GetLazyMessagesOutput', isMaximumMessages: boolean, messages: Array<{ __typename?: 'Message', id: string, content: string, createdAt: any, updatedAt: any, isEdited: boolean, user: { __typename?: 'User', id: string, firstName: string, lastName: string, image_url?: string | null, isAdmin: boolean } }> } };
+export type GetGroupByIdQueryVariables = Exact<{
+ id: Scalars['Float']['input'];
+}>;
+
+
+export type GetGroupByIdQuery = { __typename?: 'Query', getGroupById: { __typename?: 'Group', piggy_bank: number, name: string, id: string, deadline: any, event_type: string, user_admin: { __typename?: 'User', firstName: string, lastName: string, id: string, email: string, isAdmin: boolean }, groupMember: Array<{ __typename?: 'GroupMember', id: string, userId: number, groupId: number, isGroupAdmin: boolean, joined_at: any, user: { __typename?: 'User', firstName: string, email: string, lastName: string } }>, user_beneficiary?: { __typename?: 'User', firstName: string, lastName: string, id: string } | null } };
+
+export type UpdateGroupMutationVariables = Exact<{
+ data: UpdateGroupInput;
+ updateGroupId: Scalars['Float']['input'];
+}>;
+
+
+export type UpdateGroupMutation = { __typename?: 'Mutation', updateGroup: { __typename?: 'Group', id: string, event_type: string, updatedAt: any, name: string, piggy_bank: number, deadline: any } };
+
+export type DeleteGroupMutationVariables = Exact<{
+ deleteGroupId: Scalars['Float']['input'];
+}>;
+
+
+export type DeleteGroupMutation = { __typename?: 'Mutation', deleteGroup: string };
+
+export type RemoveMembersFromGroupMutationVariables = Exact<{
+ groupId: Scalars['Float']['input'];
+ data: RemoveMembersInput;
+}>;
+
+
+export type RemoveMembersFromGroupMutation = { __typename?: 'Mutation', removeMembersFromGroup: string };
+
export type SetLastMessageVuMutationVariables = Exact<{
data: SetLastMessageVuInput;
}>;
@@ -525,6 +596,13 @@ export const CreateGroupDocument = gql`
id
userId
groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
}
}
}
@@ -571,6 +649,19 @@ export const GetAllMyGroupsDocument = gql`
id
userId
groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
+ }
+ user_admin {
+ isAdmin
+ firstName
+ lastName
+ email
}
}
}
@@ -716,6 +807,181 @@ export type GetLazyMessagesQueryHookResult = ReturnType;
export type GetLazyMessagesSuspenseQueryHookResult = ReturnType;
export type GetLazyMessagesQueryResult = Apollo.QueryResult;
+export const GetGroupByIdDocument = gql`
+ query GetGroupById($id: Float!) {
+ getGroupById(id: $id) {
+ user_admin {
+ firstName
+ lastName
+ id
+ email
+ }
+ piggy_bank
+ name
+ id
+ groupMember {
+ id
+ userId
+ groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
+ }
+ deadline
+ event_type
+ user_beneficiary {
+ firstName
+ lastName
+ id
+ }
+ user_admin {
+ isAdmin
+ firstName
+ lastName
+ email
+ }
+ }
+}
+ `;
+
+/**
+ * __useGetGroupByIdQuery__
+ *
+ * To run a query within a React component, call `useGetGroupByIdQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetGroupByIdQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetGroupByIdQuery({
+ * variables: {
+ * id: // value for 'id'
+ * },
+ * });
+ */
+export function useGetGroupByIdQuery(baseOptions: Apollo.QueryHookOptions & ({ variables: GetGroupByIdQueryVariables; skip?: boolean; } | { skip: boolean; }) ) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useQuery(GetGroupByIdDocument, options);
+ }
+export function useGetGroupByIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useLazyQuery(GetGroupByIdDocument, options);
+ }
+export function useGetGroupByIdSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions) {
+ const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions}
+ return Apollo.useSuspenseQuery(GetGroupByIdDocument, options);
+ }
+export type GetGroupByIdQueryHookResult = ReturnType;
+export type GetGroupByIdLazyQueryHookResult = ReturnType;
+export type GetGroupByIdSuspenseQueryHookResult = ReturnType;
+export type GetGroupByIdQueryResult = Apollo.QueryResult;
+export const UpdateGroupDocument = gql`
+ mutation UpdateGroup($data: UpdateGroupInput!, $updateGroupId: Float!) {
+ updateGroup(data: $data, id: $updateGroupId) {
+ id
+ event_type
+ updatedAt
+ name
+ piggy_bank
+ deadline
+ }
+}
+ `;
+export type UpdateGroupMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUpdateGroupMutation__
+ *
+ * To run a mutation, you first call `useUpdateGroupMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateGroupMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [updateGroupMutation, { data, loading, error }] = useUpdateGroupMutation({
+ * variables: {
+ * data: // value for 'data'
+ * updateGroupId: // value for 'updateGroupId'
+ * },
+ * });
+ */
+export function useUpdateGroupMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UpdateGroupDocument, options);
+ }
+export type UpdateGroupMutationHookResult = ReturnType;
+export type UpdateGroupMutationResult = Apollo.MutationResult;
+export type UpdateGroupMutationOptions = Apollo.BaseMutationOptions;
+export const DeleteGroupDocument = gql`
+ mutation DeleteGroup($deleteGroupId: Float!) {
+ deleteGroup(id: $deleteGroupId)
+}
+ `;
+export type DeleteGroupMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useDeleteGroupMutation__
+ *
+ * To run a mutation, you first call `useDeleteGroupMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useDeleteGroupMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [deleteGroupMutation, { data, loading, error }] = useDeleteGroupMutation({
+ * variables: {
+ * deleteGroupId: // value for 'deleteGroupId'
+ * },
+ * });
+ */
+export function useDeleteGroupMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(DeleteGroupDocument, options);
+ }
+export type DeleteGroupMutationHookResult = ReturnType;
+export type DeleteGroupMutationResult = Apollo.MutationResult;
+export type DeleteGroupMutationOptions = Apollo.BaseMutationOptions;
+export const RemoveMembersFromGroupDocument = gql`
+ mutation RemoveMembersFromGroup($groupId: Float!, $data: RemoveMembersInput!) {
+ removeMembersFromGroup(groupId: $groupId, data: $data)
+}
+ `;
+export type RemoveMembersFromGroupMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useRemoveMembersFromGroupMutation__
+ *
+ * To run a mutation, you first call `useRemoveMembersFromGroupMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useRemoveMembersFromGroupMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [removeMembersFromGroupMutation, { data, loading, error }] = useRemoveMembersFromGroupMutation({
+ * variables: {
+ * groupId: // value for 'groupId'
+ * data: // value for 'data'
+ * },
+ * });
+ */
+export function useRemoveMembersFromGroupMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(RemoveMembersFromGroupDocument, options);
+ }
+export type RemoveMembersFromGroupMutationHookResult = ReturnType;
+export type RemoveMembersFromGroupMutationResult = Apollo.MutationResult;
+export type RemoveMembersFromGroupMutationOptions = Apollo.BaseMutationOptions;
export const SetLastMessageVuDocument = gql`
mutation SetLastMessageVu($data: SetLastMessageVuInput!) {
setLastMessageVu(data: $data) {
diff --git a/frontend/src/graphql/operations/groupOperations.ts b/frontend/src/graphql/operations/groupOperations.ts
index cc8c43f..33dfbea 100644
--- a/frontend/src/graphql/operations/groupOperations.ts
+++ b/frontend/src/graphql/operations/groupOperations.ts
@@ -24,10 +24,17 @@ mutation CreateGroup($data: CreateGroupInput!) {
email
}
groupMember {
- id
- userId
- groupId
- }
+ id
+ userId
+ groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
+ }
}
}
`;
@@ -44,11 +51,24 @@ export const GET_ALL_MY_GROUPS = gql`
event_type
piggy_bank
deadline
- groupMember {
+ groupMember {
id
userId
groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
}
+ user_admin {
+ isAdmin
+ firstName
+ lastName
+ email
+ }
}
}
}
@@ -99,6 +119,72 @@ export const GET_LAZY_MESSAGES = gql`
}
`;
+export const GET_GROUP_BY_ID = gql`
+ query GetGroupById($id: Float!) {
+ getGroupById(id: $id) {
+ user_admin {
+ firstName
+ lastName
+ id
+ email
+ }
+ piggy_bank
+ name
+ id
+ groupMember {
+ id
+ userId
+ groupId
+ isGroupAdmin
+ joined_at
+ user {
+ firstName
+ email
+ lastName
+ }
+ }
+ deadline
+ event_type
+ user_beneficiary {
+ firstName
+ lastName
+ id
+ }
+ user_admin {
+ isAdmin
+ firstName
+ lastName
+ email
+ }
+ }
+ }
+`;
+
+export const UPDATE_GROUP = gql`
+ mutation UpdateGroup($data: UpdateGroupInput!, $updateGroupId: Float!) {
+ updateGroup(data: $data, id: $updateGroupId) {
+ id
+ event_type
+ updatedAt
+ name
+ piggy_bank
+ deadline
+ }
+}
+`;
+
+export const DELETE_GROUP = gql`
+mutation DeleteGroup($deleteGroupId: Float!) {
+ deleteGroup(id: $deleteGroupId)
+}
+`;
+
+export const REMOVE_GROUP_MEMBERS = gql`
+mutation RemoveMembersFromGroup($groupId: Float!, $data: RemoveMembersInput!) {
+ removeMembersFromGroup(groupId: $groupId, data: $data)
+}
+`;
+
// pour mettre en bdd le vu du dernier message pour un groupe donné
export const SET_LAST_MESSAGE_VU = gql`
mutation SetLastMessageVu($data: SetLastMessageVuInput!) {
diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts
index b334338..080363e 100644
--- a/frontend/src/hooks/useChat.ts
+++ b/frontend/src/hooks/useChat.ts
@@ -30,3 +30,10 @@ export function useLiveChat(setMessages: (response: { newMessage: message; group
return { connectToRoom, sendMessage };
}
+
+// const sendMessage = useCallback(...)
+// const connectToRoom = useCallback(...)
+// return useMemo(() => ({
+// sendMessage,
+// connectToRoom,
+// }), [sendMessage, connectToRoom]);
diff --git a/frontend/src/hooks/useUserPermissions.ts b/frontend/src/hooks/useUserPermissions.ts
new file mode 100644
index 0000000..2da9514
--- /dev/null
+++ b/frontend/src/hooks/useUserPermissions.ts
@@ -0,0 +1,19 @@
+import type { Group } from "../graphql/generated/graphql-types";
+import type { UserProfile } from "../zustand/myProfileStore";
+import { useMyProfileStore } from "../zustand/myProfileStore";
+
+type UseUserPermissionsResult = {
+ currentUser: UserProfile | null;
+ isAdmin: boolean;
+};
+
+export function useUserPermissions(group?: Group): UseUserPermissionsResult {
+ const currentUser = useMyProfileStore((s) => s.userProfile);
+
+ const isAdmin = !!group && !!currentUser && group.user_admin?.id === currentUser.id;
+
+ return {
+ currentUser,
+ isAdmin,
+ };
+}
diff --git a/frontend/src/pages/Conversations.tsx b/frontend/src/pages/Conversations.tsx
index 1da7fdb..d3775b8 100644
--- a/frontend/src/pages/Conversations.tsx
+++ b/frontend/src/pages/Conversations.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import { HiDotsVertical } from "react-icons/hi";
import { LuCirclePlus, LuMessageCircleMore } from "react-icons/lu";
-import CreateGroupForm from "../components/forms/CreateGroupForm";
+import GroupFormindex from "../components/forms/groups/index";
import AddFundsModal from "../components/groups/AddFundsModal";
import Groups from "../components/groups/Groups";
import Messaging from "../components/groups/Messaging/Messaging";
@@ -50,12 +50,16 @@ export default function Conversations() {
// const [nbNewMessagesRef, setNbNewMessagesRef] = useState<{ [groupId: number]: number }>({});
const { updateLastVu, getNbNewMessages, getLastVu } = useVuMessage();
const [indexGroups, setIndexGroup] = useState(0);
+ const [selectedGroupId, setSelectedGroupId] = useState(null);
const contenairMessageRef = useRef(null);
const handlerNewMessage = (response: { newMessage: Message; groupId: number }) => {
setMessages((prev) => {
const clone = structuredClone(prev);
+ if (!clone[response.groupId]) {
+ clone[response.groupId] = [];
+ }
clone[response.groupId]?.unshift(response.newMessage);
return clone;
});
@@ -88,7 +92,9 @@ export default function Conversations() {
const chat = useLiveChat(handlerNewMessage);
function setActiveGroup(group: GetAllMyGroupsQuery["getAllMyGroups"]["groups"][number]) {
- setIndexGroup(groups.findIndex((g) => Number(g.id) === Number(group.id)));
+ const groupIndex = groups.findIndex((g) => Number(g.id) === Number(group.id));
+ setIndexGroup(groupIndex);
+ setSelectedGroupId(Number(group.id));
}
// Handle mobile view changes - hide/show bottom navigation
@@ -118,10 +124,24 @@ export default function Conversations() {
if (groupData?.getAllMyGroups.groups.length === 0) {
setIndexGroup(-1);
+ setSelectedGroupId(null);
return;
}
- setGroups(groupData?.getAllMyGroups.groups || []);
+ const newGroups = groupData?.getAllMyGroups.groups || [];
+ setGroups(newGroups);
+
+ // Initialize messages map with empty arrays for all groups
+ setMessages((prev) => {
+ const updated = { ...prev };
+ newGroups.forEach((group) => {
+ const groupId = Number(group.id);
+ if (!updated[groupId]) {
+ updated[groupId] = [];
+ }
+ });
+ return updated;
+ });
const existing =
indexGroups !== -1
@@ -147,14 +167,6 @@ export default function Conversations() {
});
}, [messageData]);
- useEffect(() => {
- if (indexGroups === -1) {
- setIndexGroup(-1);
- return;
- }
- setIndexGroup(indexGroups);
- }, [indexGroups]);
-
const addMessage = (groupId: number, message: Message[]) => {
setMessages((prev) => {
const clone = structuredClone(prev);
@@ -292,9 +304,10 @@ export default function Conversations() {
{/* Create Group Modal */}
{isCreateGroupModalOpen && (
setIsCreateGroupModalOpen(false)} isOpen={isCreateGroupModalOpen}>
- setIsCreateGroupModalOpen(false)}
onCancel={() => setIsCreateGroupModalOpen(false)}
+ groupId={selectedGroupId || undefined}
/>
)}
@@ -485,10 +498,11 @@ export default function Conversations() {
{indexGroups !== -1 &&
groups.length > 0 &&
+ indexGroups < groups.length &&
messages[Number(groups[indexGroups].id)] !== undefined && (
{
return (
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx
index 0212013..c1740ab 100644
--- a/frontend/src/pages/RegisterPage.tsx
+++ b/frontend/src/pages/RegisterPage.tsx
@@ -1,6 +1,6 @@
-import RegisterForm from "../components/auth/RegisterForm";
+import RegisterForm from "../components/forms/auth/RegisterForm";
import InfoHome from "../components/InfoHome";
-import "../components/auth/auth.css";
+import "../components/forms/auth/auth.css";
const RegisterPage = () => {
return (
diff --git a/frontend/src/zustand/myProfileStore.ts b/frontend/src/zustand/myProfileStore.ts
index f6f8ca2..0b2663a 100644
--- a/frontend/src/zustand/myProfileStore.ts
+++ b/frontend/src/zustand/myProfileStore.ts
@@ -3,22 +3,22 @@ import { defaultPictureProfile } from "../data/pictureDefault";
import type { GetMyProfileQuery } from "../graphql/generated/graphql-types";
type State = {
- userProfile: null | UserProfil;
- setUserProfile: (user: null | UserProfil) => void;
+ userProfile: null | UserProfile;
+ setUserProfile: (user: null | UserProfile) => void;
clearUserProfile: () => void;
};
-type UserProfil = GetMyProfileQuery["getMyProfile"];
+export type UserProfile = GetMyProfileQuery["getMyProfile"];
export const useMyProfileStore = create((set) => ({
userProfile: null,
- setUserProfile: (newUser: UserProfil | null) =>
+ setUserProfile: (newUser: UserProfile | null) =>
set(() => ({
userProfile: newUser
? ({
...newUser,
image_url: newUser.image_url ? `/service/picture/${newUser.image_url}` : defaultPictureProfile,
- } as UserProfil)
+ } as UserProfile)
: null,
})),
clearUserProfile: () => set({ userProfile: null }),