[WEB-4896]feat: filters to project and workspace members list (#7786)

This commit is contained in:
Vamsi Krishna 2025-09-24 17:45:43 +05:30 committed by GitHub
parent 85bffaa231
commit 586a7a48ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 914 additions and 80 deletions

View File

@ -20,6 +20,7 @@ import { cn } from "@plane/utils";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { CountChip } from "@/components/common/count-chip";
import { PageHead } from "@/components/core/page-title";
import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { WorkspaceMembersList } from "@/components/workspace/settings/members-list";
// helpers
@ -41,7 +42,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@ -88,8 +89,20 @@ const WorkspaceMembersSettingsPage = observer(() => {
});
};
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
const currentFilters = filtersStore.filters;
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role) ? currentRoles.filter((r) => r !== role) : [...currentRoles, role];
filtersStore.updateFilters({
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
};
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
const appliedRoleFilters = filtersStore.filters?.roles || [];
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
@ -116,27 +129,34 @@ const WorkspaceMembersSettingsPage = observer(() => {
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
)}
</h4>
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={`${t("search")}...`}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={`${t("search")}...`}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="workspace"
/>
{canPerformWorkspaceAdminActions && (
<Button
variant="primary"
size="sm"
onClick={() => setInviteModal(true)}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
{canPerformWorkspaceAdminActions && (
<Button
variant="primary"
size="sm"
onClick={() => setInviteModal(true)}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("workspace_settings.settings.members.add_member")}
</Button>
)}
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>

View File

@ -2,10 +2,14 @@ import { useState } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IWorkspaceMember, TProjectMembership } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
// components
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { IMemberFilters } from "@/store/member/utils";
export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
@ -20,9 +24,15 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
const { projectId, workspaceSlug } = props;
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
// store hooks
const { data: currentUser } = useUser();
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const {
project: {
filters: { getFilters, updateFilters },
},
} = useMember();
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
@ -33,11 +43,11 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
const currentProjectRole =
getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST;
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const displayFilters = getFilters(projectId);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
// handlers
const handleDisplayFilterUpdate = (filters: Partial<IMemberFilters>) => {
updateFilters(projectId, filters);
};
const columns = [
@ -45,6 +55,13 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
key: "Full Name",
content: "Full name",
thClassName: "text-left",
thRender: () => (
<MemberHeaderColumn
property="full_name"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => (
<NameColumn
rowData={rowData}
@ -58,12 +75,37 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
{
key: "Display Name",
content: "Display name",
thRender: () => (
<MemberHeaderColumn
property="display_name"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Email",
content: "Email",
thRender: () => (
<MemberHeaderColumn
property="email"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-48 text-custom-text-200">{rowData.member.email}</div>,
},
{
key: "Account Type",
content: "Account type",
thRender: () => (
<MemberHeaderColumn
property="role"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => (
<AccountTypeColumn
rowData={rowData}
@ -76,8 +118,21 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
{
key: "Joining Date",
content: "Joining date",
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
thRender: () => (
<MemberHeaderColumn
property="joining_date"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
},
];
return { columns, removeMemberModal, setRemoveMemberModal };
return {
columns,
removeMemberModal,
setRemoveMemberModal,
displayFilters,
handleDisplayFilterUpdate,
};
};

View File

@ -2,8 +2,12 @@ import { useState } from "react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { renderFormattedDate } from "@plane/utils";
import { MemberHeaderColumn } from "@/components/project/member-header-column";
import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns";
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { IMemberFilters } from "@/store/member/utils";
export const useMemberColumns = () => {
// states
@ -13,23 +17,33 @@ export const useMemberColumns = () => {
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const {
workspace: {
filtersStore: { filters, updateFilters },
},
} = useMember();
const { t } = useTranslation();
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// handlers
const handleDisplayFilterUpdate = (filterUpdates: Partial<IMemberFilters>) => {
updateFilters(filterUpdates);
};
const columns = [
{
key: "Full name",
content: t("workspace_settings.settings.members.details.full_name"),
thClassName: "text-left",
thRender: () => (
<MemberHeaderColumn
property="full_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => (
<NameColumn
rowData={rowData}
@ -44,18 +58,39 @@ export const useMemberColumns = () => {
{
key: "Display name",
content: t("workspace_settings.settings.members.details.display_name"),
thRender: () => (
<MemberHeaderColumn
property="display_name"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Email address",
content: t("workspace_settings.settings.members.details.email_address"),
thRender: () => (
<MemberHeaderColumn
property="email"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
},
{
key: "Account type",
content: t("workspace_settings.settings.members.details.account_type"),
thRender: () => (
<MemberHeaderColumn
property="role"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <AccountTypeColumn rowData={rowData} workspaceSlug={workspaceSlug as string} />,
},
@ -70,7 +105,14 @@ export const useMemberColumns = () => {
{
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
thRender: () => (
<MemberHeaderColumn
property="joining_date"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
},
];
return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal };

View File

@ -5,7 +5,7 @@ import { EUserProjectRoles } from "@plane/types";
import type { RootStore } from "@/plane-web/store/root.store";
// store
import type { IMemberRootStore } from "@/store/member";
import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store";
import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store";
export type IProjectMemberStore = IBaseProjectMemberStore;

View File

@ -3,8 +3,8 @@ import { useParams } from "next/navigation";
// plane imports
import type { TDisplayConfig } from "@plane/editor";
import type { JSONContent, TPageVersion } from "@plane/types";
import { isJSONContentEmpty } from "@plane/utils";
import { Loader } from "@plane/ui";
import { isJSONContentEmpty } from "@plane/utils";
// components
import { DocumentEditor } from "@/components/editor/document/editor";
// hooks

View File

@ -0,0 +1,106 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { ChevronDown } from "lucide-react";
// plane imports
import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
// plane ui
import { Button, CustomMenu } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
interface IRoleOption {
value: string;
label: string;
}
type Props = {
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
};
const PROJECT_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserProjectRoles.ADMIN), label: "Admin" },
{ value: String(EUserProjectRoles.MEMBER), label: "Member" },
{ value: String(EUserProjectRoles.GUEST), label: "Guest" },
];
const WORKSPACE_ROLE_OPTIONS: IRoleOption[] = [
{ value: String(EUserWorkspaceRoles.ADMIN), label: "Admin" },
{ value: String(EUserWorkspaceRoles.MEMBER), label: "Member" },
{ value: String(EUserWorkspaceRoles.GUEST), label: "Guest" },
];
// Role filter group component
const RoleFilterGroup: React.FC<{
appliedFilters: string[] | null;
handleUpdate: (role: string) => void;
memberType: "project" | "workspace";
}> = observer(({ appliedFilters, handleUpdate, memberType }) => {
const [isExpanded, setIsExpanded] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const roleOptions = memberType === "project" ? PROJECT_ROLE_OPTIONS : WORKSPACE_ROLE_OPTIONS;
return (
<div className="space-y-2">
<FilterHeader
title={`Roles${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={isExpanded}
handleIsPreviewEnabled={() => setIsExpanded(!isExpanded)}
/>
{isExpanded && (
<div className="space-y-1">
{roleOptions.map((role) => {
const isSelected = appliedFilters?.includes(role.value) ?? false;
return (
<FilterOption
key={`role-${role.value}`}
isChecked={isSelected}
title={role.label}
onClick={() => handleUpdate(role.value)}
/>
);
})}
</div>
)}
</div>
);
});
export const MemberListFilters: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, memberType } = props;
return (
<div className="space-y-4">
{/* Role Filter Group */}
<RoleFilterGroup appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</div>
);
});
// Dropdown component for member list filters
export const MemberListFiltersDropdown: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, memberType } = props;
const appliedFiltersCount = appliedFilters?.length ?? 0;
return (
<CustomMenu
customButton={
<div className="relative">
<Button variant="neutral-primary" size="sm" className="flex items-center gap-2">
<span>Filters</span>
<ChevronDown className="h-3 w-3" />
</Button>
{appliedFiltersCount > 0 && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-custom-primary-100" />
)}
</div>
}
placement="bottom-start"
>
<MemberListFilters appliedFilters={appliedFilters} handleUpdate={handleUpdate} memberType={memberType} />
</CustomMenu>
);
});

View File

@ -0,0 +1,114 @@
// ui
import { observer } from "mobx-react";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, CheckIcon, ChevronDownIcon, Eraser, MoveRight } from "lucide-react";
// constants
import { MEMBER_PROPERTY_DETAILS, IProjectMemberDisplayProperties, TMemberOrderByOptions } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { CustomMenu } from "@plane/ui";
import { IMemberFilters } from "@/store/member/utils";
interface Props {
property: keyof IProjectMemberDisplayProperties;
displayFilters?: IMemberFilters;
handleDisplayFilterUpdate: (data: Partial<IMemberFilters>) => void;
}
export const MemberHeaderColumn = observer((props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property } = props;
// i18n
const { t } = useTranslation();
const propertyDetails = MEMBER_PROPERTY_DETAILS[property];
const activeSortingProperty = displayFilters?.order_by;
const handleOrderBy = (order: TMemberOrderByOptions, _itemKey: keyof IProjectMemberDisplayProperties) => {
handleDisplayFilterUpdate({ order_by: order });
};
const handleClearSorting = () => {
handleDisplayFilterUpdate({ order_by: undefined });
};
if (!propertyDetails) return null;
return (
<CustomMenu
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
<span>{t(propertyDetails.i18n_title)}</span>
<div className="ml-3 flex">
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<div className="flex h-3.5 w-3.5 items-center justify-center rounded-full">
{propertyDetails.ascendingOrderKey === activeSortingProperty ? (
<ArrowDownWideNarrow className="h-3 w-3" />
) : (
<ArrowUpNarrowWide className="h-3 w-3" />
)}
</div>
)}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
</div>
}
placement="bottom-end"
closeOnSelect
>
{propertyDetails.isSortingAllowed && (
<>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.ascendingOrderKey
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.ascendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.descendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.ascendingOrderKey && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
activeSortingProperty === propertyDetails.descendingOrderKey
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.descendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.ascendingOrderTitle}</span>
</div>
{activeSortingProperty === propertyDetails.descendingOrderKey && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
{(activeSortingProperty === propertyDetails.ascendingOrderKey ||
activeSortingProperty === propertyDetails.descendingOrderKey) && (
<CustomMenu.MenuItem className="mt-0.5" key={property} onClick={handleClearSorting}>
<div className="flex items-center gap-2 px-1">
<Eraser className="h-3 w-3" />
<span>{t("common.actions.clear_sorting")}</span>
</div>
</CustomMenu.MenuItem>
)}
</>
)}
</CustomMenu>
);
});

View File

@ -13,7 +13,7 @@ import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useProjectColumns } from "@/plane-web/components/projects/settings/useProjectColumns";
// store
import { IProjectMemberDetails } from "@/store/member/base-project-member.store";
import { IProjectMemberDetails } from "@/store/member/project/base-project-member.store";
// local imports
import { ConfirmProjectMemberRemove } from "./confirm-project-member-remove";

View File

@ -13,6 +13,7 @@ import { MembersSettingsLoader } from "@/components/ui/loader/settings/members";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberListFiltersDropdown } from "./dropdowns/filters/member-list";
import { ProjectMemberListItem } from "./member-list-item";
import { SendProjectInvitationModal } from "./send-project-invitation-modal";
@ -27,14 +28,14 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getProjectMemberDetails },
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member || !memberDetails.original_role) return false;
@ -43,12 +44,31 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
});
const memberDetails = searchedProjectMembers?.map((memberId) =>
projectId ? getProjectMemberDetails(memberId, projectId.toString()) : null
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
if (projectId) {
const currentFilters = filters.getFilters(projectId);
const currentRoles = currentFilters?.roles || [];
const updatedRoles = currentRoles.includes(role)
? currentRoles.filter((r) => r !== role)
: [...currentRoles, role];
filters.updateFilters(projectId, {
roles: updatedRoles.length > 0 ? updatedRoles : undefined,
});
}
};
// Get current role filters
const appliedRoleFilters = projectId ? filters.getFilters(projectId)?.roles || [] : [];
return (
<>
<SendProjectInvitationModal
@ -59,28 +79,35 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
/>
<div className="flex items-center justify-between gap-4 py-2 overflow-x-hidden border-b border-custom-border-100">
<div className="text-base font-semibold">{t("common.members")}</div>
<div className="ml-auto flex items-center justify-start gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
<div className="flex items-center gap-2">
<div className="flex items-center justify-start gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2 py-1">
<Search className="h-3.5 w-3.5" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm focus:outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<MemberListFiltersDropdown
appliedFilters={appliedRoleFilters}
handleUpdate={handleRoleFilterUpdate}
memberType="project"
/>
{isAdmin && (
<Button
variant="primary"
size="sm"
onClick={() => {
setInviteModal(true);
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("add_member")}
</Button>
)}
</div>
{isAdmin && (
<Button
variant="primary"
size="sm"
onClick={() => {
setInviteModal(true);
}}
data-ph-element={MEMBER_TRACKER_ELEMENTS.HEADER_ADD_BUTTON}
>
{t("add_member")}
</Button>
)}
</div>
{!projectMemberIds ? (
<MembersSettingsLoader />

View File

@ -28,6 +28,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
fetchWorkspaceMembers,
fetchWorkspaceMemberInvitations,
workspaceMemberIds,
getFilteredWorkspaceMemberIds,
getSearchedWorkspaceMemberIds,
workspaceMemberInvitationIds,
getSearchedWorkspaceInvitationIds,
@ -49,7 +50,8 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
// derived values
const searchedMemberIds = getSearchedWorkspaceMemberIds(searchQuery);
const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : [];
const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds;
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId));

View File

@ -29,7 +29,7 @@ import {
// root store
import { IWorkspaceIssues, WorkspaceIssues } from "@/plane-web/store/issue/workspace/issue.store";
import type { RootStore } from "@/plane-web/store/root.store";
import { IWorkspaceMembership } from "@/store/member/workspace-member.store";
import { IWorkspaceMembership } from "@/store/member/workspace/workspace-member.store";
// issues data store
import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedIssues } from "./archived";
import { ICycleIssuesFilter, CycleIssuesFilter, ICycleIssues, CycleIssues } from "./cycle";

View File

@ -6,7 +6,7 @@ import { IUserLite } from "@plane/types";
import { IProjectMemberStore, ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import type { RootStore } from "@/plane-web/store/root.store";
// local imports
import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace-member.store";
import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace/workspace-member.store";
export interface IMemberRootStore {
// observables

View File

@ -13,11 +13,13 @@ import type { RootStore } from "@/plane-web/store/root.store";
// services
import { ProjectMemberService } from "@/services/project";
// store
import { IProjectStore } from "@/store/project/project.store";
import { IRouterStore } from "@/store/router.store";
import { IUserStore } from "@/store/user";
// local imports
import { IProjectStore } from "../project/project.store";
import { IMemberRootStore } from ".";
import { IMemberRootStore } from "../index";
import { sortProjectMembers } from "../utils";
import { ProjectMemberFiltersStore, IProjectMemberFiltersStore } from "./project-member-filters.store";
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
member: IUserLite;
@ -31,12 +33,15 @@ export interface IBaseProjectMemberStore {
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
};
// filters store
filters: IProjectMemberFiltersStore;
// computed
projectMemberIds: string[] | null;
// computed actions
getProjectMemberFetchStatus: (projectId: string) => boolean;
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
// fetch actions
fetchProjectMembers: (
workspaceSlug: string,
@ -67,6 +72,8 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
} = {};
// filters store
filters: IProjectMemberFiltersStore;
// stores
routerStore: IRouterStore;
userStore: IUserStore;
@ -88,31 +95,40 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
updateMemberRole: action,
removeMemberFromProject: action,
});
// root store
this.rootStore = _rootStore;
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
this.memberRoot = _memberRoot;
this.projectRoot = _rootStore.projectRoot.project;
this.filters = new ProjectMemberFiltersStore();
// services
this.projectMemberService = new ProjectMemberService();
}
/**
* @description get the list of all the user ids of all the members of the current project
* Returns filtered and sorted member IDs based on current filters
*/
get projectMemberIds() {
const projectId = this.routerStore.projectId;
if (!projectId) return null;
let members = Object.values(this.projectMemberMap?.[projectId] ?? {});
const members = Object.values(this.projectMemberMap?.[projectId] ?? {});
if (members.length === 0) return null;
members = sortBy(members, [
(m) => m.member !== this.userStore.data?.id,
(m) => this.memberRoot.memberMap?.[m.member]?.display_name.toLowerCase(),
]);
const memberIds = members.map((m) => m.member);
return memberIds;
// Access the filters directly to ensure MobX tracking
const currentFilters = this.filters.filtersMap[projectId];
// Apply filters and sorting directly here to ensure MobX tracking
const sortedMembers = sortProjectMembers(
members,
this.memberRoot?.memberMap || {},
(member) => member.member,
currentFilters
);
return sortedMembers.map((member) => member.member);
}
/**
@ -202,6 +218,41 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
return memberIds;
});
/**
* @description get the filtered project member details for a specific user
* @param userId
* @param projectId
*/
getFilteredProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
const userDetails = this.memberRoot?.memberMap?.[projectMember?.member];
if (!projectMember || !userDetails) return null;
// Check if this member passes the current filters
const allMembers = this.getProjectMemberships(projectId);
const filteredMemberIds = this.filters.getFilteredMemberIds(
allMembers,
this.memberRoot?.memberMap || {},
(member) => member.member,
projectId
);
// Return null if this user doesn't pass the filters
if (!filteredMemberIds.includes(userId)) return null;
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
original_role: projectMember.original_role,
member: {
...userDetails,
joining_date: projectMember.created_at ?? undefined,
},
created_at: projectMember.created_at,
};
return memberDetails;
});
/**
* @description fetch the list of all the members of a project
* @param workspaceSlug

View File

@ -0,0 +1,70 @@
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// types
import type { IUserLite, TProjectMembership } from "@plane/types";
// local imports
import { IMemberFilters, sortProjectMembers } from "../utils";
export interface IProjectMemberFiltersStore {
// observables
filtersMap: Record<string, IMemberFilters>;
// computed actions
getFilteredMemberIds: (
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
projectId: string
) => string[];
// actions
updateFilters: (projectId: string, filters: Partial<IMemberFilters>) => void;
getFilters: (projectId: string) => IMemberFilters | undefined;
}
export class ProjectMemberFiltersStore implements IProjectMemberFiltersStore {
// observables
filtersMap: Record<string, IMemberFilters> = {};
constructor() {
makeObservable(this, {
// observables
filtersMap: observable,
// actions
updateFilters: action,
});
}
/**
* @description get filtered and sorted member ids
* @param members - array of project membership objects
* @param memberDetailsMap - map of member details by user id
* @param getMemberKey - function to get member key from membership object
* @param projectId - project id to get filters for
*/
getFilteredMemberIds = computedFn(
(
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
projectId: string
): string[] => {
if (!members || members.length === 0) return [];
// Apply filters and sorting
const sortedMembers = sortProjectMembers(members, memberDetailsMap, getMemberKey, this.filtersMap[projectId]);
return sortedMembers.map(getMemberKey);
}
);
getFilters = (projectId: string) => this.filtersMap[projectId];
/**
* @description update filters
* @param projectId - project id
* @param filters - partial filters to update
*/
updateFilters = (projectId: string, filters: Partial<IMemberFilters>) => {
const current = this.filtersMap[projectId] ?? {};
this.filtersMap[projectId] = { ...current, ...filters };
};
}

View File

@ -0,0 +1,163 @@
// Types and utilities for member filtering
import type { EUserPermissions, TMemberOrderByOptions } from "@plane/constants";
import type { IUserLite, TProjectMembership } from "@plane/types";
export interface IMemberFilters {
order_by?: TMemberOrderByOptions;
roles?: string[];
}
// Helper function to parse order key and direction
export const parseOrderKey = (orderKey?: TMemberOrderByOptions): { field: string; direction: "asc" | "desc" } => {
// Default to sorting by display_name in ascending order when no order key is provided
if (!orderKey) {
return {
field: "display_name",
direction: "asc",
};
}
const isDescending = orderKey.startsWith("-");
const field = isDescending ? orderKey.slice(1) : orderKey;
return {
field,
direction: isDescending ? "desc" : "asc",
};
};
// Unified function to get sort key for any member type
export const getMemberSortKey = (memberDetails: IUserLite, field: string, memberRole?: string): string | Date => {
switch (field) {
case "display_name":
return memberDetails.display_name?.toLowerCase() || "";
case "full_name": {
const firstName = memberDetails.first_name || "";
const lastName = memberDetails.last_name || "";
return `${firstName} ${lastName}`.toLowerCase().trim();
}
case "email":
return memberDetails.email?.toLowerCase() || "";
case "joining_date":
return memberDetails.joining_date ? new Date(memberDetails.joining_date) : new Date(NaN);
case "role":
return (memberRole ?? "").toString().toLowerCase();
default:
return "";
}
};
// Filter functions
export const filterProjectMembersByRole = (
members: TProjectMembership[],
roleFilters: string[]
): TProjectMembership[] => {
if (roleFilters.length === 0) return members;
return members.filter((member) => {
const memberRole = String(member.role ?? member.original_role ?? "");
return roleFilters.includes(memberRole);
});
};
export const filterWorkspaceMembersByRole = <T extends { role: string | EUserPermissions }>(
members: T[],
roleFilters: string[]
): T[] => {
if (roleFilters.length === 0) return members;
return members.filter((member) => {
const memberRole = String(member.role ?? "");
return roleFilters.includes(memberRole);
});
};
// Unified sorting function
export const sortMembers = <T>(
members: T[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: T) => string,
getMemberRole: (member: T) => string,
orderBy?: TMemberOrderByOptions
): T[] => {
if (!orderBy) return members;
const { field, direction } = parseOrderKey(orderBy);
return [...members].sort((a, b) => {
const aKey = getMemberKey(a);
const bKey = getMemberKey(b);
const aMemberDetails = memberDetailsMap[aKey];
const bMemberDetails = memberDetailsMap[bKey];
if (!aMemberDetails || !bMemberDetails) return 0;
const aRole = getMemberRole(a);
const bRole = getMemberRole(b);
const aValue = getMemberSortKey(aMemberDetails, field, aRole);
const bValue = getMemberSortKey(bMemberDetails, field, bRole);
let comparison = 0;
if (field === "joining_date") {
// For dates, we need to handle Date objects
const aDate = aValue instanceof Date ? aValue : new Date(aValue);
const bDate = bValue instanceof Date ? bValue : new Date(bValue);
comparison = aDate.getTime() - bDate.getTime();
} else {
// For strings, use localeCompare for proper alphabetical sorting
const aStr = String(aValue);
const bStr = String(bValue);
comparison = aStr.localeCompare(bStr);
}
return direction === "desc" ? -comparison : comparison;
});
};
// Specific implementations using the unified functions
export const sortProjectMembers = (
members: TProjectMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: TProjectMembership) => string,
filters?: IMemberFilters
): TProjectMembership[] => {
// Apply role filtering first
const filteredMembers =
filters?.roles && filters.roles.length > 0 ? filterProjectMembersByRole(members, filters.roles) : members;
// If no order_by filter, return filtered members
if (!filters?.order_by) return filteredMembers;
// Apply sorting
return sortMembers(
filteredMembers,
memberDetailsMap,
getMemberKey,
(member) => String(member.role ?? member.original_role ?? ""),
filters.order_by
);
};
export const sortWorkspaceMembers = <T extends { role: string | EUserPermissions }>(
members: T[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: T) => string,
filters?: IMemberFilters
): T[] => {
// Apply role filtering first
const filteredMembers =
filters?.roles && filters.roles.length > 0 ? filterWorkspaceMembersByRole(members, filters.roles) : members;
// If no order_by filter, return filtered members
if (!filters?.order_by) return filteredMembers;
// Apply sorting
return sortMembers(
filteredMembers,
memberDetailsMap,
getMemberKey,
(member) => String(member.role ?? ""),
filters.order_by
);
};

View File

@ -0,0 +1,71 @@
import { action, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// types
import type { EUserPermissions } from "@plane/constants";
import type { IUserLite } from "@plane/types";
// local imports
import { IMemberFilters, sortWorkspaceMembers } from "../utils";
// Workspace membership interface matching the store structure
interface IWorkspaceMembership {
id: string;
member: string;
role: EUserPermissions;
is_active?: boolean;
}
export interface IWorkspaceMemberFiltersStore {
// observables
filters: IMemberFilters;
// computed actions
getFilteredMemberIds: (
members: IWorkspaceMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: IWorkspaceMembership) => string
) => string[];
// actions
updateFilters: (filters: Partial<IMemberFilters>) => void;
}
export class WorkspaceMemberFiltersStore implements IWorkspaceMemberFiltersStore {
// observables
filters: IMemberFilters = {};
constructor() {
makeObservable(this, {
// observables
filters: observable,
// actions
updateFilters: action,
});
}
/**
* @description get filtered and sorted member ids
* @param members - array of workspace membership objects
* @param memberDetailsMap - map of member details by user id
* @param getMemberKey - function to get member key from membership object
*/
getFilteredMemberIds = computedFn(
(
members: IWorkspaceMembership[],
memberDetailsMap: Record<string, IUserLite>,
getMemberKey: (member: IWorkspaceMembership) => string
): string[] => {
if (!members || members.length === 0) return [];
// Apply filters and sorting
const sortedMembers = sortWorkspaceMembers(members, memberDetailsMap, getMemberKey, this.filters);
return sortedMembers.map(getMemberKey);
}
);
/**
* @description update filters
* @param filters - partial filters to update
*/
updateFilters = (filters: Partial<IMemberFilters>) => {
this.filters = { ...this.filters, ...filters };
};
}

View File

@ -12,8 +12,9 @@ import { WorkspaceService } from "@/plane-web/services";
import type { IRouterStore } from "@/store/router.store";
import type { IUserStore } from "@/store/user";
// store
import type { CoreRootStore } from "../root.store";
import type { IMemberRootStore } from ".";
import type { CoreRootStore } from "../../root.store";
import type { IMemberRootStore } from "../index.ts";
import { WorkspaceMemberFiltersStore, IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
export interface IWorkspaceMembership {
id: string;
@ -26,12 +27,15 @@ export interface IWorkspaceMemberStore {
// observables
workspaceMemberMap: Record<string, Record<string, IWorkspaceMembership>>;
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]>;
// filters store
filtersStore: IWorkspaceMemberFiltersStore;
// computed
workspaceMemberIds: string[] | null;
workspaceMemberInvitationIds: string[] | null;
memberMap: Record<string, IWorkspaceMembership> | null;
// computed actions
getWorkspaceMemberIds: (workspaceSlug: string) => string[];
getFilteredWorkspaceMemberIds: (workspaceSlug: string) => string[];
getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null;
getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null;
getWorkspaceMemberDetails: (workspaceMemberId: string) => IWorkspaceMember | null;
@ -58,6 +62,8 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
[workspaceSlug: string]: Record<string, IWorkspaceMembership>;
} = {}; // { workspaceSlug: { userId: userDetails } }
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]> = {}; // { workspaceSlug: [invitations] }
// filters store
filtersStore: IWorkspaceMemberFiltersStore;
// stores
routerStore: IRouterStore;
userStore: IUserStore;
@ -82,7 +88,8 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
updateMemberInvitation: action,
deleteMemberInvitation: action,
});
// initialize filters store
this.filtersStore = new WorkspaceMemberFiltersStore();
// root store
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
@ -126,6 +133,25 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
return memberIds;
});
/**
* @description get the filtered and sorted list of all the user ids of all the members of the workspace
* @param workspaceSlug
*/
getFilteredWorkspaceMemberIds = computedFn((workspaceSlug: string) => {
let members = Object.values(this.workspaceMemberMap?.[workspaceSlug] ?? {});
//filter out bots and inactive members
members = members.filter((m) => m.is_active && !this.memberRoot?.memberMap?.[m.member]?.is_bot);
// Use filters store to get filtered member ids
const memberIds = this.filtersStore.getFilteredMemberIds(
members,
this.memberRoot?.memberMap || {},
(member) => member.member
);
return memberIds;
});
/**
* @description get the list of all the user ids that match the search query of all the members of the current workspace
* @param searchQuery
@ -133,9 +159,9 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
getSearchedWorkspaceMemberIds = computedFn((searchQuery: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const workspaceMemberIds = this.workspaceMemberIds;
if (!workspaceMemberIds) return null;
const searchedWorkspaceMemberIds = workspaceMemberIds?.filter((userId) => {
const filteredMemberIds = this.getFilteredWorkspaceMemberIds(workspaceSlug);
if (!filteredMemberIds) return null;
const searchedWorkspaceMemberIds = filteredMemberIds.filter((userId) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) return false;
const memberSearchQuery = `${memberDetails.member.first_name} ${memberDetails.member.last_name} ${

View File

@ -15,6 +15,7 @@ export * from "./icon";
export * from "./instance";
export * from "./intake";
export * from "./issue";
export * from "./members";
export * from "./label";
export * from "./metadata";
export * from "./module";

View File

@ -0,0 +1,79 @@
// Member property constants - Single source of truth for member spreadsheet properties
export type TMemberOrderByOptions =
| "display_name"
| "-display_name"
| "full_name"
| "-full_name"
| "email"
| "-email"
| "joining_date"
| "-joining_date"
| "role"
| "-role";
export interface IProjectMemberDisplayProperties {
full_name: boolean;
display_name: boolean;
email: boolean;
joining_date: boolean;
role: boolean;
}
export const MEMBER_PROPERTY_DETAILS: {
[key in keyof IProjectMemberDisplayProperties]: {
i18n_title: string;
ascendingOrderKey: TMemberOrderByOptions;
ascendingOrderTitle: string;
descendingOrderKey: TMemberOrderByOptions;
descendingOrderTitle: string;
iconName: string;
isSortingAllowed: boolean;
};
} = {
full_name: {
i18n_title: "project_members.full_name",
ascendingOrderKey: "full_name",
ascendingOrderTitle: "A",
descendingOrderKey: "-full_name",
descendingOrderTitle: "Z",
iconName: "User",
isSortingAllowed: true,
},
display_name: {
i18n_title: "project_members.display_name",
ascendingOrderKey: "display_name",
ascendingOrderTitle: "A",
descendingOrderKey: "-display_name",
descendingOrderTitle: "Z",
iconName: "User",
isSortingAllowed: true,
},
email: {
i18n_title: "project_members.email",
ascendingOrderKey: "email",
ascendingOrderTitle: "A",
descendingOrderKey: "-email",
descendingOrderTitle: "Z",
iconName: "Mail",
isSortingAllowed: true,
},
joining_date: {
i18n_title: "project_members.joining_date",
ascendingOrderKey: "joining_date",
ascendingOrderTitle: "Old",
descendingOrderKey: "-joining_date",
descendingOrderTitle: "New",
iconName: "Calendar",
isSortingAllowed: true,
},
role: {
i18n_title: "project_members.role",
ascendingOrderKey: "role",
ascendingOrderTitle: "Guest",
descendingOrderKey: "-role",
descendingOrderTitle: "Admin",
iconName: "Shield",
isSortingAllowed: true,
},
};

View File

@ -2405,5 +2405,12 @@
"open_button": "Open navigation pane",
"close_button": "Close navigation pane",
"outline_floating_button": "Open outline"
},
"project_members": {
"full_name": "Full name",
"display_name": "Display name",
"email": "Email",
"joining_date": "Joining date",
"role": "Role"
}
}