refactor(frontend): update role dropdown styles in the team page (org support) (#11906)

Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Hiep Le
2025-12-06 15:57:41 +07:00
committed by GitHub
parent 10fcbbcafa
commit 1678993235
5 changed files with 194 additions and 63 deletions

View File

@@ -132,12 +132,14 @@ describe("Manage Team Route", () => {
const memberListItems = await screen.findAllByTestId("member-item");
const userRoleMember = memberListItems[2]; // third member is "user"
let userCombobox = within(userRoleMember).getByText(/user/i);
let userCombobox = within(userRoleMember).getByText(/^User$/i);
expect(userCombobox).toBeInTheDocument();
await userEvent.click(userCombobox);
const dropdown = within(userRoleMember).getByTestId("role-dropdown");
const adminOption = within(dropdown).getByText(/admin/i);
const dropdown = within(userRoleMember).getByTestId(
"organization-member-role-context-menu",
);
const adminOption = within(dropdown).getByTestId("admin-option");
expect(adminOption).toBeInTheDocument();
await userEvent.click(adminOption);
@@ -147,18 +149,22 @@ describe("Manage Team Route", () => {
role: "admin",
});
expect(
within(userRoleMember).queryByTestId("role-dropdown"),
within(userRoleMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
// Verify the role has been updated in the UI
userCombobox = within(userRoleMember).getByText(/admin/i);
userCombobox = within(userRoleMember).getByText(/^Admin$/i);
expect(userCombobox).toBeInTheDocument();
// revert the role back to user
await userEvent.click(userCombobox);
const userOption = within(
within(userRoleMember).getByTestId("role-dropdown"),
).getByText(/user/i);
within(userRoleMember).getByTestId(
"organization-member-role-context-menu",
),
).getByTestId("user-option");
expect(userOption).toBeInTheDocument();
await userEvent.click(userOption);
@@ -169,7 +175,7 @@ describe("Manage Team Route", () => {
});
// Verify the role has been reverted in the UI
userCombobox = within(userRoleMember).getByText(/user/i);
userCombobox = within(userRoleMember).getByText(/^User$/i);
expect(userCombobox).toBeInTheDocument();
});
@@ -191,13 +197,15 @@ describe("Manage Team Route", () => {
const memberListItems = await screen.findAllByTestId("member-item");
const ownerMember = memberListItems[0]; // first member is "owner
const userCombobox = within(ownerMember).getByText(/owner/i);
const userCombobox = within(ownerMember).getByText(/^Owner$/i);
expect(userCombobox).toBeInTheDocument();
await userEvent.click(userCombobox);
// Verify that the dropdown does not open for owner
expect(
within(ownerMember).queryByTestId("role-dropdown"),
within(ownerMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
});
@@ -211,12 +219,14 @@ describe("Manage Team Route", () => {
const adminMember = memberListItems[1]; // first member is "admin"
expect(adminMember).toBeDefined();
const roleText = within(adminMember).getByText(/admin/i);
const roleText = within(adminMember).getByText(/^Admin$/i);
await userEvent.click(roleText);
// Verify that the dropdown does not open for the other admin
expect(
within(adminMember).queryByTestId("role-dropdown"),
within(adminMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
});
@@ -238,12 +248,14 @@ describe("Manage Team Route", () => {
const memberListItems = await screen.findAllByTestId("member-item");
const currentUserMember = memberListItems[0]; // First member is Alice (id: "1")
const roleText = within(currentUserMember).getByText(/owner/i);
const roleText = within(currentUserMember).getByText(/^Owner$/i);
await userEvent.click(roleText);
// Verify that the dropdown does not open for the current user's own role
expect(
within(currentUserMember).queryByTestId("role-dropdown"),
within(currentUserMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
});
@@ -263,18 +275,17 @@ describe("Manage Team Route", () => {
const userEmail = within(userRoleMember).getByText("charlie@acme.org");
expect(userEmail).toBeInTheDocument();
const userCombobox = within(userRoleMember).getByText(/user/i);
const userCombobox = within(userRoleMember).getByText(/^User$/i);
await userEvent.click(userCombobox);
const dropdown = within(userRoleMember).getByTestId("role-dropdown");
const dropdown = within(userRoleMember).getByTestId(
"organization-member-role-context-menu",
);
// Check that remove option exists
const removeOption = within(dropdown).getByText(/remove/i);
const removeOption = within(dropdown).getByTestId("remove-option");
expect(removeOption).toBeInTheDocument();
// Check that remove option has danger styling (red color)
expect(removeOption).toHaveClass("text-red-500"); // or whatever danger class is used
await userEvent.click(removeOption);
expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({
@@ -370,9 +381,11 @@ describe("Manage Team Route", () => {
expect(invitedBadge).toBeInTheDocument();
// should not have a role combobox
await userEvent.click(within(invitedMember).getByText(/user/i));
await userEvent.click(within(invitedMember).getByText(/^User$/i));
expect(
within(invitedMember).queryByTestId("role-dropdown"),
within(invitedMember).queryByTestId(
"organization-member-role-context-menu",
),
).not.toBeInTheDocument();
});
});

View File

@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
import { OrganizationMember, OrganizationUserRole } from "#/types/org";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { OrganizationMemberRoleContextMenu } from "./organization-member-role-context-menu";
interface OrganizationMemberListItemProps {
email: OrganizationMember["email"];
@@ -24,16 +25,19 @@ export function OrganizationMemberListItem({
onRemove,
}: OrganizationMemberListItemProps) {
const { t } = useTranslation();
const [roleSelectionOpen, setRoleSelectionOpen] = React.useState(false);
const handleRoleSelectionClick = (newRole: OrganizationUserRole) => {
onRoleChange(newRole);
setRoleSelectionOpen(false);
};
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const roleSelectionIsPermitted =
status !== "invited" && hasPermissionToChangeRole;
const handleRoleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
if (roleSelectionIsPermitted) {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen(true);
}
};
return (
<div className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
@@ -51,42 +55,25 @@ export function OrganizationMemberListItem({
</span>
)}
</div>
<span
onClick={() => setRoleSelectionOpen(true)}
className={cn(
"text-xs text-gray-400 uppercase flex items-center gap-1",
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
<div className="relative">
<span
onClick={handleRoleClick}
className={cn(
"text-xs text-gray-400 flex items-center gap-1 capitalize",
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
)}
>
{role}
{hasPermissionToChangeRole && <ChevronDown size={14} />}
</span>
{roleSelectionIsPermitted && contextMenuOpen && (
<OrganizationMemberRoleContextMenu
onClose={() => setContextMenuOpen(false)}
onRoleChange={onRoleChange}
onRemove={onRemove}
/>
)}
>
{role}
{hasPermissionToChangeRole && <ChevronDown size={14} />}
</span>
{roleSelectionIsPermitted && roleSelectionOpen && (
<ul data-testid="role-dropdown">
<li>
<span onClick={() => handleRoleSelectionClick("admin")}>
{t(I18nKey.ORG$ROLE_ADMIN)}
</span>
</li>
<li>
<span onClick={() => handleRoleSelectionClick("user")}>
{t(I18nKey.ORG$ROLE_USER)}
</span>
</li>
<li>
<span
className="text-red-500 cursor-pointer"
onClick={() => {
onRemove?.();
setRoleSelectionOpen(false);
}}
>
{t(I18nKey.ORG$REMOVE)}
</span>
</li>
</ul>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuIconText } from "#/ui/context-menu-icon-text";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { OrganizationUserRole } from "#/types/org";
import { cn } from "#/utils/utils";
import UserIcon from "#/icons/user.svg?react";
import DeleteIcon from "#/icons/u-delete.svg?react";
import AdminIcon from "#/icons/admin.svg?react";
const contextMenuListItemClassName = cn(
"cursor-pointer p-0 h-auto hover:bg-transparent",
);
interface OrganizationMemberRoleContextMenuProps {
onClose: () => void;
onRoleChange: (role: OrganizationUserRole) => void;
onRemove?: () => void;
}
export function OrganizationMemberRoleContextMenu({
onClose,
onRoleChange,
onRemove,
}: OrganizationMemberRoleContextMenuProps) {
const { t } = useTranslation();
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
const handleAdminClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRoleChange("admin");
onClose();
};
const handleUserClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRoleChange("user");
onClose();
};
const handleRemoveClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRemove?.();
onClose();
};
return (
<ContextMenu
ref={menuRef}
testId="organization-member-role-context-menu"
position="bottom"
alignment="right"
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
>
<ContextMenuListItem
testId="admin-option"
onClick={handleAdminClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={
<AdminIcon width={16} height={16} className="text-white pl-[2px]" />
}
text={t(I18nKey.ORG$ROLE_ADMIN)}
className="capitalize"
/>
</ContextMenuListItem>
<ContextMenuListItem
testId="user-option"
onClick={handleUserClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={<UserIcon width={16} height={16} className="text-white" />}
text={t(I18nKey.ORG$ROLE_USER)}
className="capitalize"
/>
</ContextMenuListItem>
<ContextMenuListItem
testId="remove-option"
onClick={handleRemoveClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
icon={<DeleteIcon width={16} height={16} className="text-red-500" />}
text={t(I18nKey.ORG$REMOVE)}
className="text-red-500 capitalize"
/>
</ContextMenuListItem>
</ContextMenu>
);
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 13 12" fill="none">
<path d="M5.93266 9.60059H2.99809C2.0034 9.60059 1.19828 10.4057 1.19828 11.4004C1.19815 11.7727 0.860332 12.0653 0.473671 11.9873C0.187333 11.9299 -0.00314397 11.6658 3.8147e-05 11.373C0.0144066 9.72907 1.35287 8.40039 3.00004 8.40039H4.34476L5.93266 9.60059ZM8.99027 6.50586C9.0747 6.2466 9.44203 6.24656 9.52641 6.50586H9.52445L10.0977 8.26758H11.9502C12.2224 8.26758 12.3365 8.6166 12.1153 8.77734L10.6162 9.86523L11.1895 11.627C11.2738 11.8863 10.9765 12.103 10.7569 11.9424L9.25785 10.8535L7.75785 11.9424C7.53665 12.1028 7.24091 11.8863 7.32523 11.627L7.89848 9.86523L6.39945 8.77734C6.17827 8.61663 6.29243 8.26768 6.56449 8.26758H8.41703L8.99027 6.50586ZM5.39945 0C7.38708 0 8.99979 1.61204 9.00004 3.59961C9.00004 5.58739 7.38723 7.2002 5.39945 7.2002C3.41175 7.20011 1.79984 5.58734 1.79984 3.59961C1.80009 1.61209 3.4119 8.31798e-05 5.39945 0ZM5.39945 1.2002C4.07396 1.20028 3.00029 2.27416 3.00004 3.59961C3.00004 4.92527 4.07381 5.99992 5.39945 6C6.72517 6 7.79984 4.92532 7.79984 3.59961C7.79959 2.27411 6.72501 1.2002 5.39945 1.2002Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,30 @@
import { cn } from "#/utils/utils";
interface ContextMenuIconTextProps {
icon: React.ReactNode;
text: string;
rightIcon?: React.ReactNode;
className?: string;
}
export function ContextMenuIconText({
icon,
text,
rightIcon,
className,
}: ContextMenuIconTextProps) {
return (
<div
className={cn(
"flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded",
className,
)}
>
<div className="flex items-center gap-2">
{icon}
{text}
</div>
{rightIcon && <div className="flex items-center">{rightIcon}</div>}
</div>
);
}