chore: 7114 improve ux in team settings (#7237)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Dhruwang Jariwala
2026-02-12 11:48:05 +05:30
committed by GitHub
parent 8ab8adc3d0
commit fb0ef2fa82
24 changed files with 395 additions and 275 deletions

View File

@@ -1038,8 +1038,6 @@ checksums:
environments/settings/teams/please_fill_all_workspace_fields: 190fc5d3c63cc5ec49d77f587e619ed8
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_workspace: 0ad989c23616c6a04faf23d9e63ed3f3
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
environments/settings/teams/team_created_successfully: 5b0cc007e18053508fdebc9545cc2c05
environments/settings/teams/team_deleted_successfully: d0729ad8d982cc5d542f89291bf57c50

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Bitte füllen Sie alle Felder aus, um einen neuen Workspace hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"select_member": "Mitglied auswählen",
"select_workspace": "Workspace auswählen",
"team_admin": "Team-Admin",
"team_created_successfully": "Team erfolgreich erstellt.",
"team_deleted_successfully": "Team erfolgreich gelöscht.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Please fill all the fields to add a new workspace.",
"read": "Read",
"read_write": "Read & Write",
"select_member": "Select member",
"select_workspace": "Select workspace",
"team_admin": "Team Admin",
"team_created_successfully": "Team created successfully",
"team_deleted_successfully": "Team deleted successfully",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Por favor, rellena todos los campos para añadir un proyecto nuevo.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"select_member": "Seleccionar miembro",
"select_workspace": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
"team_created_successfully": "Equipo creado con éxito.",
"team_deleted_successfully": "Equipo eliminado correctamente.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Veuillez remplir tous les champs pour ajouter un nouvel espace de travail.",
"read": "Lire",
"read_write": "Lire et Écrire",
"select_member": "Sélectionner membre",
"select_workspace": "Sélectionner un espace de travail",
"team_admin": "Administrateur d'équipe",
"team_created_successfully": "Équipe créée avec succès.",
"team_deleted_successfully": "Équipe supprimée avec succès.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Töltse ki az összes mezőt egy új munkaterület hozzáadásához.",
"read": "Olvasás",
"read_write": "Olvasás és írás",
"select_member": "Tag kiválasztása",
"select_workspace": "Munkaterület kiválasztása",
"team_admin": "Csapatadminisztrátor",
"team_created_successfully": "A csapat sikeresen létrehozva",
"team_deleted_successfully": "A csapat sikeresen törölve",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "新しいワークスペースを追加するには、すべてのフィールドを入力してください。",
"read": "読み取り",
"read_write": "読み書き",
"select_member": "メンバーを選択",
"select_workspace": "ワークスペースを選択",
"team_admin": "チーム管理者",
"team_created_successfully": "チームを正常に作成しました。",
"team_deleted_successfully": "チームを正常に削除しました。",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Vul alle velden in om een nieuwe werkruimte toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"select_member": "Selecteer lid",
"select_workspace": "Selecteer werkruimte",
"team_admin": "Teambeheerder",
"team_created_successfully": "Team succesvol aangemaakt.",
"team_deleted_successfully": "Team succesvol verwijderd.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da equipe",
"team_created_successfully": "Equipe criada com sucesso.",
"team_deleted_successfully": "Equipe excluída com sucesso.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Preencha todos os campos para adicionar um novo espaço de trabalho.",
"read": "Ler",
"read_write": "Ler e Escrever",
"select_member": "Selecionar membro",
"select_workspace": "Selecionar espaço de trabalho",
"team_admin": "Administrador da Equipa",
"team_created_successfully": "Equipa criada com sucesso.",
"team_deleted_successfully": "Equipa eliminada com sucesso.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou spațiu de lucru.",
"read": "Citește",
"read_write": "Citire & Scriere",
"select_member": "Selectează membrul",
"select_workspace": "Selectați spațiul de lucru",
"team_admin": "Administrator Echipe",
"team_created_successfully": "Echipă creată cu succes",
"team_deleted_successfully": "Echipă ștearsă cu succes.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Пожалуйста, заполните все поля для добавления нового рабочего пространства.",
"read": "Чтение",
"read_write": "Чтение и запись",
"select_member": "Выберите участника",
"select_workspace": "Выберите рабочее пространство",
"team_admin": "Администратор команды",
"team_created_successfully": "Команда успешно создана.",
"team_deleted_successfully": "Команда успешно удалена.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "Fyll i alla fält för att lägga till en ny arbetsyta.",
"read": "Läs",
"read_write": "Läs och skriv",
"select_member": "Välj medlem",
"select_workspace": "Välj arbetsyta",
"team_admin": "Teamadministratör",
"team_created_successfully": "Team skapat.",
"team_deleted_successfully": "Team borttaget.",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "请填写所有字段以添加新工作区。",
"read": "阅读",
"read_write": "读 & 写",
"select_member": "选择成员",
"select_workspace": "选择工作区",
"team_admin": "团队管理员",
"team_created_successfully": "团队 创建 成功",
"team_deleted_successfully": "团队 删除 成功",

View File

@@ -1105,8 +1105,6 @@
"please_fill_all_workspace_fields": "請填寫所有欄位以新增工作區。",
"read": "讀取",
"read_write": "讀取和寫入",
"select_member": "選擇成員",
"select_workspace": "選擇工作區",
"team_admin": "團隊管理員",
"team_created_successfully": "團隊已成功建立。",
"team_deleted_successfully": "團隊已成功刪除。",

View File

@@ -0,0 +1,137 @@
"use client";
import { XIcon } from "lucide-react";
import { Control } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
TOrganizationMember,
TTeamRole,
TTeamSettingsFormSchema,
ZTeamRole,
} from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
export interface MemberRowProps {
index: number;
member: { userId: string; role: TTeamRole };
memberOpts: { value: string; label: string }[];
control: Control<TTeamSettingsFormSchema>;
orgMembers: TOrganizationMember[];
watchMembers: { userId: string; role: TTeamRole }[];
initialMemberIds: Set<string>;
isOwnerOrManager: boolean;
isTeamAdminMember: boolean;
isTeamContributorMember: boolean;
currentUserId: string;
onMemberSelectionChange: (index: number, userId: string) => void;
onRemoveMember: (index: number) => void;
memberCount: number;
}
export function MemberRow(props: Readonly<MemberRowProps>) {
const {
index,
member,
memberOpts,
control,
orgMembers,
watchMembers,
initialMemberIds,
isOwnerOrManager,
isTeamAdminMember,
isTeamContributorMember,
currentUserId,
onMemberSelectionChange,
onRemoveMember,
memberCount,
} = props;
const { t } = useTranslation();
const chosenMember = orgMembers.find((m) => m.id === watchMembers[index]?.userId);
const canEditWhenNoMember = isOwnerOrManager || isTeamAdminMember;
const isRoleSelectDisabled =
chosenMember === undefined
? !canEditWhenNoMember
: chosenMember.role === "owner" ||
chosenMember.role === "manager" ||
isTeamContributorMember ||
chosenMember.id === currentUserId;
return (
<div className="flex gap-2.5">
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => {
const isExistingMember = member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled = isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<div className={isSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`member-select-${index}`}
options={memberOpts}
value={field.value || null}
onChangeValue={(val) => {
const value = typeof val === "string" ? val : "";
field.onChange(value);
onMemberSelectionChange(index, value);
}}
showSearch
searchPlaceholder={t("common.search")}
comboboxClasses="flex-1 min-w-0 w-full"
emptyDropdownText={t("environments.surveys.edit.no_option_found")}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`members.${index}.role`}
render={({ field }) => {
const roleOptions = [
{ value: ZTeamRole.enum.admin, label: t("environments.settings.teams.team_admin") },
{
value: ZTeamRole.enum.contributor,
label: t("environments.settings.teams.contributor"),
},
];
return (
<FormItem className="flex-1">
<div className={isRoleSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`member-role-select-${index}`}
options={roleOptions}
value={field.value}
onChangeValue={(val) => field.onChange(val)}
showSearch={false}
comboboxClasses="flex-1 min-w-0 w-full"
/>
</div>
</FormItem>
);
}}
/>
{memberCount > 1 && (
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={!isOwnerOrManager && (!isTeamAdminMember || member.userId === currentUserId)}
onClick={() => onRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -13,6 +13,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
import { MemberRow } from "@/modules/ee/teams/team-list/components/team-settings/member-row";
import { WorkspaceRow } from "@/modules/ee/teams/team-list/components/team-settings/workspace-row";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {
TOrganizationMember,
@@ -36,13 +38,6 @@ import {
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Muted } from "@/modules/ui/components/typography";
@@ -187,14 +182,16 @@ export const TeamSettingsModal = ({
const currentMemberId = watchMembers[index]?.userId;
return orgMembers
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
.map((om) => ({ label: om?.name, value: om?.id }));
.map((om) => ({ label: om?.name ?? "", value: om?.id }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
};
const getProjectOptionsForIndex = (index: number) => {
const currentProjectId = watchProjects[index]?.projectId;
return orgProjects
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
.map((op) => ({ label: op?.name, value: op?.id }));
.map((op) => ({ label: op?.name ?? "", value: op?.id }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
};
const handleMemberSelectionChange = (index: number, userId: string) => {
@@ -262,110 +259,25 @@ export const TeamSettingsModal = ({
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="space-y-2 overflow-y-auto">
{watchMembers.map((member, index) => {
const memberOpts = getMemberOptionsForIndex(index);
return (
<div key={`member-${member.userId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`members.${index}.userId`}
render={({ field, fieldState: { error } }) => {
// Disable user select for existing members (can only remove or change role)
const isExistingMember =
member.userId && initialMemberIds.has(member.userId);
const isSelectDisabled =
isExistingMember || (!isOwnerOrManager && !isTeamAdminMember);
return (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
}}
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`members.${index}.role`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={member.role}
disabled={(() => {
const chosenMember = orgMembers.find(
(m) => m.id === watchMembers[index]?.userId
);
if (!chosenMember) return !isOwnerOrManager && !isTeamAdminMember;
return (
chosenMember.role === "owner" ||
chosenMember.role === "manager" ||
isTeamContributorMember ||
chosenMember.id === currentUserId
);
})()}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamRole.enum.admin}>
{t("environments.settings.teams.team_admin")}
</SelectItem>
<SelectItem value={ZTeamRole.enum.contributor}>
{t("environments.settings.teams.contributor")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Delete Button for Member */}
{watchMembers.length > 1 && (
<TooltipRenderer tooltipContent={t("common.remove_from_team")}>
<Button
size="icon"
type="button"
variant="destructive"
className="shrink-0"
disabled={
!isOwnerOrManager &&
(!isTeamAdminMember || member.userId === currentUserId)
}
onClick={() => handleRemoveMember(index)}>
<XIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
)}
</div>
);
})}
{watchMembers.map((member, index) => (
<MemberRow
key={`member-${member.userId}-${index}`}
index={index}
member={member}
memberOpts={getMemberOptionsForIndex(index)}
control={control}
orgMembers={orgMembers}
watchMembers={watchMembers}
initialMemberIds={initialMemberIds}
isOwnerOrManager={isOwnerOrManager}
isTeamAdminMember={isTeamAdminMember}
isTeamContributorMember={isTeamContributorMember}
currentUserId={currentUserId}
onMemberSelectionChange={handleMemberSelectionChange}
onRemoveMember={handleRemoveMember}
memberCount={watchMembers.length}
/>
))}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>
@@ -411,90 +323,19 @@ export const TeamSettingsModal = ({
render={({ fieldState: { error } }) => (
<FormItem className="flex-1">
<div className="space-y-2">
{watchProjects.map((project, index) => {
const projectOpts = getProjectOptionsForIndex(index);
return (
<div key={`project-${project.projectId}-${index}`} className="flex gap-2.5">
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => {
// Disable project select for existing projects (can only remove or change permission)
const isExistingProject =
project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_workspace")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`projects.${index}.permission`}
render={({ field }) => (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.permission}
disabled={!isOwnerOrManager}>
<SelectTrigger>
<SelectValue placeholder="Select project role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ZTeamPermission.enum.read}>
{t("environments.settings.teams.read")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.readWrite}>
{t("environments.settings.teams.read_write")}
</SelectItem>
<SelectItem value={ZTeamPermission.enum.manage}>
{t("environments.settings.teams.manage")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{watchProjects.length > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={!isOwnerOrManager}
onClick={() => handleRemoveProject(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
{watchProjects.map((project, index) => (
<WorkspaceRow
key={`workspace-${project.projectId}-${index}`}
index={index}
project={project}
projectOpts={getProjectOptionsForIndex(index)}
control={control}
initialProjectIds={initialProjectIds}
isOwnerOrManager={isOwnerOrManager}
onRemoveProject={handleRemoveProject}
projectCount={watchProjects.length}
/>
))}
</div>
{error?.root?.message && (
<FormError className="text-left">{error.root.message}</FormError>

View File

@@ -0,0 +1,115 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { Control } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
export interface WorkspaceRowProps {
index: number;
project: { projectId: string; permission: string };
projectOpts: { value: string; label: string }[];
control: Control<TTeamSettingsFormSchema>;
initialProjectIds: Set<string>;
isOwnerOrManager: boolean;
onRemoveProject: (index: number) => void;
projectCount: number;
}
export function WorkspaceRow(props: Readonly<WorkspaceRowProps>) {
const {
index,
project,
projectOpts,
control,
initialProjectIds,
isOwnerOrManager,
onRemoveProject,
projectCount,
} = props;
const { t } = useTranslation();
return (
<div className="flex gap-2.5">
<FormField
control={control}
name={`projects.${index}.projectId`}
render={({ field, fieldState: { error } }) => {
const isExistingProject = project.projectId && initialProjectIds.has(project.projectId);
const isSelectDisabled = isExistingProject || !isOwnerOrManager;
return (
<FormItem className="flex-1">
<div className={isSelectDisabled ? "pointer-events-none opacity-50" : undefined}>
<InputCombobox
id={`project-select-${index}`}
options={projectOpts}
value={field.value || null}
onChangeValue={(val) => {
const value = typeof val === "string" ? val : "";
field.onChange(value);
}}
showSearch
searchPlaceholder={t("common.search")}
comboboxClasses="flex-1 min-w-0 w-full"
emptyDropdownText={t("environments.surveys.edit.no_option_found")}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
);
}}
/>
<FormField
control={control}
name={`projects.${index}.permission`}
render={({ field }) => {
const permissionOptions = [
{
value: ZTeamPermission.enum.read,
label: t("environments.settings.teams.read"),
},
{
value: ZTeamPermission.enum.readWrite,
label: t("environments.settings.teams.read_write"),
},
{
value: ZTeamPermission.enum.manage,
label: t("environments.settings.teams.manage"),
},
];
return (
<FormItem className="flex-1">
<div className={isOwnerOrManager ? undefined : "pointer-events-none opacity-50"}>
<InputCombobox
id={`project-permission-select-${index}`}
options={permissionOptions}
value={field.value}
onChangeValue={(val) => field.onChange(val)}
showSearch={false}
comboboxClasses="flex-1 min-w-0 w-full"
/>
</div>
</FormItem>
);
}}
/>
{projectCount > 1 && (
<Button
size="icon"
type="button"
variant="secondary"
className="shrink-0"
disabled={!isOwnerOrManager}
onClick={() => onRemoveProject(index)}>
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@@ -24,6 +24,9 @@ vi.mock("@formbricks/database", () => ({
update: vi.fn(),
delete: vi.fn(),
},
teamUser: {
findMany: vi.fn(),
},
membership: { findUnique: vi.fn(), count: vi.fn() },
project: { count: vi.fn() },
environment: { findMany: vi.fn() },
@@ -31,13 +34,16 @@ vi.mock("@formbricks/database", () => ({
}));
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
{ id: "t1", name: "Team 1", organizationId: "org1", createdAt: new Date(), updatedAt: new Date() },
{ id: "t2", name: "Team 2", organizationId: "org1", createdAt: new Date(), updatedAt: new Date() },
];
const mockUserTeams = [
{
id: "t1",
name: "Team 1",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
teamUsers: [{ role: "admin" }],
_count: { teamUsers: 2 },
},
@@ -46,14 +52,24 @@ const mockOtherTeams = [
{
id: "t2",
name: "Team 2",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
_count: { teamUsers: 3 },
},
];
const mockMembership = { role: "admin" };
const mockMembership = {
userId: "u1",
accepted: true,
role: "owner" as const,
organizationId: "org1",
};
const mockTeamDetails = {
id: "t1",
name: "Team 1",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
teamUsers: [
{ userId: "u1", role: "admin", user: { name: "User 1" } },
{ userId: "u2", role: "member", user: { name: "User 2" } },
@@ -153,7 +169,13 @@ describe("createTeam", () => {
expect(result).toBe("t1");
});
test("throws InvalidInputError if team exists", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({ id: "t1" });
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(createTeam("org1", "Team 1")).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError if name too short", async () => {
@@ -253,7 +275,16 @@ describe("updateTeamDetails", () => {
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.environment.findMany).mockResolvedValueOnce([{ id: "env1" }]);
vi.mocked(prisma.environment.findMany).mockResolvedValueOnce([
{
id: "env1",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
appSetupCompleted: false,
},
]);
const result = await updateTeamDetails("t1", data);
expect(result).toBe(true);
});
@@ -284,9 +315,11 @@ describe("updateTeamDetails", () => {
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
createdAt: new Date(),
updatedAt: new Date(),
teamUsers: [],
projectTeams: [],
} as any);
vi.mocked(prisma.membership.count).mockResolvedValueOnce(0);
await expect(updateTeamDetails("t1", data)).rejects.toThrow();
});
@@ -302,9 +335,11 @@ describe("updateTeamDetails", () => {
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
createdAt: new Date(),
updatedAt: new Date(),
teamUsers: [],
projectTeams: [],
} as any);
vi.mocked(prisma.membership.count).mockResolvedValueOnce(1);
vi.mocked(prisma.project.count).mockResolvedValueOnce(0);
await expect(

View File

@@ -28,18 +28,16 @@ export const EditMemberships = async ({
return (
<div>
<div className="rounded-lg border border-slate-200">
<div className="flex h-12 w-full max-w-full items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
<div className="grid h-12 w-full max-w-full grid-cols-12 items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 overflow-hidden">{t("common.full_name")}</div>
<div className="col-span-3 overflow-hidden">{t("common.email")}</div>
{isAccessControlAllowed && (
<div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>
)}
{isAccessControlAllowed && <div className="col-span-2 whitespace-nowrap">{t("common.role")}</div>}
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
<div className="col-span-2 whitespace-nowrap">{t("common.status")}</div>
{!isUserManagementDisabledFromUi && (
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
<div className="col-span-3 whitespace-nowrap text-center">{t("common.actions")}</div>
)}
</div>

View File

@@ -33,7 +33,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState("");
const handleDeleteMember = async () => {
@@ -124,7 +123,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
};
return (
<div className="flex gap-2">
<div className="flex justify-end gap-2">
<TooltipRenderer tooltipContent={t("common.delete")} shouldRender={!!showDeleteButton}>
<Button
variant="destructive"

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { TMember, TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utils";
@@ -48,7 +48,7 @@ export const MembersInfo = ({
) : (
<TooltipRenderer
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
date: getFormattedDateTimeString(member.expiresAt),
date: formatDateWithOrdinal(member.expiresAt),
})}`}>
<Badge type="warning" text="Pending" size="tiny" />
</TooltipRenderer>
@@ -96,17 +96,17 @@ export const MembersInfo = ({
{allMembers.map((member) => (
<div
id="singleMemberInfo"
className="flex w-full max-w-full items-center gap-x-4 text-left text-sm text-slate-900"
className="grid w-full max-w-full grid-cols-12 items-center gap-x-4 text-left text-sm text-slate-900"
key={member.email}>
<div className="ph-no-capture w-1/2 overflow-hidden">
<div className="ph-no-capture col-span-2 overflow-hidden">
<p className="w-full truncate">{member.name}</p>
</div>
<div className="ph-no-capture w-1/2 overflow-hidden">
<div className="ph-no-capture col-span-3 overflow-hidden">
<p className="w-full truncate"> {member.email}</p>
</div>
{isAccessControlAllowed && allMembers?.length > 0 && (
<div className="ph-no-capture min-w-[100px]">
<div className="ph-no-capture col-span-2">
<EditMembershipRole
currentUserRole={currentUserRole}
memberRole={member.role}
@@ -121,15 +121,17 @@ export const MembersInfo = ({
/>
</div>
)}
<div className="min-w-[80px]">{getMembershipBadge(member)}</div>
<div className="col-span-2 flex items-center">{getMembershipBadge(member)}</div>
{!isUserManagementDisabledFromUi && (
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
/>
<div className="col-span-3">
<MemberActions
organization={organization}
member={isInvitee(member) ? undefined : member}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
/>
</div>
)}
</div>
))}

View File

@@ -21,7 +21,7 @@ interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
}
export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
props: MultiSelectProps<T, K>
props: Readonly<MultiSelectProps<T, K>>
) {
const { options, value, onChange, disabled = false, placeholder = "Select options..." } = props;
@@ -38,6 +38,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
const [inputValue, setInputValue] = React.useState("");
const [position, setPosition] = React.useState<{ top: number; left: number; width: number } | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
// Track if changes are user-initiated (not from value prop)
const isUserInitiatedRef = React.useRef(false);
@@ -126,18 +127,39 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
// Calculate position for dropdown when opening
React.useEffect(() => {
if (open && containerRef.current) {
if (!open || !containerRef.current) {
setPosition(null);
return;
}
const updatePosition = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY + 6,
left: rect.left + window.scrollX,
top: rect.bottom,
left: rect.left,
width: rect.width,
});
} else {
setPosition(null);
}
};
updatePosition();
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
React.useEffect(() => {
if (!containerRef.current) return;
// In modal contexts (Radix dialog), rendering inside body can make dropdown non-interactive.
const dialogContent = containerRef.current.closest("[role='dialog']");
setPortalContainer((dialogContent as HTMLElement) ?? document.body);
}, []);
return (
<Command
onKeyDown={handleKeyDown}
@@ -184,9 +206,10 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
!disabled &&
position &&
globalThis.window !== undefined &&
portalContainer &&
createPortal(
<div
className="absolute z-[100]"
className="fixed z-[100]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
@@ -216,7 +239,7 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
</div>
</CommandList>
</div>,
document.body
portalContainer
)}
</Command>
);

View File

@@ -146,11 +146,11 @@ test.describe("Create, update and delete team", async () => {
await page.getByPlaceholder("Team name").fill("E2E Updated");
await page.locator("button").filter({ hasText: "Select member" }).first().click();
await page.locator("#member-0-option").click();
await page.locator("#member-select-0").click();
await page.locator('[data-slot="command-item"]').first().click();
await page.locator("button").filter({ hasText: "Select workspace" }).first().click();
await page.locator("#project-0-option").click();
await page.locator("#project-select-0").click();
await page.locator('[data-slot="command-item"]').first().click();
await page.getByRole("button", { name: "Save" }).click();