From fc59481703661345ec0a43e1b7672605751de710 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 12 May 2026 15:11:17 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D1=8B=D0=B9=20flow?= =?UTF-8?q?=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/plane/app/views/notification/base.py | 2 +- .../api/plane/app/views/project/member.py | 32 ++- .../api/plane/app/views/workspace/invite.py | 56 +++++- .../apps/web/app/(all)/invitations/layout.tsx | 2 +- .../apps/web/app/(all)/invitations/page.tsx | 189 +++++++++++------- .../(all)/workspace-invitations/layout.tsx | 2 +- .../app/(all)/workspace-invitations/page.tsx | 165 ++++++++++----- .../components/nodedc/standalone-shell.tsx | 96 +++++++++ .../core/components/project/member-list.tsx | 23 ++- .../core/components/project/member-select.tsx | 15 +- .../project-settings-member-defaults.tsx | 4 + .../detail/nodedc-notification-detail.tsx | 148 ++++++++++++++ .../workspace-notifications/root.tsx | 6 + .../sidebar/notification-card/content.tsx | 8 +- .../sidebar/notification-card/item.tsx | 72 +++++-- .../lib/wrappers/authentication-wrapper.tsx | 49 ++++- .../types/src/workspace-notifications.ts | 12 +- 17 files changed, 717 insertions(+), 164 deletions(-) create mode 100644 plane-src/apps/web/core/components/nodedc/standalone-shell.tsx create mode 100644 plane-src/apps/web/core/components/workspace-notifications/detail/nodedc-notification-detail.tsx diff --git a/plane-src/apps/api/plane/app/views/notification/base.py b/plane-src/apps/api/plane/app/views/notification/base.py index 63fd577..a8adedf 100644 --- a/plane-src/apps/api/plane/app/views/notification/base.py +++ b/plane-src/apps/api/plane/app/views/notification/base.py @@ -62,7 +62,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): notifications = ( Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id) - .filter(entity_name="issue") + .filter(Q(entity_name="issue") | Q(sender__startswith="in_app:nodedc:")) .annotate(is_inbox_issue=Exists(intake_issue)) .annotate(is_intake_issue=Exists(intake_issue)) .annotate( diff --git a/plane-src/apps/api/plane/app/views/project/member.py b/plane-src/apps/api/plane/app/views/project/member.py index f30c79e..47abc01 100644 --- a/plane-src/apps/api/plane/app/views/project/member.py +++ b/plane-src/apps/api/plane/app/views/project/member.py @@ -22,7 +22,7 @@ from plane.app.serializers import ( from plane.app.permissions import WorkspaceUserPermission -from plane.db.models import IssueAssignee, Project, ProjectMember, ProjectUserProperty, WorkspaceMember +from plane.db.models import IssueAssignee, Notification, Project, ProjectMember, ProjectUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE @@ -145,6 +145,36 @@ class ProjectMemberViewSet(BaseViewSet): project_members = ProjectMember.objects.filter( project_id=project_id, member_id__in=[member.get("member_id") for member in members], + ).select_related("member", "project", "workspace") + Notification.objects.bulk_create( + [ + Notification( + workspace=project_member.workspace, + project=project_member.project, + sender="in_app:nodedc:project_member_added", + triggered_by=request.user, + receiver=project_member.member, + entity_identifier=project_member.project_id, + entity_name="project_member_added", + title=f"Вам открыли доступ к проекту {project_member.project.name}", + message=f"Вы добавлены в проект {project_member.project.name} workspace {project_member.workspace.name}.", + message_stripped=( + f"Вы добавлены в проект {project_member.project.name} workspace {project_member.workspace.name}." + ), + data={ + "notification_type": "project_member_added", + "target_url": f"/{slug}/projects/{project_id}/issues", + "workspace_slug": slug, + "workspace_name": project_member.workspace.name, + "project_id": str(project_member.project_id), + "project_name": project_member.project.name, + "role": project_member.role, + }, + ) + for project_member in project_members + if project_member.member_id != request.user.id + ], + batch_size=10, ) # Send emails to notify the users [ diff --git a/plane-src/apps/api/plane/app/views/workspace/invite.py b/plane-src/apps/api/plane/app/views/workspace/invite.py index 9fbcc27..5164cc8 100644 --- a/plane-src/apps/api/plane/app/views/workspace/invite.py +++ b/plane-src/apps/api/plane/app/views/workspace/invite.py @@ -37,7 +37,7 @@ from plane.app.serializers import ( from plane.app.views.base import BaseAPIView from plane.bgtasks.event_tracking_task import track_event from plane.bgtasks.workspace_invitation_task import workspace_invitation -from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite +from plane.db.models import Notification, User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly from plane.utils.host import base_host from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE @@ -314,6 +314,31 @@ class WorkspaceJoinEndpoint(BaseAPIView): "joined_at": str(timezone.now()), }, ) + if workspace_invite.created_by_id and workspace_invite.created_by_id != user.id: + Notification.objects.create( + workspace=workspace_invite.workspace, + sender="in_app:nodedc:workspace_invite_accepted", + triggered_by=user, + receiver_id=workspace_invite.created_by_id, + entity_identifier=workspace_invite.workspace_id, + entity_name="workspace_invite_accepted", + title=f"{user.display_name or user.email} принял приглашение", + message=( + f"{user.display_name or user.email} присоединился к workspace " + f"{workspace_invite.workspace.name}." + ), + message_stripped=( + f"{user.display_name or user.email} присоединился к workspace " + f"{workspace_invite.workspace.name}." + ), + data={ + "notification_type": "workspace_invite_accepted", + "target_url": f"/{workspace_invite.workspace.slug}/settings/members", + "workspace_slug": workspace_invite.workspace.slug, + "workspace_name": workspace_invite.workspace.name, + "invitee_email": user.email, + }, + ) # Delete the invitation workspace_invite.delete() @@ -358,6 +383,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): ).order_by("-created_at") # If the user is already a member of workspace and was deactivated then activate the user + accepted_notifications = [] for invitation in workspace_invitations: workspace_member = WorkspaceMember.objects.filter( workspace_id=invitation.workspace_id, member=request.user @@ -394,6 +420,33 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): "joined_at": str(timezone.now()), }, ) + if invitation.created_by_id and invitation.created_by_id != request.user.id: + accepted_notifications.append( + Notification( + workspace=invitation.workspace, + sender="in_app:nodedc:workspace_invite_accepted", + triggered_by=request.user, + receiver_id=invitation.created_by_id, + entity_identifier=invitation.workspace_id, + entity_name="workspace_invite_accepted", + title=f"{request.user.display_name or request.user.email} принял приглашение", + message=( + f"{request.user.display_name or request.user.email} присоединился к workspace " + f"{invitation.workspace.name}." + ), + message_stripped=( + f"{request.user.display_name or request.user.email} присоединился к workspace " + f"{invitation.workspace.name}." + ), + data={ + "notification_type": "workspace_invite_accepted", + "target_url": f"/{invitation.workspace.slug}/settings/members", + "workspace_slug": invitation.workspace.slug, + "workspace_name": invitation.workspace.name, + "invitee_email": request.user.email, + }, + ) + ) # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( @@ -408,6 +461,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): ], ignore_conflicts=True, ) + Notification.objects.bulk_create(accepted_notifications, batch_size=10) # Delete joined workspace invites workspace_invitations.delete() diff --git a/plane-src/apps/web/app/(all)/invitations/layout.tsx b/plane-src/apps/web/app/(all)/invitations/layout.tsx index 68f4fe7..a4a5e0a 100644 --- a/plane-src/apps/web/app/(all)/invitations/layout.tsx +++ b/plane-src/apps/web/app/(all)/invitations/layout.tsx @@ -11,4 +11,4 @@ export default function InvitationsLayout() { return ; } -export const meta: Route.MetaFunction = () => [{ title: "Invitations" }]; +export const meta: Route.MetaFunction = () => [{ title: "Приглашения - NODE.DC Tasker" }]; diff --git a/plane-src/apps/web/app/(all)/invitations/page.tsx b/plane-src/apps/web/app/(all)/invitations/page.tsx index bdf3da4..b663df5 100644 --- a/plane-src/apps/web/app/(all)/invitations/page.tsx +++ b/plane-src/apps/web/app/(all)/invitations/page.tsx @@ -9,23 +9,20 @@ import { observer } from "mobx-react"; import Link from "next/link"; import useSWR, { mutate } from "swr"; -import { CheckCircle2 } from "lucide-react"; +import { ArrowRight, Bell, CheckCircle2, MailCheck, Sparkles } from "lucide-react"; // plane imports import { ROLE_DETAILS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types import { Button } from "@plane/propel/button"; -import { PlaneLogo } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspaceMemberInvitation } from "@plane/types"; import { truncateText } from "@plane/utils"; -// assets -import emptyInvitation from "@/app/assets/empty-state/invitation.svg?url"; -// components -import { EmptyState } from "@/components/common/empty-state"; +import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell"; import { WorkspaceLogo } from "@/components/workspace/logo"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; // hooks +import { useWorkspaceNotifications } from "@/hooks/store/notifications"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUser, useUserProfile } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -47,14 +44,29 @@ function UserInvitationsPage() { const { data: currentUser } = useUser(); const { updateUserProfile } = useUserProfile(); - const { fetchWorkspaces } = useWorkspace(); + const { fetchWorkspaces, workspaces } = useWorkspace(); + const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations()); + useSWR(USER_WORKSPACES_LIST, () => fetchWorkspaces()); + + const fallbackWorkspaceSlug = Object.values(workspaces ?? {})?.[0]?.slug; + + useSWR( + fallbackWorkspaceSlug ? ["STANDALONE_UNREAD_NOTIFICATION_COUNT", fallbackWorkspaceSlug] : null, + fallbackWorkspaceSlug ? () => getUnreadNotificationsCount(fallbackWorkspaceSlug) : null + ); + + const notificationsCount = + unreadNotificationsCount.mention_unread_notifications_count > 0 + ? unreadNotificationsCount.mention_unread_notifications_count + : unreadNotificationsCount.total_unread_notifications_count || invitations?.length || 0; const redirectWorkspaceSlug = // currentUserSettings?.workspace?.last_workspace_slug || // currentUserSettings?.workspace?.fallback_workspace_slug || ""; + const hasInvitations = !!invitations && invitations.length > 0; const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { if (action === "accepted") { @@ -64,7 +76,7 @@ function UserInvitationsPage() { } }; - const submitInvitations = () => { + const submitInvitations = async () => { if (invitationsRespond.length === 0) { setToast({ type: TOAST_TYPE.ERROR, @@ -76,88 +88,102 @@ function UserInvitationsPage() { setIsJoiningWorkspaces(true); - workspaceService - .joinWorkspaces({ invitations: invitationsRespond }) - .then(() => { - mutate(USER_WORKSPACES_LIST); - const firstInviteId = invitationsRespond[0]; - const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; - updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) - .then(() => { - setIsJoiningWorkspaces(false); - fetchWorkspaces().then(() => { - router.push(`/${redirectWorkspace?.slug}`); - }); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("error"), - message: t("something_went_wrong_please_try_again"), - }); - setIsJoiningWorkspaces(false); - }); - }) - .catch((_err) => { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("error"), - message: t("something_went_wrong_please_try_again"), - }); - setIsJoiningWorkspaces(false); + try { + await workspaceService.joinWorkspaces({ invitations: invitationsRespond }); + void mutate(USER_WORKSPACES_LIST); + const firstInviteId = invitationsRespond[0]; + const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; + await updateUserProfile({ last_workspace_id: redirectWorkspace?.id }); + await fetchWorkspaces(); + router.push(redirectWorkspace?.slug ? `/${redirectWorkspace.slug}` : "/"); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), }); + } finally { + setIsJoiningWorkspaces(false); + } + }; + + const openNotifications = () => { + if (fallbackWorkspaceSlug) { + router.push(`/${fallbackWorkspaceSlug}?workspaceNotifications=open`); + return; + } + + router.push("/invitations"); }; return ( -
-
- - - -
{currentUser?.email}
-
+ {invitations ? ( - invitations.length > 0 ? ( -
-
-
-
{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
-

{t("join_a_workspace")}

+ hasInvitations ? ( +
+
+
+
+
+
+ + Новые приглашения +
+
+

Принять доступ

+

+ Выберите рабочие пространства, к которым хотите присоединиться. После принятия Tasker + откроет первый выбранный workspace. +

+
+
+
+ +
+
-
+ +
{invitations.map((invitation) => { const isSelected = invitationsRespond.includes(invitation.id); return ( -
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} > -
+
-
{truncateText(invitation.workspace.name, 30)}
-

+

{truncateText(invitation.workspace.name, 42)}
+

{t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")}

- + -
+ ); })}
-
+
- @@ -179,20 +206,32 @@ function UserInvitationsPage() {
) : ( -
- router.push("/"), - }} - /> +
+
+
+
+ +
+
+

Нет ожидающих приглашений

+

+ Когда вас пригласят в workspace, здесь появится карточка доступа с возможностью принять приглашение. +

+
+ +
) ) : null} -
+ ); } diff --git a/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx b/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx index b9a7337..145979d 100644 --- a/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx +++ b/plane-src/apps/web/app/(all)/workspace-invitations/layout.tsx @@ -11,4 +11,4 @@ export default function WorkspaceInvitationsLayout() { return ; } -export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }]; +export const meta: Route.MetaFunction = () => [{ title: "Workspace приглашение - NODE.DC Tasker" }]; diff --git a/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx b/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx index fbb8b2b..a63c4f3 100644 --- a/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/plane-src/apps/web/app/(all)/workspace-invitations/page.tsx @@ -5,13 +5,14 @@ */ import { observer } from "mobx-react"; +import type { ReactNode } from "react"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; -import { Boxes, User2 } from "lucide-react"; -import { CheckIcon, CloseIcon } from "@plane/propel/icons"; +import { ArrowRight, Check, MailCheck, X } from "lucide-react"; +import { Button } from "@plane/propel/button"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; -import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; +import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell"; // constants import { WORKSPACE_INVITATION } from "@/constants/fetch-keys"; // helpers @@ -45,82 +46,140 @@ function WorkspaceInvitationPage() { : null ); - const handleAccept = () => { + const handleAccept = async () => { if (!invitationDetail) return; - workspaceService - .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + try { + await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: true, token: token, - }) - .then(() => { - if (invitationDetail.email === currentUser?.email) { - router.push(`/${invitationDetail.workspace.slug}`); - } else { - router.push("/"); - } - }) - .catch((err: unknown) => console.error(err)); + }); + router.push(invitationDetail.email === currentUser?.email ? `/${invitationDetail.workspace.slug}` : "/"); + } catch (err: unknown) { + console.error(err); + } }; - const handleReject = () => { + const handleReject = async () => { if (!invitationDetail || !token) return; - void workspaceService - .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + try { + await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: false, token: token, - }) - .then(() => { - router.push("/"); - }) - .catch((err: unknown) => console.error(err)); + }); + router.push("/"); + } catch (err: unknown) { + console.error(err); + } }; return ( -
+ {invitationDetail && !invitationDetail.responded_at ? ( error ? ( -
-

INVITATION NOT FOUND

-
+ router.push("/")} />} + /> ) : ( - - - - + + + +
+ } + /> ) ) : error || invitationDetail?.responded_at ? ( invitationDetail?.accepted ? ( - - - + router.push("/")} />} + /> ) : ( - - {!currentUser ? ( - - ) : ( - - )} - + router.push("/")} />} + /> ) ) : ( -
+
)} -
+
); } export default observer(WorkspaceInvitationPage); + +function InvitationShell({ + action, + description, + eyebrow = "NODE.DC Tasker", + title, +}: { + action: ReactNode; + description: string; + eyebrow?: string; + title: string; +}) { + return ( +
+
+
+ +
+
+
+ {eyebrow} +
+

{title}

+

{description}

+
+
{action}
+
+ ); +} + +function HomeButton({ routerPush }: { routerPush: () => void }) { + return ( + + ); +} diff --git a/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx b/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx new file mode 100644 index 0000000..785b83b --- /dev/null +++ b/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx @@ -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 ( +
+
+
+
+
+
+ +
+
+
+ + NODE DC + +
+
+
+ {showUserControls && ( +
+ {onOpenNotifications && ( + + + + )} + +
+ )} +
+
+
+ +
+ {children} +
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/project/member-list.tsx b/plane-src/apps/web/core/components/project/member-list.tsx index 61ad139..1295647 100644 --- a/plane-src/apps/web/core/components/project/member-list.tsx +++ b/plane-src/apps/web/core/components/project/member-list.tsx @@ -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)} />
@@ -113,7 +126,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro )}
- {!projectMemberIds ? ( + {!hasFetchedProjectMembers ? ( ) : (
diff --git a/plane-src/apps/web/core/components/project/member-select.tsx b/plane-src/apps/web/core/components/project/member-select.tsx index c299d3a..583fb91 100644 --- a/plane-src/apps/web/core/components/project/member-select.tsx +++ b/plane-src/apps/web/core/components/project/member-select.tsx @@ -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 ( { 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} /> )} /> diff --git a/plane-src/apps/web/core/components/workspace-notifications/detail/nodedc-notification-detail.tsx b/plane-src/apps/web/core/components/workspace-notifications/detail/nodedc-notification-detail.tsx new file mode 100644 index 0000000..de1704e --- /dev/null +++ b/plane-src/apps/web/core/components/workspace-notifications/detail/nodedc-notification-detail.tsx @@ -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: , + label: "Workspace", + value: notification.data?.workspace_name, + }, + { + icon: , + label: "Проект", + value: notification.data?.project_name, + }, + { + icon: , + label: "Роль", + value: notification.data?.role, + }, + ].filter((item) => item.value); + + const handleOpenTarget = () => { + if (!targetUrl) return; + + closeWorkspaceNotificationsModal(); + router.push(targetUrl); + }; + + return ( +
+
+
+
+ NODE.DC уведомление +
+
+ {notification.created_at ? calculateTimeAgo(notification.created_at) : "Новое событие"} +
+
+
+ {targetUrl && ( + + )} + +
+
+ +
+
+
+
+
+
+ {actor ? ( + + ) : ( + + )} +
+
+

+ {notification.title || "Новое событие в Tasker"} +

+

+ {notification.message_stripped || + [notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")} +

+ {actor?.display_name && ( +
+ Инициатор: {actor.display_name} +
+ )} +
+
+ + {contextItems.length > 0 && ( +
+ {contextItems.map((item) => ( +
+
+ {item.icon} + {item.label} +
+
{item.value}
+
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/workspace-notifications/root.tsx b/plane-src/apps/web/core/components/workspace-notifications/root.tsx index 8dc2d09..b4e3ee4 100644 --- a/plane-src/apps/web/core/components/workspace-notifications/root.tsx +++ b/plane-src/apps/web/core/components/workspace-notifications/root.tsx @@ -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 ? ( + ) : ( )} diff --git a/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/content.tsx b/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/content.tsx index 0ed63b7..a14e2ed 100644 --- a/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/content.tsx +++ b/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/content.tsx @@ -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, diff --git a/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx b/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx index 859070c..d3cbd6c 100644 --- a/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx +++ b/plane-src/apps/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx @@ -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 ( {notification.read_at === null && (
@@ -85,7 +111,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
- {notificationTriggeredBy && ( + {notificationTriggeredBy ? ( + ) : ( + NODE DC )}
- + {isIssueNotification && projectId ? ( + + ) : ( + + {notificationTriggeredBy?.display_name && ( + {notificationTriggeredBy.display_name} + )} + {notification.title} + + )}
- {notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}  - {notification?.data?.issue?.name} + {isIssueNotification ? ( + <> + {notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}  + {notification?.data?.issue?.name} + + ) : ( + <> + {notification.message_stripped || + [notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")} + + )}
{notification?.snoozed_till ? ( diff --git a/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx b/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx index a8ae0ed..a3a8012 100644 --- a/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx +++ b/plane-src/apps/web/core/lib/wrappers/authentication-wrapper.tsx @@ -4,10 +4,11 @@ * See the LICENSE file for details. */ -import type { ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { observer } from "mobx-react"; import { useSearchParams, usePathname } from "next/navigation"; import useSWR from "swr"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; // helpers @@ -17,6 +18,10 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC } import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { WorkspaceService } from "@/services/workspace.service"; + +const workspaceService = new WorkspaceService(); type TPageType = EPageTypes; @@ -35,6 +40,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro const router = useAppRouter(); const searchParams = useSearchParams(); const nextPath = searchParams.get("next_path"); + const pendingInviteToastKey = useRef(undefined); // props const { children, pageType = EPageTypes.AUTHENTICATED } = props; // hooks @@ -55,6 +61,47 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro currentUserProfile?.onboarding_step?.workspace_invite && currentUserProfile?.onboarding_step?.workspace_join) || false; + const shouldWatchPendingInvites = + pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations"; + + const { data: pendingWorkspaceInvitations } = useSWR( + shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null, + () => workspaceService.userWorkspaceInvitations(), + { + refreshInterval: 15000, + revalidateOnFocus: true, + shouldRetryOnError: false, + } + ); + + useEffect(() => { + if (!shouldWatchPendingInvites || !pendingWorkspaceInvitations?.length) return; + + const inviteKey = pendingWorkspaceInvitations + .map((invitation) => invitation.id) + .toSorted() + .join(":"); + if (pendingInviteToastKey.current === inviteKey) return; + pendingInviteToastKey.current = inviteKey; + + setToast({ + type: TOAST_TYPE.INFO, + title: "Новое приглашение в Tasker", + message: + pendingWorkspaceInvitations.length === 1 + ? "Вам отправили доступ в workspace. Откройте приглашения, чтобы принять его." + : `У вас ${pendingWorkspaceInvitations.length} ожидающих приглашений. Откройте список, чтобы принять доступы.`, + actionItems: ( + + ), + }); + }, [pendingWorkspaceInvitations, router, shouldWatchPendingInvites]); const getWorkspaceRedirectionUrl = (): string => { let redirectionRoute = "/create-workspace"; diff --git a/plane-src/packages/types/src/workspace-notifications.ts b/plane-src/packages/types/src/workspace-notifications.ts index a6c35b7..86b507d 100644 --- a/plane-src/packages/types/src/workspace-notifications.ts +++ b/plane-src/packages/types/src/workspace-notifications.ts @@ -34,8 +34,8 @@ export type TNotificationIssueLite = { }; export type TNotificationData = { - issue: TNotificationIssueLite | undefined; - issue_activity: { + issue?: TNotificationIssueLite | undefined; + issue_activity?: { id: string | undefined; actor: string | undefined; field: string | undefined; @@ -44,6 +44,14 @@ export type TNotificationData = { new_value: string | undefined; old_value: string | undefined; }; + notification_type?: string; + target_url?: string; + workspace_slug?: string; + workspace_name?: string; + project_id?: string; + project_name?: string; + invitee_email?: string; + role?: number; }; export type TNotification = {