mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-13 11:18:46 -06:00
chore: 7114 improve ux in team settings (#7237)
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
committed by
GitHub
parent
8ab8adc3d0
commit
fb0ef2fa82
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1105,8 +1105,6 @@
|
||||
"please_fill_all_workspace_fields": "新しいワークスペースを追加するには、すべてのフィールドを入力してください。",
|
||||
"read": "読み取り",
|
||||
"read_write": "読み書き",
|
||||
"select_member": "メンバーを選択",
|
||||
"select_workspace": "ワークスペースを選択",
|
||||
"team_admin": "チーム管理者",
|
||||
"team_created_successfully": "チームを正常に作成しました。",
|
||||
"team_deleted_successfully": "チームを正常に削除しました。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1105,8 +1105,6 @@
|
||||
"please_fill_all_workspace_fields": "Пожалуйста, заполните все поля для добавления нового рабочего пространства.",
|
||||
"read": "Чтение",
|
||||
"read_write": "Чтение и запись",
|
||||
"select_member": "Выберите участника",
|
||||
"select_workspace": "Выберите рабочее пространство",
|
||||
"team_admin": "Администратор команды",
|
||||
"team_created_successfully": "Команда успешно создана.",
|
||||
"team_deleted_successfully": "Команда успешно удалена.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1105,8 +1105,6 @@
|
||||
"please_fill_all_workspace_fields": "请填写所有字段以添加新工作区。",
|
||||
"read": "阅读",
|
||||
"read_write": "读 & 写",
|
||||
"select_member": "选择成员",
|
||||
"select_workspace": "选择工作区",
|
||||
"team_admin": "团队管理员",
|
||||
"team_created_successfully": "团队 创建 成功",
|
||||
"team_deleted_successfully": "团队 删除 成功",
|
||||
|
||||
@@ -1105,8 +1105,6 @@
|
||||
"please_fill_all_workspace_fields": "請填寫所有欄位以新增工作區。",
|
||||
"read": "讀取",
|
||||
"read_write": "讀取和寫入",
|
||||
"select_member": "選擇成員",
|
||||
"select_workspace": "選擇工作區",
|
||||
"team_admin": "團隊管理員",
|
||||
"team_created_successfully": "團隊已成功建立。",
|
||||
"team_deleted_successfully": "團隊已成功刪除。",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user