From dce8b75a1e4d4b5b1ce37866f7f2b87583aa6e00 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:43:02 +0530 Subject: [PATCH 1/5] [WIKI-638] fix: peek overview closing while dropdowns are open (#7841) --- .../issues/peek-overview/issue-detail.tsx | 12 ++++----- .../components/issues/peek-overview/view.tsx | 11 +++++--- .../core/extensions/slash-commands/root.tsx | 15 +++++++---- .../drag-handles/column/drag-handle.tsx | 16 +++++++++--- .../plugins/drag-handles/row/drag-handle.tsx | 16 +++++++++--- .../editor/src/core/extensions/utility.ts | 25 ++++++++++++++++--- .../editor/src/core/helpers/editor-ref.ts | 5 ++++ packages/editor/src/core/types/editor.ts | 1 + 8 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/web/core/components/issues/peek-overview/issue-detail.tsx b/apps/web/core/components/issues/peek-overview/issue-detail.tsx index 781aa623cf..20d89a78f0 100644 --- a/apps/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/apps/web/core/components/issues/peek-overview/issue-detail.tsx @@ -29,7 +29,8 @@ import { IssueTitleInput } from "../title-input"; // services init const workItemVersionService = new WorkItemVersionService(); -interface IPeekOverviewIssueDetails { +type Props = { + editorRef: React.RefObject; workspaceSlug: string; projectId: string; issueId: string; @@ -38,12 +39,11 @@ interface IPeekOverviewIssueDetails { isArchived: boolean; isSubmitting: TNameDescriptionLoader; setIsSubmitting: (value: TNameDescriptionLoader) => void; -} +}; -export const PeekOverviewIssueDetails: FC = observer((props) => { - const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props; - // refs - const editorRef = useRef(null); +export const PeekOverviewIssueDetails: FC = observer((props) => { + const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = + props; // store hooks const { data: currentUser } = useUser(); const { diff --git a/apps/web/core/components/issues/peek-overview/view.tsx b/apps/web/core/components/issues/peek-overview/view.tsx index bbae72db33..9840de4cee 100644 --- a/apps/web/core/components/issues/peek-overview/view.tsx +++ b/apps/web/core/components/issues/peek-overview/view.tsx @@ -2,6 +2,7 @@ import { FC, useRef, useState } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; // plane imports +import type { EditorRefApi } from "@plane/editor"; import { EIssueServiceType, TNameDescriptionLoader } from "@plane/types"; import { cn } from "@plane/utils"; // hooks @@ -53,6 +54,7 @@ export const IssueView: FC = observer((props) => { const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false); // ref const issuePeekOverviewRef = useRef(null); + const editorRef = useRef(null); // store hooks const { setPeekIssue, @@ -80,8 +82,9 @@ export const IssueView: FC = observer((props) => { usePeekOverviewOutsideClickDetector( issuePeekOverviewRef, () => { + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); if (!embedIssue) { - if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen) { + if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen && !isAnyDropbarOpen) { removeRoutePeekId(); } } @@ -90,10 +93,10 @@ export const IssueView: FC = observer((props) => { ); const handleKeyDown = () => { - const slashCommandDropdownElement = document.querySelector("#slash-command"); const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal"); const dropdownElement = document.activeElement?.tagName === "INPUT"; - if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement && !editorImageFullScreenModalElement) { + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); + if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) { removeRoutePeekId(); const issueElement = document.getElementById(`issue-${issueId}`); if (issueElement) issueElement?.focus(); @@ -166,6 +169,7 @@ export const IssueView: FC = observer((props) => { {["side-peek", "modal"].includes(peekMode) ? (
= observer((props) => {
({ }, }); -const renderItems = () => { +const renderItems: SuggestionOptions["render"] = () => { let component: ReactRenderer | null = null; let popup: Instance | null = null; return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + onStart: (props) => { + // Track active dropdown + props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); + component = new ReactRenderer(SlashCommandsMenu, { props, editor: props.editor, @@ -78,14 +81,14 @@ const renderItems = () => { placement: "bottom-start", }); }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + onUpdate: (props) => { component?.updateProps(props); popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect, }); }, - onKeyDown: (props: { event: KeyboardEvent }) => { + onKeyDown: (props) => { if (props.event.key === "Escape") { popup?.[0].hide(); return true; @@ -95,7 +98,9 @@ const renderItems = () => { } return false; }, - onExit: () => { + onExit: ({ editor }) => { + // Remove from active dropdowns + editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS); popup?.[0].destroy(); component?.destroy(); }, diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx index 425bc7572f..b4322b2389 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx @@ -15,6 +15,8 @@ import { Ellipsis } from "lucide-react"; import { useCallback, useState } from "react"; // plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { findTable, @@ -59,7 +61,16 @@ export const ColumnDragHandle: React.FC = (props) => { }), ], open: isDropdownOpen, - onOpenChange: setIsDropdownOpen, + onOpenChange: (open) => { + setIsDropdownOpen(open); + if (open) { + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + } else { + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + } + }, whileElementsMounted: autoUpdate, }); const click = useClick(context); @@ -185,7 +196,6 @@ export const ColumnDragHandle: React.FC = (props) => { }} lockScroll /> -
= (props) => { zIndex: 100, }} > - setIsDropdownOpen(false)} /> + context.onOpenChange(false)} />
)} diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx index 7c0f1449a1..d556e71bc4 100644 --- a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx @@ -15,6 +15,8 @@ import { Ellipsis } from "lucide-react"; import { useCallback, useState } from "react"; // plane imports import { cn } from "@plane/utils"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { findTable, @@ -59,7 +61,16 @@ export const RowDragHandle: React.FC = (props) => { }), ], open: isDropdownOpen, - onOpenChange: setIsDropdownOpen, + onOpenChange: (open) => { + setIsDropdownOpen(open); + if (open) { + editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + } else { + setTimeout(() => { + editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE); + }, 0); + } + }, whileElementsMounted: autoUpdate, }); const click = useClick(context); @@ -184,7 +195,6 @@ export const RowDragHandle: React.FC = (props) => { }} lockScroll /> -
= (props) => { zIndex: 100, }} > - setIsDropdownOpen(false)} /> + context.onOpenChange(false)} />
)} diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index 3c014538d3..77fdcc9125 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -9,13 +9,18 @@ import { DropHandlerPlugin } from "@/plugins/drop"; import { FilePlugins } from "@/plugins/file/root"; import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; // types - import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types"; -type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions; + +type TActiveDropbarExtensions = + | CORE_EXTENSIONS.MENTION + | CORE_EXTENSIONS.EMOJI + | CORE_EXTENSIONS.SLASH_COMMANDS + | CORE_EXTENSIONS.TABLE + | TAdditionalActiveDropbarExtensions; declare module "@tiptap/core" { interface Commands { - utility: { + [CORE_EXTENSIONS.UTILITY]: { updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; updateAssetsList: ( args: @@ -26,6 +31,8 @@ declare module "@tiptap/core" { idToRemove: string; } ) => () => void; + addActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void; + removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void; }; } } @@ -102,6 +109,18 @@ export const UtilityExtension = (props: Props) => { } this.storage.assetsList = Array.from(uniqueAssets); }, + addActiveDropbarExtension: (extension) => () => { + const index = this.storage.activeDropbarExtensions.indexOf(extension); + if (index === -1) { + this.storage.activeDropbarExtensions.push(extension); + } + }, + removeActiveDropbarExtension: (extension) => () => { + const index = this.storage.activeDropbarExtensions.indexOf(extension); + if (index !== -1) { + this.storage.activeDropbarExtensions.splice(index, 1); + } + }, }; }, }); diff --git a/packages/editor/src/core/helpers/editor-ref.ts b/packages/editor/src/core/helpers/editor-ref.ts index 49d20df940..2e45e89633 100644 --- a/packages/editor/src/core/helpers/editor-ref.ts +++ b/packages/editor/src/core/helpers/editor-ref.ts @@ -81,6 +81,11 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { const markdownOutput = editor?.storage?.markdown?.getMarkdown?.(); return markdownOutput; }, + isAnyDropbarOpen: () => { + if (!editor) return false; + const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY); + return utilityStorage.activeDropbarExtensions.length > 0; + }, scrollSummary: (marking) => { if (!editor) return; scrollSummary(editor, marking); diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 6c72541377..a99870c05a 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -119,6 +119,7 @@ export type EditorRefApi = { getMarkDown: () => string; getSelectedText: () => string | null; insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; + isAnyDropbarOpen: () => boolean; isEditorReadyToDiscard: () => boolean; isMenuItemActive: (props: TCommandWithPropsWithItemKey) => boolean; listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; From b5ba0a705ff0846bea15a734a7f19c6638935b91 Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Wed, 24 Sep 2025 17:43:28 +0530 Subject: [PATCH 2/5] [WEB-4996] fix: accessing NoneType intake #7847 --- apps/api/plane/api/views/intake.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 1ea9c73fda..6d82bd4536 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -66,7 +66,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") ) - if intake is None and not project.intake_view: + if intake is None or not project.intake_view: return IntakeIssue.objects.none() return ( @@ -230,7 +230,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") ) - if intake is None and not project.intake_view: + if intake is None or not project.intake_view: return IntakeIssue.objects.none() return ( From 85bffaa231cd18252d664232754094aa73e29976 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:45:18 +0530 Subject: [PATCH 3/5] [WIKI-695] fix: tippy width fix #7843 --- packages/editor/src/core/components/menus/bubble-menu/root.tsx | 2 +- packages/editor/src/styles/editor.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index d2cc489a97..4f3fef2ace 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -155,7 +155,7 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi {!isSelecting && (
Date: Wed, 24 Sep 2025 17:45:43 +0530 Subject: [PATCH 4/5] [WEB-4896]feat: filters to project and workspace members list (#7786) --- .../settings/(workspace)/members/page.tsx | 60 ++++--- .../projects/settings/useProjectColumns.tsx | 69 +++++++- .../workspace/settings/useMemberColumns.tsx | 58 ++++++- .../ce/store/member/project-member.store.ts | 2 +- .../core/components/pages/version/editor.tsx | 2 +- .../project/dropdowns/filters/member-list.tsx | 106 ++++++++++++ .../project/member-header-column.tsx | 114 ++++++++++++ .../components/project/member-list-item.tsx | 2 +- .../core/components/project/member-list.tsx | 73 +++++--- .../workspace/settings/members-list.tsx | 4 +- apps/web/core/store/issue/root.store.ts | 2 +- apps/web/core/store/member/index.ts | 2 +- .../base-project-member.store.ts | 71 ++++++-- .../project/project-member-filters.store.ts | 70 ++++++++ apps/web/core/store/member/utils.ts | 163 ++++++++++++++++++ .../workspace-member-filters.store.ts | 71 ++++++++ .../{ => workspace}/workspace-member.store.ts | 38 +++- packages/constants/src/index.ts | 1 + packages/constants/src/members.ts | 79 +++++++++ .../i18n/src/locales/en/translations.json | 7 + 20 files changed, 914 insertions(+), 80 deletions(-) create mode 100644 apps/web/core/components/project/dropdowns/filters/member-list.tsx create mode 100644 apps/web/core/components/project/member-header-column.tsx rename apps/web/core/store/member/{ => project}/base-project-member.store.ts (84%) create mode 100644 apps/web/core/store/member/project/project-member-filters.store.ts create mode 100644 apps/web/core/store/member/utils.ts create mode 100644 apps/web/core/store/member/workspace/workspace-member-filters.store.ts rename apps/web/core/store/member/{ => workspace}/workspace-member.store.ts (89%) create mode 100644 packages/constants/src/members.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 065d8c5e73..d5f9de0aff 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -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(() => { )} -
- - setSearchQuery(e.target.value)} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + {canPerformWorkspaceAdminActions && ( + + )} +
- {canPerformWorkspaceAdminActions && ( - - )} -
diff --git a/apps/web/ce/components/projects/settings/useProjectColumns.tsx b/apps/web/ce/components/projects/settings/useProjectColumns.tsx index 1d6256045a..881851427d 100644 --- a/apps/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/apps/web/ce/components/projects/settings/useProjectColumns.tsx @@ -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 { member: IWorkspaceMember; @@ -20,9 +24,15 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => { const { projectId, workspaceSlug } = props; // states const [removeMemberModal, setRemoveMemberModal] = useState(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) => { + updateFilters(projectId, filters); }; const columns = [ @@ -45,6 +55,13 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => { key: "Full Name", content: "Full name", thClassName: "text-left", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Display Name", content: "Display name", + thRender: () => ( + + ), tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, }, - + { + key: "Email", + content: "Email", + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
{rowData.member.email}
, + }, { key: "Account Type", content: "Account type", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Joining Date", content: "Joining date", - tdRender: (rowData: RowData) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
{renderFormattedDate(rowData?.member?.joining_date)}
, }, ]; - return { columns, removeMemberModal, setRemoveMemberModal }; + return { + columns, + removeMemberModal, + setRemoveMemberModal, + displayFilters, + handleDisplayFilterUpdate, + }; }; diff --git a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx index 10ce47824d..67dd102aa1 100644 --- a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -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) => { + updateFilters(filterUpdates); + }; + const columns = [ { key: "Full name", content: t("workspace_settings.settings.members.details.full_name"), thClassName: "text-left", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Display name", content: t("workspace_settings.settings.members.details.display_name"), + thRender: () => ( + + ), tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, }, { key: "Email address", content: t("workspace_settings.settings.members.details.email_address"), + thRender: () => ( + + ), tdRender: (rowData: RowData) =>
{rowData.member.email}
, }, { key: "Account type", content: t("workspace_settings.settings.members.details.account_type"), + thRender: () => ( + + ), tdRender: (rowData: RowData) => , }, @@ -70,7 +105,14 @@ export const useMemberColumns = () => { { key: "Joining date", content: t("workspace_settings.settings.members.details.joining_date"), - tdRender: (rowData: RowData) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
{renderFormattedDate(rowData?.member?.joining_date)}
, }, ]; return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; diff --git a/apps/web/ce/store/member/project-member.store.ts b/apps/web/ce/store/member/project-member.store.ts index 10aeb842a1..356ed33326 100644 --- a/apps/web/ce/store/member/project-member.store.ts +++ b/apps/web/ce/store/member/project-member.store.ts @@ -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; diff --git a/apps/web/core/components/pages/version/editor.tsx b/apps/web/core/components/pages/version/editor.tsx index d001072dbb..c484a4ee5c 100644 --- a/apps/web/core/components/pages/version/editor.tsx +++ b/apps/web/core/components/pages/version/editor.tsx @@ -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 diff --git a/apps/web/core/components/project/dropdowns/filters/member-list.tsx b/apps/web/core/components/project/dropdowns/filters/member-list.tsx new file mode 100644 index 0000000000..e0aff0cf13 --- /dev/null +++ b/apps/web/core/components/project/dropdowns/filters/member-list.tsx @@ -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 ( +
+ 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={isExpanded} + handleIsPreviewEnabled={() => setIsExpanded(!isExpanded)} + /> + + {isExpanded && ( +
+ {roleOptions.map((role) => { + const isSelected = appliedFilters?.includes(role.value) ?? false; + return ( + handleUpdate(role.value)} + /> + ); + })} +
+ )} +
+ ); +}); + +export const MemberListFilters: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, memberType } = props; + + return ( +
+ {/* Role Filter Group */} + +
+ ); +}); + +// Dropdown component for member list filters +export const MemberListFiltersDropdown: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, memberType } = props; + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + return ( + + + {appliedFiltersCount > 0 && ( +
+ )} +
+ } + placement="bottom-start" + > + +
+ ); +}); diff --git a/apps/web/core/components/project/member-header-column.tsx b/apps/web/core/components/project/member-header-column.tsx new file mode 100644 index 0000000000..045d46547b --- /dev/null +++ b/apps/web/core/components/project/member-header-column.tsx @@ -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) => 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 ( + + {t(propertyDetails.i18n_title)} +
+ {(activeSortingProperty === propertyDetails.ascendingOrderKey || + activeSortingProperty === propertyDetails.descendingOrderKey) && ( +
+ {propertyDetails.ascendingOrderKey === activeSortingProperty ? ( + + ) : ( + + )} +
+ )} +
+
+ } + placement="bottom-end" + closeOnSelect + > + {propertyDetails.isSortingAllowed && ( + <> + handleOrderBy(propertyDetails.ascendingOrderKey, property)}> +
+
+ + {propertyDetails.ascendingOrderTitle} + + {propertyDetails.descendingOrderTitle} +
+ {activeSortingProperty === propertyDetails.ascendingOrderKey && } +
+
+ + handleOrderBy(propertyDetails.descendingOrderKey, property)}> +
+
+ + {propertyDetails.descendingOrderTitle} + + {propertyDetails.ascendingOrderTitle} +
+ {activeSortingProperty === propertyDetails.descendingOrderKey && } +
+
+ + {(activeSortingProperty === propertyDetails.ascendingOrderKey || + activeSortingProperty === propertyDetails.descendingOrderKey) && ( + +
+ + {t("common.actions.clear_sorting")} +
+
+ )} + + )} + + ); +}); diff --git a/apps/web/core/components/project/member-list-item.tsx b/apps/web/core/components/project/member-list-item.tsx index ef606403db..82a129a83a 100644 --- a/apps/web/core/components/project/member-list-item.tsx +++ b/apps/web/core/components/project/member-list-item.tsx @@ -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"; diff --git a/apps/web/core/components/project/member-list.tsx b/apps/web/core/components/project/member-list.tsx index 9ee9084584..47f2f5fec6 100644 --- a/apps/web/core/components/project/member-list.tsx +++ b/apps/web/core/components/project/member-list.tsx @@ -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 = 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 = 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 ( <> = observer((pr />
{t("common.members")}
-
- - setSearchQuery(e.target.value)} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + {isAdmin && ( + + )}
- {isAdmin && ( - - )}
{!projectMemberIds ? ( diff --git a/apps/web/core/components/workspace/settings/members-list.tsx b/apps/web/core/components/workspace/settings/members-list.tsx index f2d51807c5..c9bf00b962 100644 --- a/apps/web/core/components/workspace/settings/members-list.tsx +++ b/apps/web/core/components/workspace/settings/members-list.tsx @@ -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 ; // 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)); diff --git a/apps/web/core/store/issue/root.store.ts b/apps/web/core/store/issue/root.store.ts index d8cc94bfeb..e773069c26 100644 --- a/apps/web/core/store/issue/root.store.ts +++ b/apps/web/core/store/issue/root.store.ts @@ -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"; diff --git a/apps/web/core/store/member/index.ts b/apps/web/core/store/member/index.ts index f3536dedf8..04cb577551 100644 --- a/apps/web/core/store/member/index.ts +++ b/apps/web/core/store/member/index.ts @@ -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 diff --git a/apps/web/core/store/member/base-project-member.store.ts b/apps/web/core/store/member/project/base-project-member.store.ts similarity index 84% rename from apps/web/core/store/member/base-project-member.store.ts rename to apps/web/core/store/member/project/base-project-member.store.ts index bd2c915389..5c75c01b75 100644 --- a/apps/web/core/store/member/base-project-member.store.ts +++ b/apps/web/core/store/member/project/base-project-member.store.ts @@ -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 { member: IUserLite; @@ -31,12 +33,15 @@ export interface IBaseProjectMemberStore { projectMemberMap: { [projectId: string]: Record; }; + // 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; } = {}; + // 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 diff --git a/apps/web/core/store/member/project/project-member-filters.store.ts b/apps/web/core/store/member/project/project-member-filters.store.ts new file mode 100644 index 0000000000..ccd20a599e --- /dev/null +++ b/apps/web/core/store/member/project/project-member-filters.store.ts @@ -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; + // computed actions + getFilteredMemberIds: ( + members: TProjectMembership[], + memberDetailsMap: Record, + getMemberKey: (member: TProjectMembership) => string, + projectId: string + ) => string[]; + // actions + updateFilters: (projectId: string, filters: Partial) => void; + getFilters: (projectId: string) => IMemberFilters | undefined; +} + +export class ProjectMemberFiltersStore implements IProjectMemberFiltersStore { + // observables + filtersMap: Record = {}; + + 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, + 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) => { + const current = this.filtersMap[projectId] ?? {}; + this.filtersMap[projectId] = { ...current, ...filters }; + }; +} diff --git a/apps/web/core/store/member/utils.ts b/apps/web/core/store/member/utils.ts new file mode 100644 index 0000000000..465fd7b057 --- /dev/null +++ b/apps/web/core/store/member/utils.ts @@ -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 = ( + 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 = ( + members: T[], + memberDetailsMap: Record, + 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, + 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 = ( + members: T[], + memberDetailsMap: Record, + 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 + ); +}; diff --git a/apps/web/core/store/member/workspace/workspace-member-filters.store.ts b/apps/web/core/store/member/workspace/workspace-member-filters.store.ts new file mode 100644 index 0000000000..cefa2c6ff3 --- /dev/null +++ b/apps/web/core/store/member/workspace/workspace-member-filters.store.ts @@ -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, + getMemberKey: (member: IWorkspaceMembership) => string + ) => string[]; + // actions + updateFilters: (filters: Partial) => 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, + 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) => { + this.filters = { ...this.filters, ...filters }; + }; +} diff --git a/apps/web/core/store/member/workspace-member.store.ts b/apps/web/core/store/member/workspace/workspace-member.store.ts similarity index 89% rename from apps/web/core/store/member/workspace-member.store.ts rename to apps/web/core/store/member/workspace/workspace-member.store.ts index 5afa019fcc..53e7f9de8d 100644 --- a/apps/web/core/store/member/workspace-member.store.ts +++ b/apps/web/core/store/member/workspace/workspace-member.store.ts @@ -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>; workspaceMemberInvitations: Record; + // filters store + filtersStore: IWorkspaceMemberFiltersStore; // computed workspaceMemberIds: string[] | null; workspaceMemberInvitationIds: string[] | null; memberMap: Record | 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; } = {}; // { workspaceSlug: { userId: userDetails } } workspaceMemberInvitations: Record = {}; // { 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} ${ diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 7be7ac63ef..7c33d5d3a8 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -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"; diff --git a/packages/constants/src/members.ts b/packages/constants/src/members.ts new file mode 100644 index 0000000000..355ae81e36 --- /dev/null +++ b/packages/constants/src/members.ts @@ -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, + }, +}; diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 39087d43a0..341a698a66 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -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" } } From 20634dd79a2aa049386e6d18a99df03269c7fa2c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 24 Sep 2025 18:01:32 +0530 Subject: [PATCH 5/5] fix: epic peek overview --- .../epics/details/main/info-section-root.tsx | 7 +++---- .../ee/components/epics/details/main/root.tsx | 12 ++++++++++-- apps/web/ee/components/epics/details/root.tsx | 5 ++++- .../ee/components/epics/peek-overview/view.tsx | 16 +++++++++++----- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/web/ee/components/epics/details/main/info-section-root.tsx b/apps/web/ee/components/epics/details/main/info-section-root.tsx index d1a3bd5878..b0e166e425 100644 --- a/apps/web/ee/components/epics/details/main/info-section-root.tsx +++ b/apps/web/ee/components/epics/details/main/info-section-root.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { FC, useRef } from "react"; +import React, { FC } from "react"; import { observer } from "mobx-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; @@ -16,6 +16,7 @@ import { EpicInfoActionItems } from "./info-section/action-items"; import { EpicInfoIndicatorItem } from "./info-section/indicator-item"; type Props = { + editorRef: React.RefObject; workspaceSlug: string; projectId: string; epicId: string; @@ -23,9 +24,7 @@ type Props = { }; export const EpicInfoSection: FC = observer((props) => { - const { workspaceSlug, projectId, epicId, disabled = false } = props; - // refs - const editorRef = useRef(null); + const { editorRef, workspaceSlug, projectId, epicId, disabled = false } = props; // store hooks const { issue: { getIssueById }, diff --git a/apps/web/ee/components/epics/details/main/root.tsx b/apps/web/ee/components/epics/details/main/root.tsx index b837b34d93..7f66c35f09 100644 --- a/apps/web/ee/components/epics/details/main/root.tsx +++ b/apps/web/ee/components/epics/details/main/root.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; // plane imports +import type { EditorRefApi } from "@plane/editor"; import { EIssueServiceType } from "@plane/types"; // components import { IssueDetailWidgets } from "@/components/issues/issue-detail-widgets/root"; @@ -15,6 +16,7 @@ import { EpicOverviewRoot } from "./overview-section-root"; import { EpicProgressSection } from "./progress-section-root"; type Props = { + editorRef: React.RefObject; workspaceSlug: string; projectId: string; epicId: string; @@ -22,13 +24,19 @@ type Props = { }; export const EpicMainContentRoot: React.FC = observer((props) => { - const { workspaceSlug, projectId, epicId, disabled = false } = props; + const { editorRef, workspaceSlug, projectId, epicId, disabled = false } = props; // store hooks const { epicDetailSidebarCollapsed } = useAppTheme(); return ( - +
; workspaceSlug: string; projectId: string; epicId: string; }; export const EpicDetailRoot: FC = observer((props) => { - const { workspaceSlug, projectId, epicId } = props; + const { editorRef, workspaceSlug, projectId, epicId } = props; // hooks const { fetchEpicAnalytics } = useEpicAnalytics(); const { @@ -62,6 +64,7 @@ export const EpicDetailRoot: FC = observer((props) => { emptyStateComponent={} > = observer((props) => { const [deleteEpicModal, setDeleteEpicModal] = useState(false); const [editEpicModal, setEditEpicModal] = useState(false); const [duplicateEpicModal, setDuplicateEpicModal] = useState(false); - // ref + // refs const issuePeekOverviewRef = useRef(null); + const editorRef = useRef(null); // store hooks const { setPeekIssue, @@ -78,6 +81,7 @@ export const EpicView: FC = observer((props) => { usePeekOverviewOutsideClickDetector( issuePeekOverviewRef, () => { + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); if (!embedIssue) { if ( !isAnyModalOpen && @@ -88,7 +92,8 @@ export const EpicView: FC = observer((props) => { !isAnyCustomerModalOpen && !isInitiativeModalOpen && !duplicateEpicModal && - !isPeekOpen + !isPeekOpen && + !isAnyDropbarOpen ) { removeRoutePeekId(); } @@ -98,10 +103,10 @@ export const EpicView: FC = observer((props) => { ); const handleKeyDown = () => { - const slashCommandDropdownElement = document.querySelector("#slash-command"); const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal"); const dropdownElement = document.activeElement?.tagName === "INPUT"; - if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement && !editorImageFullScreenModalElement) { + const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen(); + if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) { removeRoutePeekId(); const issueElement = document.getElementById(`issue-${issueId}`); if (issueElement) issueElement?.focus(); @@ -175,6 +180,7 @@ export const EpicView: FC = observer((props) => { /> {/* content */}