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

View File

@ -62,7 +62,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
notifications = ( notifications = (
Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id) 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_inbox_issue=Exists(intake_issue))
.annotate(is_intake_issue=Exists(intake_issue)) .annotate(is_intake_issue=Exists(intake_issue))
.annotate( .annotate(

View File

@ -22,7 +22,7 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission 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.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE from plane.app.permissions.base import allow_permission, ROLE
@ -145,6 +145,36 @@ class ProjectMemberViewSet(BaseViewSet):
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
project_id=project_id, project_id=project_id,
member_id__in=[member.get("member_id") for member in members], 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 # Send emails to notify the users
[ [

View File

@ -37,7 +37,7 @@ from plane.app.serializers import (
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.bgtasks.event_tracking_task import track_event from plane.bgtasks.event_tracking_task import track_event
from plane.bgtasks.workspace_invitation_task import workspace_invitation 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.cache import invalidate_cache, invalidate_cache_directly
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE 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()), "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 # Delete the invitation
workspace_invite.delete() workspace_invite.delete()
@ -358,6 +383,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
).order_by("-created_at") ).order_by("-created_at")
# If the user is already a member of workspace and was deactivated then activate the user # If the user is already a member of workspace and was deactivated then activate the user
accepted_notifications = []
for invitation in workspace_invitations: for invitation in workspace_invitations:
workspace_member = WorkspaceMember.objects.filter( workspace_member = WorkspaceMember.objects.filter(
workspace_id=invitation.workspace_id, member=request.user workspace_id=invitation.workspace_id, member=request.user
@ -394,6 +420,33 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
"joined_at": str(timezone.now()), "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 # Bulk create the user for all the workspaces
WorkspaceMember.objects.bulk_create( WorkspaceMember.objects.bulk_create(
@ -408,6 +461,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
], ],
ignore_conflicts=True, ignore_conflicts=True,
) )
Notification.objects.bulk_create(accepted_notifications, batch_size=10)
# Delete joined workspace invites # Delete joined workspace invites
workspace_invitations.delete() workspace_invitations.delete()

View File

@ -11,4 +11,4 @@ export default function InvitationsLayout() {
return <Outlet />; return <Outlet />;
} }
export const meta: Route.MetaFunction = () => [{ title: "Invitations" }]; export const meta: Route.MetaFunction = () => [{ title: "Приглашения - NODE.DC Tasker" }];

View File

@ -9,23 +9,20 @@ import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { CheckCircle2 } from "lucide-react"; import { ArrowRight, Bell, CheckCircle2, MailCheck, Sparkles } from "lucide-react";
// plane imports // plane imports
import { ROLE_DETAILS } from "@plane/constants"; import { ROLE_DETAILS } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// types // types
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { PlaneLogo } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceMemberInvitation } from "@plane/types"; import type { IWorkspaceMemberInvitation } from "@plane/types";
import { truncateText } from "@plane/utils"; import { truncateText } from "@plane/utils";
// assets import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell";
import emptyInvitation from "@/app/assets/empty-state/invitation.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { WorkspaceLogo } from "@/components/workspace/logo"; import { WorkspaceLogo } from "@/components/workspace/logo";
import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys";
// hooks // hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile } from "@/hooks/store/user"; import { useUser, useUserProfile } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
@ -47,14 +44,29 @@ function UserInvitationsPage() {
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { updateUserProfile } = useUserProfile(); const { updateUserProfile } = useUserProfile();
const { fetchWorkspaces } = useWorkspace(); const { fetchWorkspaces, workspaces } = useWorkspace();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations()); 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 = const redirectWorkspaceSlug =
// currentUserSettings?.workspace?.last_workspace_slug || // currentUserSettings?.workspace?.last_workspace_slug ||
// currentUserSettings?.workspace?.fallback_workspace_slug || // currentUserSettings?.workspace?.fallback_workspace_slug ||
""; "";
const hasInvitations = !!invitations && invitations.length > 0;
const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => {
if (action === "accepted") { if (action === "accepted") {
@ -64,7 +76,7 @@ function UserInvitationsPage() {
} }
}; };
const submitInvitations = () => { const submitInvitations = async () => {
if (invitationsRespond.length === 0) { if (invitationsRespond.length === 0) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
@ -76,88 +88,102 @@ function UserInvitationsPage() {
setIsJoiningWorkspaces(true); setIsJoiningWorkspaces(true);
workspaceService try {
.joinWorkspaces({ invitations: invitationsRespond }) await workspaceService.joinWorkspaces({ invitations: invitationsRespond });
.then(() => { void mutate(USER_WORKSPACES_LIST);
mutate(USER_WORKSPACES_LIST); const firstInviteId = invitationsRespond[0];
const firstInviteId = invitationsRespond[0]; const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; await updateUserProfile({ last_workspace_id: redirectWorkspace?.id });
updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) await fetchWorkspaces();
.then(() => { router.push(redirectWorkspace?.slug ? `/${redirectWorkspace.slug}` : "/");
setIsJoiningWorkspaces(false); } catch {
fetchWorkspaces().then(() => { setToast({
router.push(`/${redirectWorkspace?.slug}`); type: TOAST_TYPE.ERROR,
}); title: t("error"),
}) message: t("something_went_wrong_please_try_again"),
.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);
}); });
} finally {
setIsJoiningWorkspaces(false);
}
};
const openNotifications = () => {
if (fallbackWorkspaceSlug) {
router.push(`/${fallbackWorkspaceSlug}?workspaceNotifications=open`);
return;
}
router.push("/invitations");
}; };
return ( return (
<AuthenticationWrapper> <AuthenticationWrapper>
<div className="flex min-h-screen flex-col bg-surface-1 sm:flex-row"> <NodeDCStandaloneShell
<div className="flex items-center justify-between border-b border-subtle px-5 py-4 sm:w-72 sm:flex-col sm:items-start sm:justify-between sm:border-b-0 sm:px-8 sm:py-8"> notificationsCount={notificationsCount}
<Link href="/" className="inline-flex items-center"> onOpenNotifications={openNotifications}
<PlaneLogo className="h-9 w-auto text-primary" /> showUserControls={!!currentUser}
</Link> >
<div className="text-13 text-primary sm:pt-6">{currentUser?.email}</div>
</div>
{invitations ? ( {invitations ? (
invitations.length > 0 ? ( hasInvitations ? (
<div className="flex flex-1 items-center justify-center px-6 py-8 sm:px-12 sm:py-12"> <div className="flex flex-1 items-center justify-center py-10">
<div className="w-full max-w-3xl space-y-10"> <div className="w-full max-w-4xl space-y-7">
<div className="space-y-3"> <div className="nodedc-glass-surface rounded-[2rem] border-0 px-6 py-6 sm:px-8">
<h5 className="text-16">{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}</h5> <div className="flex flex-wrap items-start justify-between gap-5">
<h4 className="text-20 font-semibold">{t("join_a_workspace")}</h4> <div className="min-w-0 space-y-3">
<div className="inline-flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5 text-11 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
<Bell className="size-3.5" />
Новые приглашения
</div>
<div>
<h1 className="text-28 font-semibold tracking-[-0.03em] text-primary">Принять доступ</h1>
<p className="mt-2 max-w-2xl text-13 leading-6 text-secondary">
Выберите рабочие пространства, к которым хотите присоединиться. После принятия Tasker
откроет первый выбранный workspace.
</p>
</div>
</div>
<div className="flex size-14 items-center justify-center rounded-[1.15rem] bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]">
<MailCheck className="size-7" />
</div>
</div>
</div> </div>
<div className="max-h-[45vh] space-y-4 overflow-y-auto md:max-h-[52vh] md:max-w-2xl">
<div className="max-h-[48vh] space-y-3 overflow-y-auto pr-1 md:max-h-[54vh]">
{invitations.map((invitation) => { {invitations.map((invitation) => {
const isSelected = invitationsRespond.includes(invitation.id); const isSelected = invitationsRespond.includes(invitation.id);
return ( return (
<div <button
type="button"
key={invitation.id} key={invitation.id}
className={`flex cursor-pointer items-center gap-2 rounded-sm border px-3.5 py-5 ${ className={`group flex w-full cursor-pointer items-center gap-4 rounded-[1.6rem] px-4 py-4 text-left transition ${
isSelected ? "border-accent-strong" : "border-subtle hover:bg-layer-1" isSelected
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "nodedc-settings-card hover:bg-white/[0.055]"
}`} }`}
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0 rounded-full bg-black/10 p-1">
<WorkspaceLogo <WorkspaceLogo
logo={invitation.workspace.logo_url} logo={invitation.workspace.logo_url}
name={invitation.workspace.name} name={invitation.workspace.name}
classNames="size-9 flex-shrink-0" classNames="size-11 flex-shrink-0"
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-13 font-medium">{truncateText(invitation.workspace.name, 30)}</div> <div className="text-15 font-semibold">{truncateText(invitation.workspace.name, 42)}</div>
<p className="text-11 text-secondary"> <p className={`mt-1 text-12 ${isSelected ? "opacity-70" : "text-secondary"}`}>
{t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")} {t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")}
</p> </p>
</div> </div>
<span className={`flex-shrink-0 ${isSelected ? "text-accent-primary" : "text-secondary"}`}> <span className={`flex-shrink-0 ${isSelected ? "opacity-100" : "text-tertiary"}`}>
<CheckCircle2 className="h-5 w-5" /> <CheckCircle2 className="h-5 w-5" />
</span> </span>
</div> </button>
); );
})} })}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
@ -165,13 +191,14 @@ function UserInvitationsPage() {
onClick={submitInvitations} onClick={submitInvitations}
disabled={isJoiningWorkspaces || invitationsRespond.length === 0} disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
loading={isJoiningWorkspaces} loading={isJoiningWorkspaces}
className="nodedc-empty-state-primary min-w-[12rem]"
> >
{t("accept_and_join")} Принять выбранные
</Button> </Button>
<Link href={`/${redirectWorkspaceSlug}`}> <Link href={`/${redirectWorkspaceSlug}`}>
<span> <span>
<Button variant="secondary" size="lg"> <Button variant="secondary" size="lg" className="nodedc-empty-state-secondary">
{t("go_home")} Вернуться на главную
</Button> </Button>
</span> </span>
</Link> </Link>
@ -179,20 +206,32 @@ function UserInvitationsPage() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="fixed top-0 left-0 grid h-full w-full place-items-center"> <div className="flex flex-1 items-center justify-center py-10">
<EmptyState <div className="nodedc-glass-surface relative w-full max-w-[34rem] overflow-hidden rounded-[2.2rem] px-8 py-9 text-center">
title={t("no_pending_invites")} <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" />
description={t("you_can_see_here_if_someone_invites_you_to_a_workspace")} <div className="mx-auto flex size-24 items-center justify-center rounded-[2rem] bg-white/[0.035] text-[rgb(var(--nodedc-accent-rgb))]">
image={emptyInvitation} <Sparkles className="size-11" />
primaryButton={{ </div>
text: t("back_to_home"), <div className="mt-6 space-y-2">
onClick: () => router.push("/"), <h1 className="text-24 font-semibold tracking-[-0.03em]">Нет ожидающих приглашений</h1>
}} <p className="mx-auto max-w-sm text-13 leading-6 text-secondary">
/> Когда вас пригласят в workspace, здесь появится карточка доступа с возможностью принять приглашение.
</p>
</div>
<Button
variant="primary"
size="lg"
onClick={() => router.push("/")}
className="nodedc-empty-state-primary mt-7"
appendIcon={<ArrowRight className="size-4" />}
>
Вернуться на главную
</Button>
</div>
</div> </div>
) )
) : null} ) : null}
</div> </NodeDCStandaloneShell>
</AuthenticationWrapper> </AuthenticationWrapper>
); );
} }

View File

@ -11,4 +11,4 @@ export default function WorkspaceInvitationsLayout() {
return <Outlet />; return <Outlet />;
} }
export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }]; export const meta: Route.MetaFunction = () => [{ title: "Workspace приглашение - NODE.DC Tasker" }];

View File

@ -5,13 +5,14 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { ReactNode } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { Boxes, User2 } from "lucide-react"; import { ArrowRight, Check, MailCheck, X } from "lucide-react";
import { CheckIcon, CloseIcon } from "@plane/propel/icons"; import { Button } from "@plane/propel/button";
// components // components
import { LogoSpinner } from "@/components/common/logo-spinner"; import { LogoSpinner } from "@/components/common/logo-spinner";
import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; import { NodeDCStandaloneShell } from "@/components/nodedc/standalone-shell";
// constants // constants
import { WORKSPACE_INVITATION } from "@/constants/fetch-keys"; import { WORKSPACE_INVITATION } from "@/constants/fetch-keys";
// helpers // helpers
@ -45,82 +46,140 @@ function WorkspaceInvitationPage() {
: null : null
); );
const handleAccept = () => { const handleAccept = async () => {
if (!invitationDetail) return; if (!invitationDetail) return;
workspaceService try {
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: true, accepted: true,
token: token, token: token,
}) });
.then(() => { router.push(invitationDetail.email === currentUser?.email ? `/${invitationDetail.workspace.slug}` : "/");
if (invitationDetail.email === currentUser?.email) { } catch (err: unknown) {
router.push(`/${invitationDetail.workspace.slug}`); console.error(err);
} else { }
router.push("/");
}
})
.catch((err: unknown) => console.error(err));
}; };
const handleReject = () => { const handleReject = async () => {
if (!invitationDetail || !token) return; if (!invitationDetail || !token) return;
void workspaceService try {
.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { await workspaceService.joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, {
accepted: false, accepted: false,
token: token, token: token,
}) });
.then(() => { router.push("/");
router.push("/"); } catch (err: unknown) {
}) console.error(err);
.catch((err: unknown) => console.error(err)); }
}; };
return ( return (
<AuthenticationWrapper pageType={EPageTypes.PUBLIC}> <AuthenticationWrapper pageType={EPageTypes.PUBLIC}>
<div className="flex h-full w-full flex-col items-center justify-center px-3"> <NodeDCStandaloneShell showUserControls={!!currentUser}>
{invitationDetail && !invitationDetail.responded_at ? ( {invitationDetail && !invitationDetail.responded_at ? (
error ? ( error ? (
<div className="shadow-2xl flex w-full flex-col space-y-4 rounded-sm border border-subtle bg-surface-1 px-4 py-8 text-center md:w-1/3"> <InvitationShell
<h2 className="text-18 uppercase">INVITATION NOT FOUND</h2> title="Приглашение не найдено"
</div> description="Ссылка устарела или была отозвана администратором workspace."
action={<HomeButton routerPush={() => router.push("/")} />}
/>
) : ( ) : (
<EmptySpace <InvitationShell
title={`You have been invited to ${invitationDetail.workspace.name}`} eyebrow="Workspace invite"
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account." title={`Вас пригласили в ${invitationDetail.workspace.name}`}
> description="Примите приглашение, чтобы получить доступ к workspace и связанным проектам Tasker."
<EmptySpaceItem Icon={CheckIcon} title="Accept" action={handleAccept} /> action={
<EmptySpaceItem Icon={CloseIcon} title="Ignore" action={handleReject} /> <div className="flex flex-wrap justify-center gap-3">
</EmptySpace> <Button
variant="primary"
size="lg"
onClick={handleAccept}
className="nodedc-empty-state-primary"
prependIcon={<Check className="size-4" />}
>
Принять
</Button>
<Button
variant="secondary"
size="lg"
onClick={handleReject}
className="nodedc-empty-state-secondary"
prependIcon={<X className="size-4" />}
>
Отклонить
</Button>
</div>
}
/>
) )
) : error || invitationDetail?.responded_at ? ( ) : error || invitationDetail?.responded_at ? (
invitationDetail?.accepted ? ( invitationDetail?.accepted ? (
<EmptySpace <InvitationShell
title={`You are already a member of ${invitationDetail.workspace.name}`} title={`Вы уже участник ${invitationDetail.workspace.name}`}
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account." description="Приглашение принято. Можно вернуться в Tasker и продолжить работу."
> action={<HomeButton routerPush={() => router.push("/")} />}
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" /> />
</EmptySpace>
) : ( ) : (
<EmptySpace <InvitationShell
title="This invitation link is not active anymore." title="Ссылка приглашения больше не активна"
description="Your workspace is where you'll create projects, collaborate on your work items, and organize different streams of work in your NODE.DC account." description={
link={{ text: "Or start from an empty project", href: "/" }} currentUser
> ? "Вернитесь на главную страницу Tasker или запросите новое приглашение."
{!currentUser ? ( : "Войдите через NODE.DC и запросите новое приглашение, если доступ всё ещё нужен."
<EmptySpaceItem Icon={User2} title="Sign in to continue" href="/" /> }
) : ( action={<HomeButton routerPush={() => router.push("/")} />}
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" /> />
)}
</EmptySpace>
) )
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="relative z-[1] flex h-full w-full items-center justify-center">
<LogoSpinner /> <LogoSpinner />
</div> </div>
)} )}
</div> </NodeDCStandaloneShell>
</AuthenticationWrapper> </AuthenticationWrapper>
); );
} }
export default observer(WorkspaceInvitationPage); export default observer(WorkspaceInvitationPage);
function InvitationShell({
action,
description,
eyebrow = "NODE.DC Tasker",
title,
}: {
action: ReactNode;
description: string;
eyebrow?: string;
title: string;
}) {
return (
<div className="nodedc-glass-surface relative z-[1] w-full max-w-[36rem] overflow-hidden rounded-[2.2rem] px-8 py-9 text-center">
<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="mx-auto flex size-24 items-center justify-center rounded-[2rem] bg-white/[0.035] text-[rgb(var(--nodedc-accent-rgb))]">
<MailCheck className="size-11" />
</div>
<div className="mt-6 space-y-2">
<div className="text-11 font-semibold tracking-[0.18em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
{eyebrow}
</div>
<h1 className="text-24 font-semibold tracking-[-0.03em]">{title}</h1>
<p className="mx-auto max-w-sm text-13 leading-6 text-secondary">{description}</p>
</div>
<div className="mt-7">{action}</div>
</div>
);
}
function HomeButton({ routerPush }: { routerPush: () => void }) {
return (
<Button
variant="primary"
size="lg"
onClick={routerPush}
className="nodedc-empty-state-primary"
appendIcon={<ArrowRight className="size-4" />}
>
Вернуться на главную
</Button>
);
}

View File

@ -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>
);
};

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants"; 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 [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { const {
project: { projectMemberIds, getFilteredProjectMemberDetails, filters }, project: {
fetchProjectMembers,
getFilteredProjectMemberDetails,
getProjectMemberFetchStatus,
getProjectMemberIds,
filters,
},
} = useMember(); } = useMember();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { t } = useTranslation(); 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 searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null; 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 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 // Handler for role filter updates
const handleRoleFilterUpdate = (role: string) => { 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" className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
placeholder={t("search")} placeholder={t("search")}
value={searchQuery} value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
@ -113,7 +126,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
)} )}
</div> </div>
</div> </div>
{!projectMemberIds ? ( {!hasFetchedProjectMembers ? (
<MembersSettingsLoader /> <MembersSettingsLoader />
) : ( ) : (
<div className="nodedc-settings-card overflow-hidden px-1 py-1"> <div className="nodedc-settings-card overflow-hidden px-1 py-1">

View File

@ -21,21 +21,25 @@ type Props = {
value: any; value: any;
onChange: (val: string) => void; onChange: (val: string) => void;
isDisabled?: boolean; isDisabled?: boolean;
projectId?: string;
}; };
export const MemberSelect = observer(function MemberSelect(props: Props) { 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(); const { t } = useTranslation();
// router // router
const { projectId } = useParams(); const { projectId: routeProjectId } = useParams();
const projectId = explicitProjectId ?? routeProjectId?.toString();
// store hooks // store hooks
const { const {
project: { projectMemberIds, getProjectMemberDetails }, project: { getProjectMemberDetails, getProjectMemberIds },
} = useMember(); } = useMember();
const projectMemberIds = projectId ? getProjectMemberIds(projectId, true) : null;
const options = projectMemberIds const options = projectMemberIds
?.map((userId) => { ?.map((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null; const memberDetails = projectId ? getProjectMemberDetails(userId, projectId) : null;
if (!memberDetails?.member) return; if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST; const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
@ -59,7 +63,7 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
content: React.ReactNode; content: React.ReactNode;
}[] }[]
| undefined; | undefined;
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null; const selectedOption = projectId ? getProjectMemberDetails(value, projectId) : null;
return ( return (
<SearchSelectionDropdown <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" buttonClassName="nodedc-settings-select !w-full !justify-between !px-4 !py-3"
options={ options={
options &&
options && [ options && [
...options, ...options,
{ {

View File

@ -113,6 +113,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"), message: t("project_settings.general.toast.success"),
}); });
return undefined;
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@ -131,6 +132,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
message: t("project_settings.general.toast.success"), message: t("project_settings.general.toast.success"),
}); });
return undefined;
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
@ -154,6 +156,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ project_lead: val }); submitChanges({ project_lead: val });
}} }}
isDisabled={!isAdmin} isDisabled={!isAdmin}
projectId={projectId}
/> />
)} )}
/> />
@ -178,6 +181,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
submitChanges({ default_assignee: val }); submitChanges({ default_assignee: val });
}} }}
isDisabled={!isAdmin} isDisabled={!isAdmin}
projectId={projectId}
/> />
)} )}
/> />

View File

@ -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>
);
};

View File

@ -13,7 +13,9 @@ import { EmptyStateCompact } from "@plane/propel/empty-state";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { LogoSpinner } from "@/components/common/logo-spinner"; import { LogoSpinner } from "@/components/common/logo-spinner";
import { NodeDCNotificationDetail } from "@/components/workspace-notifications/detail/nodedc-notification-detail";
// hooks // hooks
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useWorkspaceNotifications } from "@/hooks/store/notifications"; import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
@ -40,9 +42,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
} = useWorkspaceNotifications(); } = useWorkspaceNotifications();
const { fetchUserProjectInfo } = useUserPermissions(); const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview(); const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
const { asJson: selectedNotification } = useNotification(currentSelectedNotificationId);
// derived values // derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } = const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
notificationLiteByNotificationId(currentSelectedNotificationId); notificationLiteByNotificationId(currentSelectedNotificationId);
const isNodeDCNotification = selectedNotification?.sender?.startsWith("in_app:nodedc:") ?? false;
// fetching workspace work item properties // fetching workspace work item properties
useWorkspaceIssueProperties(workspaceSlug); useWorkspaceIssueProperties(workspaceSlug);
@ -128,6 +132,8 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
/> />
)} )}
</> </>
) : isNodeDCNotification && workspaceSlug ? (
<NodeDCNotificationDetail notificationId={currentSelectedNotificationId} workspaceSlug={workspaceSlug} />
) : ( ) : (
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} /> <PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)} )}

View File

@ -160,10 +160,10 @@ export function NotificationContent({
renderCommentBox?: boolean; renderCommentBox?: boolean;
}) { }) {
const { data, triggered_by_details: triggeredBy } = notification; const { data, triggered_by_details: triggeredBy } = notification;
const notificationField = data?.issue_activity.field; const notificationField = data?.issue_activity?.field;
const newValue = data?.issue_activity.new_value; const newValue = data?.issue_activity?.new_value;
const oldValue = data?.issue_activity.old_value; const oldValue = data?.issue_activity?.old_value;
const verb = data?.issue_activity.verb; const verb = data?.issue_activity?.verb;
const fieldData: TNotificationFieldData = { const fieldData: TNotificationFieldData = {
field: notificationField, field: notificationField,

View File

@ -40,8 +40,10 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
const issueId = notification?.data?.issue?.id || undefined; const issueId = notification?.data?.issue?.id || undefined;
const workspace = getWorkspaceBySlug(workspaceSlug); 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 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 () => { const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) { 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) const handleNodeDCNotification = async () => {
return <></>; 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 ( return (
<Row <Row
@ -77,7 +103,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
"bg-accent-primary/5": notification.read_at === null, "bg-accent-primary/5": notification.read_at === null,
} }
)} )}
onClick={handleNotificationIssuePeekOverview} onClick={handleNotificationClick}
> >
{notification.read_at === null && ( {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" /> <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 w-full gap-2">
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1"> <div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
{notificationTriggeredBy && ( {notificationTriggeredBy ? (
<Avatar <Avatar
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name} name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
src={getFileURL(notificationTriggeredBy.avatar_url)} src={getFileURL(notificationTriggeredBy.avatar_url)}
@ -93,18 +119,29 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
shape="circle" shape="circle"
className="bg-layer-1 text-body-sm-medium" 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>
<div className="-mt-2 w-full space-y-1"> <div className="-mt-2 w-full space-y-1">
<div className="relative flex h-8 items-center gap-3"> <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"> <div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
<NotificationContent {isIssueNotification && projectId ? (
notification={notification} <NotificationContent
workspaceId={workspace.id} notification={notification}
workspaceSlug={workspaceSlug} workspaceId={workspace.id}
projectId={projectId} 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> </div>
<NotificationOption <NotificationOption
workspaceSlug={workspaceSlug} 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="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"> <div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp; {isIssueNotification ? (
{notification?.data?.issue?.name} <>
{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>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{notification?.snoozed_till ? ( {notification?.snoozed_till ? (

View File

@ -4,10 +4,11 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import type { ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useSearchParams, usePathname } from "next/navigation"; import { useSearchParams, usePathname } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// components // components
import { LogoSpinner } from "@/components/common/logo-spinner"; import { LogoSpinner } from "@/components/common/logo-spinner";
// helpers // helpers
@ -17,6 +18,10 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user"; import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
// services
import { WorkspaceService } from "@/services/workspace.service";
const workspaceService = new WorkspaceService();
type TPageType = EPageTypes; type TPageType = EPageTypes;
@ -35,6 +40,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
const router = useAppRouter(); const router = useAppRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = searchParams.get("next_path"); const nextPath = searchParams.get("next_path");
const pendingInviteToastKey = useRef<string | undefined>(undefined);
// props // props
const { children, pageType = EPageTypes.AUTHENTICATED } = props; const { children, pageType = EPageTypes.AUTHENTICATED } = props;
// hooks // hooks
@ -55,6 +61,47 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
currentUserProfile?.onboarding_step?.workspace_invite && currentUserProfile?.onboarding_step?.workspace_invite &&
currentUserProfile?.onboarding_step?.workspace_join) || currentUserProfile?.onboarding_step?.workspace_join) ||
false; 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: (
<button
type="button"
className="text-12 font-semibold text-[rgb(var(--nodedc-accent-rgb))] transition hover:opacity-80"
onClick={() => router.push("/invitations")}
>
Открыть приглашения
</button>
),
});
}, [pendingWorkspaceInvitations, router, shouldWatchPendingInvites]);
const getWorkspaceRedirectionUrl = (): string => { const getWorkspaceRedirectionUrl = (): string => {
let redirectionRoute = "/create-workspace"; let redirectionRoute = "/create-workspace";

View File

@ -34,8 +34,8 @@ export type TNotificationIssueLite = {
}; };
export type TNotificationData = { export type TNotificationData = {
issue: TNotificationIssueLite | undefined; issue?: TNotificationIssueLite | undefined;
issue_activity: { issue_activity?: {
id: string | undefined; id: string | undefined;
actor: string | undefined; actor: string | undefined;
field: string | undefined; field: string | undefined;
@ -44,6 +44,14 @@ export type TNotificationData = {
new_value: string | undefined; new_value: string | undefined;
old_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 = { export type TNotification = {