Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0761183947 |
@@ -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 (
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
-40
@@ -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,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}
|
||||
|
||||
+3
-3
@@ -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}
|
||||
|
||||
+7
-7
@@ -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}
|
||||
/>
|
||||
|
||||
+8
-8
@@ -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" : ""}
|
||||
|
||||
+3
-3
@@ -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));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
+10
-61
@@ -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
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
+6
-32
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
+3
-13
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./modal-portal";
|
||||
export * from "./portal-wrapper";
|
||||
export * from "./constants";
|
||||
export * from "./types";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user