mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[WEB-4896]feat: filters to project and workspace members list (#7786)
This commit is contained in:
parent
85bffaa231
commit
586a7a48ba
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
114
apps/web/core/components/project/member-header-column.tsx
Normal file
114
apps/web/core/components/project/member-header-column.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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 };
|
||||
};
|
||||
}
|
||||
163
apps/web/core/store/member/utils.ts
Normal file
163
apps/web/core/store/member/utils.ts
Normal 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
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
}
|
||||
@ -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} ${
|
||||
@ -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";
|
||||
|
||||
79
packages/constants/src/members.ts
Normal file
79
packages/constants/src/members.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user