UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректный flow уведомлений Tasker

This commit is contained in:
DCCONSTRUCTIONS
2026-05-12 15:11:17 +03:00
parent 6737138ab7
commit fc59481703
17 changed files with 717 additions and 164 deletions
@@ -0,0 +1,96 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
type TNodeDCStandaloneShellProps = {
children: ReactNode;
notificationsCount?: number;
onOpenNotifications?: () => void;
showUserControls?: boolean;
};
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
const { t } = useTranslation();
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
useEffect(() => {
let isMounted = true;
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload: { logoLinkUrl?: string } | null) => {
if (isMounted && payload?.logoLinkUrl) setLogoLinkUrl(payload.logoLinkUrl);
return undefined;
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
});
return () => {
isMounted = false;
};
}, []);
return (
<div className="relative flex min-h-screen w-full overflow-hidden bg-[#050507] text-primary">
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute top-[-18rem] left-[-12rem] h-[34rem] w-[34rem] rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-[120px]" />
<div className="absolute right-[-14rem] bottom-[-18rem] h-[38rem] w-[38rem] rounded-full bg-white/7 blur-[140px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,255,255,0.06),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0))]" />
</div>
<header className="nodedc-expanded-toolbar-shell absolute inset-x-0 top-0 z-[2]">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</div>
<div className="nodedc-expanded-toolbar-center" />
<div className="nodedc-expanded-toolbar-right">
{showUserControls && (
<div className="nodedc-expanded-user-group">
{onOpenNotifications && (
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button relative flex items-center justify-center"
data-active={false}
aria-label={t("notification.label")}
onClick={onOpenNotifications}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className="size-5" />
</span>
{notificationsCount > 0 && (
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
)}
<UserMenuRoot variant="expanded-toolbar" />
</div>
)}
</div>
</div>
</header>
<main className="relative z-[1] flex min-h-screen w-full items-center justify-center px-5 py-10 pt-[calc(var(--nodedc-shell-height)+2.25rem)]">
{children}
</main>
</div>
);
};
@@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
@@ -32,11 +32,25 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
project: {
fetchProjectMembers,
getFilteredProjectMemberDetails,
getProjectMemberFetchStatus,
getProjectMemberIds,
filters,
},
} = useMember();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId.toString());
const projectMemberIds = getProjectMemberIds(projectId.toString(), true);
useEffect(() => {
if (!workspaceSlug || !projectId) return;
void fetchProjectMembers(workspaceSlug.toString(), projectId.toString(), true).catch(console.error);
}, [fetchProjectMembers, projectId, workspaceSlug]);
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
@@ -53,7 +67,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
// Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => {
@@ -90,7 +104,6 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
placeholder={t("search")}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
@@ -113,7 +126,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
)}
</div>
</div>
{!projectMemberIds ? (
{!hasFetchedProjectMembers ? (
<MembersSettingsLoader />
) : (
<div className="nodedc-settings-card overflow-hidden px-1 py-1">
@@ -21,21 +21,25 @@ type Props = {
value: any;
onChange: (val: string) => void;
isDisabled?: boolean;
projectId?: string;
};
export const MemberSelect = observer(function MemberSelect(props: Props) {
const { value, onChange, isDisabled = false } = props;
const { value, onChange, isDisabled = false, projectId: explicitProjectId } = props;
const { t } = useTranslation();
// router
const { projectId } = useParams();
const { projectId: routeProjectId } = useParams();
const projectId = explicitProjectId ?? routeProjectId?.toString();
// store hooks
const {
project: { projectMemberIds, getProjectMemberDetails },
project: { getProjectMemberDetails, getProjectMemberIds },
} = useMember();
const projectMemberIds = projectId ? getProjectMemberIds(projectId, true) : null;
const options = projectMemberIds
?.map((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
@@ -59,7 +63,7 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
content: React.ReactNode;
}[]
| undefined;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId) : null;
return (
<SearchSelectionDropdown
@@ -81,7 +85,6 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
}
buttonClassName="nodedc-settings-select !w-full !justify-between !px-4 !py-3"
options={
options &&
options && [
...options,
{
@@ -113,6 +113,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
return undefined;
})
.catch((err) => {
console.error(err);
@@ -131,6 +132,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"),
});
return undefined;
})
.catch((err) => {
console.error(err);
@@ -154,6 +156,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
projectId={projectId}
/>
)}
/>
@@ -178,6 +181,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
projectId={projectId}
/>
)}
/>
@@ -0,0 +1,148 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { ArrowRight, BellRing, FolderKanban, UserRound, UsersRound } from "lucide-react";
import { Button } from "@plane/propel/button";
import { Avatar } from "@plane/ui";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
import { closeWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useAppRouter } from "@/hooks/use-app-router";
import { NotificationOption } from "../sidebar/notification-card/options";
type TNodeDCNotificationDetailProps = {
notificationId: string;
workspaceSlug: string;
};
export const NodeDCNotificationDetail = (props: TNodeDCNotificationDetailProps) => {
const { notificationId, workspaceSlug } = props;
const router = useAppRouter();
const { asJson: notification } = useNotification(notificationId);
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
if (!notification?.id) return <></>;
const targetUrl = notification.data?.target_url;
const isProjectTarget = !!notification.data?.project_id || notification.sender?.includes("project_");
const actionLabel = isProjectTarget ? "Открыть проект" : "Перейти в пространство";
const actor = notification.triggered_by_details;
const contextItems = [
{
icon: <UsersRound className="size-4" />,
label: "Workspace",
value: notification.data?.workspace_name,
},
{
icon: <FolderKanban className="size-4" />,
label: "Проект",
value: notification.data?.project_name,
},
{
icon: <UserRound className="size-4" />,
label: "Роль",
value: notification.data?.role,
},
].filter((item) => item.value);
const handleOpenTarget = () => {
if (!targetUrl) return;
closeWorkspaceNotificationsModal();
router.push(targetUrl);
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-8 py-5">
<div className="min-w-0">
<div className="text-13 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
NODE.DC уведомление
</div>
<div className="mt-1 truncate text-12 text-tertiary">
{notification.created_at ? calculateTimeAgo(notification.created_at) : "Новое событие"}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
{targetUrl && (
<Button
variant="primary"
size="lg"
onClick={handleOpenTarget}
className="nodedc-empty-state-primary min-w-[12rem]"
appendIcon={<ArrowRight className="size-4" />}
>
{actionLabel}
</Button>
)}
<NotificationOption
workspaceSlug={workspaceSlug}
notificationId={notification.id}
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
<div className="mx-auto max-w-3xl">
<div className="nodedc-glass-surface relative overflow-hidden rounded-[2rem] px-8 py-8">
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
<div className="flex items-start gap-5">
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/[0.045]">
{actor ? (
<Avatar
name={actor.display_name || actor.first_name}
src={getFileURL(actor.avatar_url)}
size={64}
shape="circle"
className="object-cover"
/>
) : (
<BellRing className="size-8 text-[rgb(var(--nodedc-accent-rgb))]" />
)}
</div>
<div className="min-w-0 flex-1">
<h2 className="text-24 font-semibold tracking-[-0.03em] text-primary">
{notification.title || "Новое событие в Tasker"}
</h2>
<p className="mt-3 max-w-2xl text-15 leading-7 text-secondary">
{notification.message_stripped ||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
</p>
{actor?.display_name && (
<div className="mt-5 text-13 text-tertiary">
Инициатор: <span className="text-secondary">{actor.display_name}</span>
</div>
)}
</div>
</div>
{contextItems.length > 0 && (
<div className="mt-8 grid gap-3 md:grid-cols-3">
{contextItems.map((item) => (
<div key={item.label} className="rounded-[1.25rem] bg-white/[0.035] px-4 py-4">
<div className="flex items-center gap-2 text-12 font-semibold tracking-[0.14em] text-tertiary uppercase">
{item.icon}
{item.label}
</div>
<div className="mt-2 truncate text-15 font-semibold text-primary">{item.value}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -13,7 +13,9 @@ import { EmptyStateCompact } from "@plane/propel/empty-state";
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { NodeDCNotificationDetail } from "@/components/workspace-notifications/detail/nodedc-notification-detail";
// hooks
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
@@ -40,9 +42,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
} = useWorkspaceNotifications();
const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
const { asJson: selectedNotification } = useNotification(currentSelectedNotificationId);
// derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
notificationLiteByNotificationId(currentSelectedNotificationId);
const isNodeDCNotification = selectedNotification?.sender?.startsWith("in_app:nodedc:") ?? false;
// fetching workspace work item properties
useWorkspaceIssueProperties(workspaceSlug);
@@ -128,6 +132,8 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
/>
)}
</>
) : isNodeDCNotification && workspaceSlug ? (
<NodeDCNotificationDetail notificationId={currentSelectedNotificationId} workspaceSlug={workspaceSlug} />
) : (
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)}
@@ -160,10 +160,10 @@ export function NotificationContent({
renderCommentBox?: boolean;
}) {
const { data, triggered_by_details: triggeredBy } = notification;
const notificationField = data?.issue_activity.field;
const newValue = data?.issue_activity.new_value;
const oldValue = data?.issue_activity.old_value;
const verb = data?.issue_activity.verb;
const notificationField = data?.issue_activity?.field;
const newValue = data?.issue_activity?.new_value;
const oldValue = data?.issue_activity?.old_value;
const verb = data?.issue_activity?.verb;
const fieldData: TNotificationFieldData = {
field: notificationField,
@@ -40,8 +40,10 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
const issueId = notification?.data?.issue?.id || undefined;
const workspace = getWorkspaceBySlug(workspaceSlug);
const notificationField = notification?.data?.issue_activity.field || undefined;
const notificationField = notification?.data?.issue_activity?.field || undefined;
const notificationTriggeredBy = notification.triggered_by_details || undefined;
const isNodeDCNotification = notification.sender?.startsWith("in_app:nodedc:") ?? false;
const isIssueNotification = !!notificationField && !!projectId && !!issueId && notification.entity_name === "issue";
const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
@@ -65,8 +67,32 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
}
};
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
return <></>;
const handleNodeDCNotification = async () => {
if (isSnoozeStateModalOpen || customSnoozeModal) return;
setPeekIssue(undefined);
setCurrentSelectedNotificationId(notificationId);
if (notification.read_at === null) {
try {
await markNotificationAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
}
};
const handleNotificationClick = () => {
if (isIssueNotification) {
void handleNotificationIssuePeekOverview();
return;
}
if (isNodeDCNotification) void handleNodeDCNotification();
};
if (!workspaceSlug || !notificationId || !notification?.id || !workspace?.id) return <></>;
if (!isIssueNotification && !isNodeDCNotification) return <></>;
return (
<Row
@@ -77,7 +103,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
"bg-accent-primary/5": notification.read_at === null,
}
)}
onClick={handleNotificationIssuePeekOverview}
onClick={handleNotificationClick}
>
{notification.read_at === null && (
<div className="absolute top-[50%] left-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-primary" />
@@ -85,7 +111,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
<div className="relative flex w-full gap-2">
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
{notificationTriggeredBy && (
{notificationTriggeredBy ? (
<Avatar
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
src={getFileURL(notificationTriggeredBy.avatar_url)}
@@ -93,18 +119,29 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
shape="circle"
className="bg-layer-1 text-body-sm-medium"
/>
) : (
<img src="/nodedc-logo.svg" alt="NODE DC" className="h-6 w-auto opacity-80" />
)}
</div>
<div className="-mt-2 w-full space-y-1">
<div className="relative flex h-8 items-center gap-3">
<div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
{isIssueNotification && projectId ? (
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
) : (
<span>
{notificationTriggeredBy?.display_name && (
<span className="font-medium text-primary">{notificationTriggeredBy.display_name} </span>
)}
<span className="text-tertiary">{notification.title}</span>
</span>
)}
</div>
<NotificationOption
workspaceSlug={workspaceSlug}
@@ -118,8 +155,17 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
<div className="relative flex items-center gap-3 text-caption-sm-regular text-secondary">
<div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
{isIssueNotification ? (
<>
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
</>
) : (
<>
{notification.message_stripped ||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
</>
)}
</div>
<div className="flex-shrink-0">
{notification?.snoozed_till ? (