ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker

Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
DCCONSTRUCTIONS
2026-05-12 17:28:40 +03:00
parent fc59481703
commit 480f85cce8
23 changed files with 993 additions and 72 deletions
@@ -6,7 +6,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertTriangle } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
@@ -22,12 +21,11 @@ type Props = {
onSubmit: () => Promise<void>;
isOpen: boolean;
onClose: () => void;
projectId: string;
};
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
const { data, onSubmit, isOpen, onClose } = props;
// router
const { projectId } = useParams();
const { data, onSubmit, isOpen, onClose, projectId } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
@@ -50,44 +48,62 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
const currentProjectDetails = getProjectById(projectId);
const memberName = data?.display_name || "участника";
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
>
<div className="p-6 sm:p-7">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-16 leading-6 font-medium text-primary">
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
<div className="min-w-0">
<p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
<h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
</h3>
<div className="mt-2">
<p className="text-13 text-secondary">
{isCurrentUser ? (
<>
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
project? You will be able to join the project if invited again or if it{"'"}s public.
</>
) : (
<>
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
They will no longer have access to this project. This action cannot be undone.
</>
)}
</p>
</div>
<p className="mt-3 text-13 leading-6 text-secondary">
{isCurrentUser ? (
<>
Вы потеряете доступ к проекту{" "}
<span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
будет только после нового приглашения или если проект публичный.
</>
) : (
<>
Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
проекту. Действие нельзя отменить автоматически.
</>
)}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
</Button>
<div className="mt-7 flex justify-end gap-3">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отмена
</Button>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isDeleteLoading}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
</Button>
</div>
</div>
</ModalCore>
);
@@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => {
router.push(`/${workspaceSlug}/projects`);
return undefined;
})
.catch((err) => {
setToast({
@@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
title: "You cant leave this project yet.",
message: err?.error || "Something went wrong. Please try again.",
});
return undefined;
});
} else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
@@ -73,6 +75,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
<ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)}
projectId={projectId}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
@@ -82,7 +85,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
@@ -69,12 +69,12 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
<SearchSelectionDropdown
value={value}
label={
<div className="flex h-3.5 items-center gap-2">
<div className="flex min-h-5 min-w-0 items-center gap-2">
{selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)}
{selectedOption ? (
selectedOption.member?.display_name
<span className="truncate">{selectedOption.member?.display_name}</span>
) : (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
@@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberSelect } from "./member-select";
@@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
// store hooks
const { allowPermissions } = useUserPermissions();
const {
project: { fetchProjectMembers, getProjectMemberFetchStatus },
} = useMember();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
@@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
useEffect(() => {
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
useEffect(() => {
if (!currentProjectDetails) return;
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
@@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
{workspaceSlug && activeProjectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
@@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
size="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
<div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
<ProjectSettingsModalContent
activeTab={activeTab}
projectId={activeProjectId}
@@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
<div className="flex justify-end gap-3 p-4 sm:px-6">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отменить
</Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isRemoving}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{currentUser?.id === userDetails.id
? isRemoving
? "Выход..."
@@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
<div className="w-full overflow-visible rounded-[1.2rem]">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
@@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
@@ -49,7 +49,14 @@ import {
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
"general",
"members",
"export",
"storage",
"webhooks",
"ai-voice-tasker",
]);
const workspaceAIService = new WorkspaceAIService();
const workspaceService = new WorkspaceService();
@@ -77,7 +84,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const { t } = useTranslation();
const canLoadVoiceTaskerEntitlement =
!!currentWorkspace?.slug &&
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
allowPermissions(
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
EUserPermissionsLevel.WORKSPACE,
currentWorkspace.slug
);
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
@@ -94,7 +105,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
setIsOpen(Boolean(tab));
if (tab) setActiveTab(tab);
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
setActiveWebhookId(
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
);
};
const handleModalEvent = (event: Event) => {
@@ -162,7 +175,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
}
if (activeTab === "webhooks" && currentWorkspace?.slug) {
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
return (
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
);
}
return <WorkspaceDetails />;
@@ -178,7 +193,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
<div className="flex h-full min-h-0">
<div className="hidden h-full w-[296px] shrink-0 md:block">
@@ -212,7 +227,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
</div>
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
<div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
</ScrollArea>
</div>
</div>
@@ -260,7 +275,7 @@ function WorkspaceModalSidebar({
return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(category)}
</div>
<div className="flex flex-col">