Compare commits

..
Author SHA1 Message Date
Anmol Singh Bhatia 0761183947 chore: sidebar content wrapper and code refactor 2025-09-23 14:11:34 +05:30
52 changed files with 200 additions and 1622 deletions
+2 -2
View File
@@ -66,7 +66,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if intake is None or not project.intake_view:
if intake is None and 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 or not project.intake_view:
if intake is None and not project.intake_view:
return IntakeIssue.objects.none()
return (
-5
View File
@@ -10,11 +10,6 @@ from plane.app.views import (
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
PageViewSet.as_view({"get": "summary"}),
name="project-pages-summary",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view({"get": "list", "post": "create"}),
+3 -75
View File
@@ -6,17 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
from django.db.models import (
Exists,
OuterRef,
Q,
Value,
UUIDField,
Count,
Case,
When,
IntegerField,
)
from django.db.models import Exists, OuterRef, Q, Value, UUIDField
from django.http import StreamingHttpResponse
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
@@ -52,7 +42,6 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.app.permissions import ProjectPagePermission
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
sql = """
@@ -196,9 +185,7 @@ class PageViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Page.DoesNotExist:
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
{"error": "Access cannot be updated since this page is owned by someone else"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -279,9 +266,7 @@ class PageViewSet(BaseViewSet):
and page.owned_by_id != request.user.id
):
return Response(
{
"error": "Access cannot be updated since this page is owned by someone else"
},
{"error": "Access cannot be updated since this page is owned by someone else"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -407,62 +392,6 @@ class PageViewSet(BaseViewSet):
).delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
def summary(self, request, slug, project_id):
queryset = (
Page.objects.filter(workspace__slug=slug)
.filter(
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=request.user) | Q(access=0))
.annotate(
project=Exists(
ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")
)
)
)
.filter(project=True)
.distinct()
)
project = Project.objects.get(pk=project_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
):
queryset = queryset.filter(owned_by=request.user)
stats = queryset.aggregate(
public_pages=Count(
Case(
When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
private_pages=Count(
Case(
When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1),
output_field=IntegerField(),
)
),
archived_pages=Count(
Case(
When(archived_at__isnull=False, then=1), output_field=IntegerField()
)
),
)
return Response(stats, status=status.HTTP_200_OK)
class PageFavoriteViewSet(BaseViewSet):
model = UserFavorite
@@ -577,7 +506,6 @@ class PagesDescriptionViewSet(BaseViewSet):
class PageDuplicateEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
@@ -20,7 +20,6 @@ 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
@@ -42,7 +41,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@@ -89,20 +88,8 @@ 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) {
@@ -129,34 +116,27 @@ const WorkspaceMembersSettingsPage = observer(() => {
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
)}
</h4>
<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"
<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)}
/>
{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,14 +2,10 @@ 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;
@@ -24,15 +20,9 @@ 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],
@@ -43,11 +33,11 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
const currentProjectRole =
getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST;
const displayFilters = getFilters(projectId);
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
// handlers
const handleDisplayFilterUpdate = (filters: Partial<IMemberFilters>) => {
updateFilters(projectId, filters);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
const columns = [
@@ -55,13 +45,6 @@ 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}
@@ -75,37 +58,12 @@ 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}
@@ -118,21 +76,8 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => {
{
key: "Joining Date",
content: "Joining date",
thRender: () => (
<MemberHeaderColumn
property="joining_date"
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
},
];
return {
columns,
removeMemberModal,
setRemoveMemberModal,
displayFilters,
handleDisplayFilterUpdate,
};
return { columns, removeMemberModal, setRemoveMemberModal };
};
+1
View File
@@ -1,2 +1,3 @@
export * from "./app-switcher";
export * from "./project-navigation-root";
export * from "./sidebar-content-wrapper";
@@ -0,0 +1,11 @@
"use client";
type TSidebarContentWrapperProps = {
children: React.ReactNode;
};
export const SidebarContentWrapper = ({ children }: TSidebarContentWrapperProps) => (
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3">
{children}
</div>
);
@@ -1,12 +1,15 @@
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import packageJson from "package.json";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { Button } from "@plane/ui";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
// hooks
import { useAppRail } from "@/hooks/use-app-rail";
import { usePlatformOS } from "@/hooks/use-platform-os";
import packageJson from "package.json";
// local components
import { PaidPlanUpgradeModal } from "../license";
@@ -15,11 +18,14 @@ export const WorkspaceEditionBadge = observer(() => {
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
// translation
const { t } = useTranslation();
// hooks
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
// platform
const { isMobile } = usePlatformOS();
return (
<>
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<PaidPlanUpgradeModal
isOpen={isPaidPlanPurchaseModalOpen}
handleClose={() => setIsPaidPlanPurchaseModalOpen(false)}
@@ -36,6 +42,10 @@ export const WorkspaceEditionBadge = observer(() => {
Community
</Button>
</Tooltip>
</>
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
);
});
@@ -2,12 +2,8 @@ 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
@@ -17,33 +13,23 @@ 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}
@@ -58,39 +44,18 @@ 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} />,
},
@@ -105,14 +70,7 @@ export const useMemberColumns = () => {
{
key: "Joining date",
content: t("workspace_settings.settings.members.details.joining_date"),
thRender: () => (
<MemberHeaderColumn
property="joining_date"
displayFilters={filters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
),
tdRender: (rowData: RowData) => <div>{renderFormattedDate(rowData?.member?.joining_date)}</div>,
tdRender: (rowData: RowData) => <div>{getFormattedDate(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/project/base-project-member.store";
import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store";
export type IProjectMemberStore = IBaseProjectMemberStore;
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
// plane package imports
import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal";
import { createPortal } from "react-dom";
import { ICycle, IModule, IProject } from "@plane/types";
import { cn } from "@plane/utils";
import { useAnalytics } from "@/hooks/store/use-analytics";
// plane web components
import { WorkItemsModalMainContent } from "./content";
@@ -31,35 +32,41 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
updateIsEpic(isPeekView ? (isEpic ?? false) : false);
}, [isEpic, updateIsEpic, isPeekView]);
return (
<ModalPortal
isOpen={isOpen}
onClose={handleClose}
width={fullScreen ? EPortalWidth.FULL : EPortalWidth.THREE_QUARTER}
position={EPortalPosition.RIGHT}
fullScreen={fullScreen}
>
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
if (!isOpen) return null;
const content = (
<div className={cn("z-[25] h-full w-full overflow-y-auto absolute")}>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
className={`top-0 right-0 z-[25] h-full bg-custom-background-100 shadow-custom-shadow-md absolute ${
fullScreen ? "w-full p-2" : "w-1/2"
}`}
>
<WorkItemsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={projectDetails?.name ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
isEpic={isEpic}
/>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<WorkItemsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={projectDetails?.name ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
isEpic={isEpic}
/>
</div>
</div>
</ModalPortal>
</div>
);
return portalContainer ? createPortal(content, portalContainer) : content;
});
@@ -122,7 +122,7 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
canEdit={!disabled}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={issueServiceType}
@@ -13,7 +13,7 @@ interface TSubIssuesListGroupProps {
workspaceSlug: string;
group: IGroupByColumn;
serviceType: TIssueServiceType;
canEdit: boolean;
disabled: boolean;
parentIssueId: string;
rootIssueId: string;
handleIssueCrudState: (
@@ -30,7 +30,7 @@ export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props)
const {
group,
serviceType,
canEdit,
disabled,
parentIssueId,
rootIssueId,
projectId,
@@ -81,7 +81,7 @@ export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props)
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
issueId={workItemId}
canEdit={canEdit}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
issueServiceType={serviceType}
@@ -28,7 +28,7 @@ type Props = {
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
@@ -48,7 +48,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
rootIssueId,
issueId,
spacingLeft = 10,
canEdit,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
@@ -174,7 +174,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
parentIssueId={parentIssueId}
issueId={issueId}
canEdit={canEdit}
disabled={disabled}
updateSubIssue={subIssueOperations.updateSubIssue}
displayProperties={displayProperties}
issue={issue}
@@ -183,7 +183,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{canEdit && (
{disabled && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
@@ -212,7 +212,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
</div>
</CustomMenu.MenuItem>
{canEdit && (
{disabled && (
<CustomMenu.MenuItem
onClick={(e) => {
e.stopPropagation();
@@ -230,7 +230,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
</CustomMenu.MenuItem>
)}
{canEdit && (
{disabled && (
<CustomMenu.MenuItem
onClick={(e) => {
e.stopPropagation();
@@ -263,7 +263,7 @@ export const SubIssuesListItem: React.FC<Props> = observer((props) => {
parentIssueId={issue.id}
rootIssueId={rootIssueId}
spacingLeft={spacingLeft + 22}
canEdit={canEdit}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
@@ -19,7 +19,7 @@ type Props = {
workspaceSlug: string;
parentIssueId: string;
issueId: string;
canEdit: boolean;
disabled: boolean;
updateSubIssue: (
workspaceSlug: string,
projectId: string,
@@ -33,7 +33,7 @@ type Props = {
};
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, canEdit, updateSubIssue, displayProperties, issue } = props;
const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;
const { t } = useTranslation();
const { getStateById } = useProjectState();
@@ -94,7 +94,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
{ ...issue }
)
}
disabled={!canEdit}
disabled={!disabled}
buttonVariant="transparent-without-text"
buttonClassName="hover:bg-transparent px-0"
iconSize="size-5"
@@ -113,7 +113,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
priority: val,
})
}
disabled={!canEdit}
disabled={!disabled}
buttonVariant="border-without-text"
buttonClassName="border"
showTooltip
@@ -144,7 +144,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
disabled={!canEdit}
disabled={!disabled}
showTooltip
customTooltipHeading="Date Range"
renderPlaceholder={false}
@@ -167,7 +167,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!canEdit}
disabled={!disabled}
showTooltip
/>
</div>
@@ -190,7 +190,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
buttonClassName={shouldHighlight ? "text-red-500" : ""}
clearIconClassName="text-custom-text-100"
optionsClassName="z-30"
disabled={!canEdit}
disabled={!disabled}
showTooltip
/>
</div>
@@ -207,7 +207,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
assignee_ids: val,
})
}
disabled={!canEdit}
disabled={!disabled}
multiple
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
@@ -24,7 +24,7 @@ type Props = {
parentIssueId: string;
rootIssueId: string;
spacingLeft: number;
canEdit: boolean;
disabled: boolean;
handleIssueCrudState: (
key: "create" | "existing" | "update" | "delete",
issueId: string,
@@ -41,7 +41,7 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
projectId,
parentIssueId,
rootIssueId,
canEdit,
disabled,
handleIssueCrudState,
subIssueOperations,
issueServiceType = EIssueServiceType.ISSUES,
@@ -116,7 +116,7 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
group={group}
serviceType={issueServiceType}
canEdit={canEdit}
disabled={disabled}
parentIssueId={parentIssueId}
rootIssueId={rootIssueId}
handleIssueCrudState={handleIssueCrudState}
@@ -29,8 +29,7 @@ import { IssueTitleInput } from "../title-input";
// services init
const workItemVersionService = new WorkItemVersionService();
type Props = {
editorRef: React.RefObject<EditorRefApi>;
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
projectId: string;
issueId: string;
@@ -39,11 +38,12 @@ type Props = {
isArchived: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: (value: TNameDescriptionLoader) => void;
};
}
export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } =
props;
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { data: currentUser } = useUser();
const {
@@ -2,7 +2,6 @@ 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
@@ -54,7 +53,6 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const {
setPeekIssue,
@@ -82,9 +80,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
usePeekOverviewOutsideClickDetector(
issuePeekOverviewRef,
() => {
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (!embedIssue) {
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen && !isAnyDropbarOpen) {
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen) {
removeRoutePeekId();
}
}
@@ -93,10 +90,10 @@ export const IssueView: FC<IIssueView> = 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";
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement && !editorImageFullScreenModalElement) {
removeRoutePeekId();
const issueElement = document.getElementById(`issue-${issueId}`);
if (issueElement) issueElement?.focus();
@@ -169,7 +166,6 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-3 px-8 py-5 space-y-3">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
@@ -210,7 +206,6 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
<div className="space-y-3">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
@@ -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 { Loader } from "@plane/ui";
import { isJSONContentEmpty } from "@plane/utils";
import { Loader } from "@plane/ui";
// components
import { DocumentEditor } from "@/components/editor/document/editor";
// hooks
@@ -1,106 +0,0 @@
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>
);
});
@@ -1,114 +0,0 @@
// 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/project/base-project-member.store";
import { IProjectMemberDetails } from "@/store/member/base-project-member.store";
// local imports
import { ConfirmProjectMemberRemove } from "./confirm-project-member-remove";
@@ -13,7 +13,6 @@ 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";
@@ -28,14 +27,14 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
project: { projectMemberIds, getProjectMemberDetails },
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member || !memberDetails.original_role) return false;
@@ -44,31 +43,12 @@ export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((pr
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
});
const memberDetails = searchedProjectMembers?.map((memberId) =>
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
projectId ? getProjectMemberDetails(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
@@ -79,35 +59,28 @@ 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="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"
<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)}
/>
{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 />
@@ -5,12 +5,12 @@ import { useOutsideClickDetector } from "@plane/hooks";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { SidebarContentWrapper } from "@/plane-web/components/sidebar";
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
type TSidebarWrapperProps = {
@@ -40,7 +40,7 @@ export const SidebarWrapper: FC<TSidebarWrapperProps> = observer((props) => {
}, [windowSize]);
return (
<div ref={ref} className="flex flex-col h-full w-full">
<div ref={ref} className="relative flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!shouldRenderAppRail && <SidebarDropdown />}
@@ -56,17 +56,9 @@ export const SidebarWrapper: FC<TSidebarWrapperProps> = observer((props) => {
{/* Quick actions */}
{quickActions}
</div>
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3 pb-0.5">
{children}
</div>
<SidebarContentWrapper>{children}</SidebarContentWrapper>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
<WorkspaceEditionBadge />
</div>
);
});
@@ -28,7 +28,6 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
fetchWorkspaceMembers,
fetchWorkspaceMemberInvitations,
workspaceMemberIds,
getFilteredWorkspaceMemberIds,
getSearchedWorkspaceMemberIds,
workspaceMemberInvitationIds,
getSearchedWorkspaceInvitationIds,
@@ -50,8 +49,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
// derived values
const filteredMemberIds = workspaceSlug ? getFilteredWorkspaceMemberIds(workspaceSlug.toString()) : [];
const searchedMemberIds = searchQuery ? getSearchedWorkspaceMemberIds(searchQuery) : filteredMemberIds;
const searchedMemberIds = getSearchedWorkspaceMemberIds(searchQuery);
const searchedInvitationsIds = getSearchedWorkspaceInvitationIds(searchQuery);
const memberDetails = searchedMemberIds?.map((memberId) => getWorkspaceMemberDetails(memberId));
+1 -1
View File
@@ -29,7 +29,7 @@ import {
// root store
import { IWorkspaceIssues, WorkspaceIssues } from "@/plane-web/store/issue/workspace/issue.store";
import type { RootStore } from "@/plane-web/store/root.store";
import { IWorkspaceMembership } from "@/store/member/workspace/workspace-member.store";
import { IWorkspaceMembership } from "@/store/member/workspace-member.store";
// issues data store
import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedIssues } from "./archived";
import { ICycleIssuesFilter, CycleIssuesFilter, ICycleIssues, CycleIssues } from "./cycle";
@@ -13,13 +13,11 @@ 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 { IMemberRootStore } from "../index";
import { sortProjectMembers } from "../utils";
import { ProjectMemberFiltersStore, IProjectMemberFiltersStore } from "./project-member-filters.store";
import { IProjectStore } from "../project/project.store";
import { IMemberRootStore } from ".";
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
member: IUserLite;
@@ -33,15 +31,12 @@ 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,
@@ -72,8 +67,6 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
} = {};
// filters store
filters: IProjectMemberFiltersStore;
// stores
routerStore: IRouterStore;
userStore: IUserStore;
@@ -95,40 +88,31 @@ 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;
const members = Object.values(this.projectMemberMap?.[projectId] ?? {});
let members = Object.values(this.projectMemberMap?.[projectId] ?? {});
if (members.length === 0) return null;
// 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);
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;
}
/**
@@ -218,41 +202,6 @@ 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
+1 -1
View File
@@ -6,7 +6,7 @@ import { IUserLite } from "@plane/types";
import { IProjectMemberStore, ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import type { RootStore } from "@/plane-web/store/root.store";
// local imports
import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace/workspace-member.store";
import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace-member.store";
export interface IMemberRootStore {
// observables
@@ -1,70 +0,0 @@
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
View File
@@ -1,163 +0,0 @@
// 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
);
};
@@ -12,9 +12,8 @@ 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 "../index.ts";
import { WorkspaceMemberFiltersStore, IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
import type { CoreRootStore } from "../root.store";
import type { IMemberRootStore } from ".";
export interface IWorkspaceMembership {
id: string;
@@ -27,15 +26,12 @@ 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;
@@ -62,8 +58,6 @@ 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;
@@ -88,8 +82,7 @@ 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;
@@ -133,25 +126,6 @@ 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
@@ -159,9 +133,9 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
getSearchedWorkspaceMemberIds = computedFn((searchQuery: string) => {
const workspaceSlug = this.routerStore.workspaceSlug;
if (!workspaceSlug) return null;
const filteredMemberIds = this.getFilteredWorkspaceMemberIds(workspaceSlug);
if (!filteredMemberIds) return null;
const searchedWorkspaceMemberIds = filteredMemberIds.filter((userId) => {
const workspaceMemberIds = this.workspaceMemberIds;
if (!workspaceMemberIds) return null;
const searchedWorkspaceMemberIds = workspaceMemberIds?.filter((userId) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) return false;
const memberSearchQuery = `${memberDetails.member.first_name} ${memberDetails.member.last_name} ${
@@ -1,71 +0,0 @@
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 };
};
}
-1
View File
@@ -15,7 +15,6 @@ 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
View File
@@ -1,79 +0,0 @@
// 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,
},
};
@@ -155,7 +155,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
{!isSelecting && (
<div
ref={menuRef}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg overflow-x-scroll horizontal-scrollbar scrollbar-xs"
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg max-w-[500px] overflow-x-scroll horizontal-scrollbar scrollbar-xs"
>
<div className="px-2">
<BubbleMenuNodeSelector
@@ -55,14 +55,11 @@ const Command = Extension.create<SlashCommandOptions>({
},
});
const renderItems: SuggestionOptions["render"] = () => {
const renderItems = () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
let popup: Instance | null = null;
return {
onStart: (props) => {
// Track active dropdown
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
props,
editor: props.editor,
@@ -81,14 +78,14 @@ const renderItems: SuggestionOptions["render"] = () => {
placement: "bottom-start",
});
},
onUpdate: (props) => {
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component?.updateProps(props);
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props) => {
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
@@ -98,9 +95,7 @@ const renderItems: SuggestionOptions["render"] = () => {
}
return false;
},
onExit: ({ editor }) => {
// Remove from active dropdowns
editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
@@ -15,8 +15,6 @@ 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,
@@ -61,16 +59,7 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
}),
],
open: isDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
if (open) {
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
} else {
setTimeout(() => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
}, 0);
}
},
onOpenChange: setIsDropdownOpen,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
@@ -196,6 +185,7 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
}}
lockScroll
/>
<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
@@ -205,7 +195,7 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
zIndex: 100,
}}
>
<ColumnOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
<ColumnOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
</div>
</FloatingPortal>
)}
@@ -15,8 +15,6 @@ 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,
@@ -61,16 +59,7 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
}),
],
open: isDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
if (open) {
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
} else {
setTimeout(() => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
}, 0);
}
},
onOpenChange: setIsDropdownOpen,
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
@@ -195,6 +184,7 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
}}
lockScroll
/>
<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
@@ -204,7 +194,7 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
zIndex: 100,
}}
>
<RowOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
<RowOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
</div>
</FloatingPortal>
)}
+3 -22
View File
@@ -9,18 +9,13 @@ 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
| CORE_EXTENSIONS.SLASH_COMMANDS
| CORE_EXTENSIONS.TABLE
| TAdditionalActiveDropbarExtensions;
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions;
declare module "@tiptap/core" {
interface Commands {
[CORE_EXTENSIONS.UTILITY]: {
utility: {
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
updateAssetsList: (
args:
@@ -31,8 +26,6 @@ declare module "@tiptap/core" {
idToRemove: string;
}
) => () => void;
addActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
};
}
}
@@ -109,18 +102,6 @@ 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);
}
},
};
},
});
@@ -81,11 +81,6 @@ 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);
-1
View File
@@ -119,7 +119,6 @@ export type EditorRefApi = {
getMarkDown: () => string;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
isAnyDropbarOpen: () => boolean;
isEditorReadyToDiscard: () => boolean;
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
+1 -1
View File
@@ -205,7 +205,7 @@ ul[data-type="taskList"] li[data-checked="true"] {
/* Overwrite tippy-box original max-width */
.tippy-box {
max-width: 800px !important;
max-width: 600px !important;
}
.fade-in {
@@ -2405,12 +2405,5 @@
"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"
}
}
-1
View File
@@ -35,7 +35,6 @@
"./menu": "./dist/menu/index.js",
"./pill": "./dist/pill/index.js",
"./popover": "./dist/popover/index.js",
"./portal": "./dist/portal/index.js",
"./scrollarea": "./dist/scrollarea/index.js",
"./skeleton": "./dist/skeleton/index.js",
"./styles/fonts": "./dist/styles/fonts/index.css",
+5 -8
View File
@@ -41,14 +41,11 @@ const OVERLAY_CLASSNAME = cn("fixed inset-0 z-backdrop bg-custom-backdrop");
const BASE_CLASSNAME = "relative text-left bg-custom-background-100 rounded-lg shadow-md w-full z-modal";
// Utility functions
const getPositionClassNames = React.useCallback(
(position: DialogPosition) =>
cn("isolate fixed z-modal", {
"top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2": position === "center",
"top-8 left-1/2 -translate-x-1/2": position === "top",
}),
[]
);
const getPositionClassNames = (position: DialogPosition) =>
cn("isolate fixed z-modal", {
"top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2": position === "center",
"top-8 left-1/2 -translate-x-1/2": position === "top",
});
const DialogPortal = React.memo<React.ComponentProps<typeof BaseDialog.Portal>>(({ children, ...props }) => (
<BaseDialog.Portal data-slot="dialog-portal" {...props}>
-28
View File
@@ -1,28 +0,0 @@
export enum EPortalWidth {
QUARTER = "quarter",
HALF = "half",
THREE_QUARTER = "three-quarter",
FULL = "full",
}
export enum EPortalPosition {
LEFT = "left",
RIGHT = "right",
CENTER = "center",
}
export const PORTAL_WIDTH_CLASSES = {
[EPortalWidth.QUARTER]: "w-1/4 min-w-80 max-w-96",
[EPortalWidth.HALF]: "w-1/2 min-w-96 max-w-2xl",
[EPortalWidth.THREE_QUARTER]: "w-3/4 min-w-96 max-w-5xl",
[EPortalWidth.FULL]: "w-full",
} as const;
export const PORTAL_POSITION_CLASSES = {
[EPortalPosition.LEFT]: "left-0",
[EPortalPosition.RIGHT]: "right-0",
[EPortalPosition.CENTER]: "left-1/2 -translate-x-1/2",
} as const;
export const DEFAULT_PORTAL_ID = "full-screen-portal";
export const MODAL_Z_INDEX = 25;
-4
View File
@@ -1,4 +0,0 @@
export * from "./modal-portal";
export * from "./portal-wrapper";
export * from "./constants";
export * from "./types";
-110
View File
@@ -1,110 +0,0 @@
import React, { useCallback, useMemo, useRef, useEffect } from "react";
import { cn } from "../utils";
import {
EPortalWidth,
EPortalPosition,
PORTAL_WIDTH_CLASSES,
PORTAL_POSITION_CLASSES,
DEFAULT_PORTAL_ID,
MODAL_Z_INDEX,
} from "./constants";
import { PortalWrapper } from "./portal-wrapper";
import { ModalPortalProps } from "./types";
/**
* @param children - The modal content to render
* @param isOpen - Whether the modal is open
* @param onClose - Function to call when modal should close
* @param portalId - The ID of the DOM element to render into
* @param className - Custom className for the modal container
* @param overlayClassName - Custom className for the overlay
* @param contentClassName - Custom className for the content area
* @param width - Predefined width options using EPortalWidth enum
* @param position - Position of the modal using EPortalPosition enum
* @param fullScreen - Whether to render in fullscreen mode
* @param showOverlay - Whether to show background overlay
* @param closeOnOverlayClick - Whether clicking overlay closes modal
* @param closeOnEscape - Whether pressing Escape closes modal
*/
export const ModalPortal: React.FC<ModalPortalProps> = ({
children,
isOpen,
onClose,
portalId = DEFAULT_PORTAL_ID,
className,
overlayClassName,
contentClassName,
width = EPortalWidth.HALF,
position = EPortalPosition.RIGHT,
fullScreen = false,
showOverlay = true,
closeOnOverlayClick = true,
closeOnEscape = true,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
// Memoized overlay click handler
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (closeOnOverlayClick && onClose && e.target === e.currentTarget) {
onClose();
}
},
[closeOnOverlayClick, onClose]
);
// close on escape
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (closeOnEscape && onClose && e.key === "Escape") {
onClose();
}
},
[closeOnEscape, onClose]
);
// add event listener for escape
useEffect(() => {
if (!isOpen) return;
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, handleEscape]);
// Memoized style classes
const modalClasses = useMemo(() => {
const widthClass = fullScreen ? "w-full h-full" : PORTAL_WIDTH_CLASSES[width];
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
return cn(
"top-0 h-full bg-white shadow-lg absolute transition-transform duration-300 ease-out",
widthClass,
positionClass,
contentClassName
);
}, [fullScreen, width, position, contentClassName]);
if (!isOpen) return null;
const content = (
<div
className={cn("absolute inset-0 h-full w-full overflow-y-auto", className)}
style={{ zIndex: MODAL_Z_INDEX }}
role="dialog"
>
{showOverlay && (
<div
className={cn("absolute inset-0 bg-black bg-opacity-50 transition-opacity duration-300", overlayClassName)}
onClick={handleOverlayClick}
aria-hidden="true"
/>
)}
<div ref={contentRef} className={cn(modalClasses)} style={{ zIndex: MODAL_Z_INDEX + 1 }} role="document">
{children}
</div>
</div>
);
return <PortalWrapper portalId={portalId}>{content}</PortalWrapper>;
};
@@ -1,76 +0,0 @@
import React, { useLayoutEffect, useState, useMemo } from "react";
import { createPortal } from "react-dom";
import { DEFAULT_PORTAL_ID } from "./constants";
import { PortalWrapperProps } from "./types";
/**
* PortalWrapper - A reusable portal component that renders children into a specific DOM element
* Optimized for SSR compatibility and performance
*
* @param children - The content to render inside the portal
* @param portalId - The ID of the DOM element to render into
* @param fallbackToDocument - Whether to render directly if portal container is not found
* @param className - Optional className to apply to the portal container div
* @param onMount - Callback fired when portal is mounted
* @param onUnmount - Callback fired when portal is unmounted
*/
export const PortalWrapper: React.FC<PortalWrapperProps> = ({
children,
portalId = DEFAULT_PORTAL_ID,
fallbackToDocument = true,
className,
onMount,
onUnmount,
}) => {
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
const [isMounted, setIsMounted] = useState(false);
useLayoutEffect(() => {
// Ensure we're in browser environment
if (typeof window === "undefined") return;
let container = document.getElementById(portalId);
// Create portal container if it doesn't exist
if (!container) {
container = document.createElement("div");
container.id = portalId;
container.setAttribute("data-portal", "true");
document.body.appendChild(container);
}
setPortalContainer(container);
setIsMounted(true);
onMount?.();
return () => {
onUnmount?.();
// Only remove if we created it and it's empty
if (container && container.children.length === 0 && container.hasAttribute("data-portal")) {
document.body.removeChild(container);
}
};
}, [portalId, onMount, onUnmount]);
const content = useMemo(() => {
if (!children) return null;
return className ? <div className={className}>{children}</div> : children;
}, [children, className]);
// SSR: render nothing on server
if (!isMounted) {
return null;
}
// If portal container exists, render into it
if (portalContainer) {
return createPortal(content, portalContainer);
}
// Fallback behavior for client-side rendering
if (fallbackToDocument) {
return content ? (content as React.ReactElement) : null;
}
return null;
};
@@ -1,222 +0,0 @@
import React, { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button, EButtonVariant, EButtonSize } from "../button/button";
import { EPortalWidth, EPortalPosition } from "./constants";
import { ModalPortal, PortalWrapper } from "./";
const meta: Meta<typeof ModalPortal> = {
title: "Components/Portal/ModalPortal",
component: ModalPortal,
parameters: {
layout: "centered",
docs: {
description: {
component: `
A high-performance, accessible modal portal component with comprehensive features:
Perfect for modals, drawers, overlays, and any UI that needs to appear above other content.
`,
},
},
},
tags: ["autodocs"],
argTypes: {
width: {
control: "select",
options: Object.values(EPortalWidth),
description: "Modal width preset",
},
position: {
control: "select",
options: Object.values(EPortalPosition),
description: "Modal position on screen",
},
fullScreen: {
control: "boolean",
description: "Render modal in fullscreen mode",
},
showOverlay: {
control: "boolean",
description: "Show/hide background overlay",
},
closeOnOverlayClick: {
control: "boolean",
description: "Close modal when clicking overlay",
},
closeOnEscape: {
control: "boolean",
description: "Close modal when pressing Escape",
},
},
};
export default meta;
type Story = StoryObj<typeof ModalPortal>;
// Helper component for interactive stories
const ModalDemo = ({
children,
buttonText = "Open Modal",
buttonVariant = EButtonVariant.PRIMARY,
...modalProps
}: Omit<Parameters<typeof ModalPortal>[0], "isOpen" | "onClose"> & {
buttonText?: string;
buttonVariant?: Parameters<typeof Button>[0]["variant"];
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button variant={buttonVariant} onClick={() => setIsOpen(true)}>
{buttonText}
</Button>
<ModalPortal {...modalProps} isOpen={isOpen} onClose={() => setIsOpen(false)}>
{children}
</ModalPortal>
</>
);
};
const ModalContent = ({
title = "Modal Title",
showCloseButton = true,
description = "This is a modal portal component with full accessibility support. Try pressing Tab to navigate through elements or Escape to close.",
onClose,
}: {
title?: string;
showCloseButton?: boolean;
description?: string;
onClose?: () => void;
}) => (
<div className="flex flex-col h-full bg-white">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
<p className="text-sm text-gray-500 mt-1">Modal demonstration</p>
</div>
{showCloseButton && onClose && (
<Button variant={EButtonVariant.GHOST} size={EButtonSize.SM} onClick={onClose} aria-label="Close modal">
</Button>
)}
</div>
<div className="flex-1 p-6 overflow-y-auto">
<p className="text-gray-600 mb-6">{description}</p>
<div className="space-y-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">Feature Highlights</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> ESC key closes the modal</li>
<li> Click outside overlay to close</li>
</ul>
</div>
</div>
</div>
</div>
);
export const Default: Story = {
render: () => (
<ModalDemo buttonText="Open Modal">
<ModalContent
title="Default Modal"
description="A standard modal with all default settings. Demonstrates focus management, keyboard navigation, and accessibility features."
/>
</ModalDemo>
),
};
export const Positions: Story = {
name: "Different Positions",
render: () => {
const [activeModal, setActiveModal] = useState<EPortalPosition | null>(null);
return (
<div className="flex gap-3">
{Object.values(EPortalPosition).map((position) => (
<React.Fragment key={position}>
<Button variant={EButtonVariant.OUTLINE} onClick={() => setActiveModal(position)}>
{position.charAt(0).toUpperCase() + position.slice(1)}
</Button>
<ModalPortal
isOpen={activeModal === position}
onClose={() => setActiveModal(null)}
width={EPortalWidth.HALF}
position={position}
>
<ModalContent
title={`${position.charAt(0).toUpperCase() + position.slice(1)} Modal`}
description={`This modal is positioned at ${position}. Try different positions to see how the modal appears in different areas of the screen.`}
onClose={() => setActiveModal(null)}
/>
</ModalPortal>
</React.Fragment>
))}
</div>
);
},
};
export const Widths: Story = {
name: "Different Widths",
render: () => {
const [activeModal, setActiveModal] = useState<EPortalWidth | null>(null);
return (
<div className="flex gap-3">
{Object.values(EPortalWidth).map((width) => (
<React.Fragment key={width}>
<Button variant={EButtonVariant.SECONDARY} onClick={() => setActiveModal(width)}>
{width.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())}
</Button>
<ModalPortal
isOpen={activeModal === width}
onClose={() => setActiveModal(null)}
width={width}
position={EPortalPosition.RIGHT}
>
<ModalContent
title={`${width.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} Width`}
description={`This modal uses ${width} width. Compare different widths to find the perfect size for your content.`}
onClose={() => setActiveModal(null)}
/>
</ModalPortal>
</React.Fragment>
))}
</div>
);
},
};
// PortalWrapper Stories
const PortalWrapperMeta: Meta<typeof PortalWrapper> = {
title: "Components/Portal/PortalWrapper",
component: PortalWrapper,
parameters: {
layout: "centered",
docs: {
description: {
component: `
The PortalWrapper is a low-level component that handles rendering content into DOM portals.
It's used internally by ModalPortal but can also be used directly for custom portal needs.`,
},
},
},
tags: ["autodocs"],
};
export const BasicPortal: StoryObj<typeof PortalWrapper> = {
render: () => (
<div className="relative">
<p>This content renders in the normal document flow.</p>
<PortalWrapper portalId="storybook-portal">
<div className="fixed top-4 right-4 p-4 bg-blue-500 text-white rounded shadow-lg z-50">
This content is rendered in a portal!
</div>
</PortalWrapper>
</div>
),
parameters: {
...PortalWrapperMeta.parameters,
},
};
-32
View File
@@ -1,32 +0,0 @@
import type { ReactNode, MouseEvent as ReactMouseEvent } from "react";
import { EPortalWidth, EPortalPosition } from "./constants";
export interface BasePortalProps {
children: ReactNode;
className?: string;
}
export interface PortalWrapperProps extends BasePortalProps {
portalId?: string;
fallbackToDocument?: boolean;
onMount?: () => void;
onUnmount?: () => void;
}
export interface ModalPortalProps extends BasePortalProps {
isOpen: boolean;
onClose?: () => void;
portalId?: string;
overlayClassName?: string;
contentClassName?: string;
width?: EPortalWidth;
position?: EPortalPosition;
fullScreen?: boolean;
showOverlay?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
}
export type PortalEventHandler = () => void;
export type PortalKeyboardHandler = (event: KeyboardEvent) => void;
export type PortalMouseHandler = (event: ReactMouseEvent) => void;
-1
View File
@@ -21,7 +21,6 @@ export default defineConfig({
"src/menu/index.ts",
"src/pill/index.ts",
"src/popover/index.ts",
"src/portal/index.ts",
"src/scrollarea/index.ts",
"src/skeleton/index.ts",
"src/switch/index.ts",