UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректный flow уведомлений Tasker
This commit is contained in:
parent
6737138ab7
commit
fc59481703
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ export default function InvitationsLayout() {
|
|||
return <Outlet />;
|
||||
}
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Invitations" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Приглашения - NODE.DC Tasker" }];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
try {
|
||||
await workspaceService.joinWorkspaces({ invitations: invitationsRespond });
|
||||
void 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(() => {
|
||||
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);
|
||||
});
|
||||
})
|
||||
.catch((_err) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong_please_try_again"),
|
||||
});
|
||||
setIsJoiningWorkspaces(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openNotifications = () => {
|
||||
if (fallbackWorkspaceSlug) {
|
||||
router.push(`/${fallbackWorkspaceSlug}?workspaceNotifications=open`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/invitations");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<div className="flex min-h-screen flex-col bg-surface-1 sm:flex-row">
|
||||
<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">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<PlaneLogo className="h-9 w-auto text-primary" />
|
||||
</Link>
|
||||
<div className="text-13 text-primary sm:pt-6">{currentUser?.email}</div>
|
||||
</div>
|
||||
<NodeDCStandaloneShell
|
||||
notificationsCount={notificationsCount}
|
||||
onOpenNotifications={openNotifications}
|
||||
showUserControls={!!currentUser}
|
||||
>
|
||||
{invitations ? (
|
||||
invitations.length > 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8 sm:px-12 sm:py-12">
|
||||
<div className="w-full max-w-3xl space-y-10">
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-16">{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}</h5>
|
||||
<h4 className="text-20 font-semibold">{t("join_a_workspace")}</h4>
|
||||
hasInvitations ? (
|
||||
<div className="flex flex-1 items-center justify-center py-10">
|
||||
<div className="w-full max-w-4xl space-y-7">
|
||||
<div className="nodedc-glass-surface rounded-[2rem] border-0 px-6 py-6 sm:px-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<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 className="max-h-[45vh] space-y-4 overflow-y-auto md:max-h-[52vh] md:max-w-2xl">
|
||||
<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 className="max-h-[48vh] space-y-3 overflow-y-auto pr-1 md:max-h-[54vh]">
|
||||
{invitations.map((invitation) => {
|
||||
const isSelected = invitationsRespond.includes(invitation.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={invitation.id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-sm border px-3.5 py-5 ${
|
||||
isSelected ? "border-accent-strong" : "border-subtle hover:bg-layer-1"
|
||||
className={`group flex w-full cursor-pointer items-center gap-4 rounded-[1.6rem] px-4 py-4 text-left transition ${
|
||||
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")}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 rounded-full bg-black/10 p-1">
|
||||
<WorkspaceLogo
|
||||
logo={invitation.workspace.logo_url}
|
||||
name={invitation.workspace.name}
|
||||
classNames="size-9 flex-shrink-0"
|
||||
classNames="size-11 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-13 font-medium">{truncateText(invitation.workspace.name, 30)}</div>
|
||||
<p className="text-11 text-secondary">
|
||||
<div className="text-15 font-semibold">{truncateText(invitation.workspace.name, 42)}</div>
|
||||
<p className={`mt-1 text-12 ${isSelected ? "opacity-70" : "text-secondary"}`}>
|
||||
{t(ROLE_DETAILS[invitation.role as keyof typeof ROLE_DETAILS]?.i18n_title || "")}
|
||||
</p>
|
||||
</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" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
|
|
@ -165,13 +191,14 @@ function UserInvitationsPage() {
|
|||
onClick={submitInvitations}
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
loading={isJoiningWorkspaces}
|
||||
className="nodedc-empty-state-primary min-w-[12rem]"
|
||||
>
|
||||
{t("accept_and_join")}
|
||||
Принять выбранные
|
||||
</Button>
|
||||
<Link href={`/${redirectWorkspaceSlug}`}>
|
||||
<span>
|
||||
<Button variant="secondary" size="lg">
|
||||
{t("go_home")}
|
||||
<Button variant="secondary" size="lg" className="nodedc-empty-state-secondary">
|
||||
Вернуться на главную
|
||||
</Button>
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -179,20 +206,32 @@ function UserInvitationsPage() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed top-0 left-0 grid h-full w-full place-items-center">
|
||||
<EmptyState
|
||||
title={t("no_pending_invites")}
|
||||
description={t("you_can_see_here_if_someone_invites_you_to_a_workspace")}
|
||||
image={emptyInvitation}
|
||||
primaryButton={{
|
||||
text: t("back_to_home"),
|
||||
onClick: () => router.push("/"),
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center py-10">
|
||||
<div className="nodedc-glass-surface relative w-full max-w-[34rem] 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))]">
|
||||
<Sparkles className="size-11" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<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>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</NodeDCStandaloneShell>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ export default function WorkspaceInvitationsLayout() {
|
|||
return <Outlet />;
|
||||
}
|
||||
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Workspace Invitations" }];
|
||||
export const meta: Route.MetaFunction = () => [{ title: "Workspace приглашение - NODE.DC Tasker" }];
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
});
|
||||
router.push(invitationDetail.email === currentUser?.email ? `/${invitationDetail.workspace.slug}` : "/");
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
.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));
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
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">
|
||||
<h2 className="text-18 uppercase">INVITATION NOT FOUND</h2>
|
||||
</div>
|
||||
<InvitationShell
|
||||
title="Приглашение не найдено"
|
||||
description="Ссылка устарела или была отозвана администратором workspace."
|
||||
action={<HomeButton routerPush={() => router.push("/")} />}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpace
|
||||
title={`You have been invited to ${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."
|
||||
<InvitationShell
|
||||
eyebrow="Workspace invite"
|
||||
title={`Вас пригласили в ${invitationDetail.workspace.name}`}
|
||||
description="Примите приглашение, чтобы получить доступ к workspace и связанным проектам Tasker."
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleAccept}
|
||||
className="nodedc-empty-state-primary"
|
||||
prependIcon={<Check className="size-4" />}
|
||||
>
|
||||
<EmptySpaceItem Icon={CheckIcon} title="Accept" action={handleAccept} />
|
||||
<EmptySpaceItem Icon={CloseIcon} title="Ignore" action={handleReject} />
|
||||
</EmptySpace>
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleReject}
|
||||
className="nodedc-empty-state-secondary"
|
||||
prependIcon={<X className="size-4" />}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : error || invitationDetail?.responded_at ? (
|
||||
invitationDetail?.accepted ? (
|
||||
<EmptySpace
|
||||
title={`You are already a member of ${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."
|
||||
>
|
||||
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" />
|
||||
</EmptySpace>
|
||||
<InvitationShell
|
||||
title={`Вы уже участник ${invitationDetail.workspace.name}`}
|
||||
description="Приглашение принято. Можно вернуться в Tasker и продолжить работу."
|
||||
action={<HomeButton routerPush={() => router.push("/")} />}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpace
|
||||
title="This invitation link is not active anymore."
|
||||
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."
|
||||
link={{ text: "Or start from an empty project", href: "/" }}
|
||||
>
|
||||
{!currentUser ? (
|
||||
<EmptySpaceItem Icon={User2} title="Sign in to continue" href="/" />
|
||||
) : (
|
||||
<EmptySpaceItem Icon={Boxes} title="Continue to home" href="/" />
|
||||
)}
|
||||
</EmptySpace>
|
||||
<InvitationShell
|
||||
title="Ссылка приглашения больше не активна"
|
||||
description={
|
||||
currentUser
|
||||
? "Вернитесь на главную страницу Tasker или запросите новое приглашение."
|
||||
: "Войдите через NODE.DC и запросите новое приглашение, если доступ всё ещё нужен."
|
||||
}
|
||||
action={<HomeButton routerPush={() => router.push("/")} />}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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 />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NodeDCStandaloneShell>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { InboxIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
|
||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
|
||||
type TNodeDCStandaloneShellProps = {
|
||||
children: ReactNode;
|
||||
notificationsCount?: number;
|
||||
onOpenNotifications?: () => void;
|
||||
showUserControls?: boolean;
|
||||
};
|
||||
|
||||
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
|
||||
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload: { logoLinkUrl?: string } | null) => {
|
||||
if (isMounted && payload?.logoLinkUrl) setLogoLinkUrl(payload.logoLinkUrl);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full overflow-hidden bg-[#050507] text-primary">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div className="absolute top-[-18rem] left-[-12rem] h-[34rem] w-[34rem] rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-[120px]" />
|
||||
<div className="absolute right-[-14rem] bottom-[-18rem] h-[38rem] w-[38rem] rounded-full bg-white/7 blur-[140px]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,255,255,0.06),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0))]" />
|
||||
</div>
|
||||
|
||||
<header className="nodedc-expanded-toolbar-shell absolute inset-x-0 top-0 z-[2]">
|
||||
<div className="nodedc-expanded-toolbar-top">
|
||||
<div className="nodedc-expanded-toolbar-left">
|
||||
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
|
||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="nodedc-expanded-toolbar-center" />
|
||||
<div className="nodedc-expanded-toolbar-right">
|
||||
{showUserControls && (
|
||||
<div className="nodedc-expanded-user-group">
|
||||
{onOpenNotifications && (
|
||||
<Tooltip tooltipContent={t("notification.label")} position="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button relative flex items-center justify-center"
|
||||
data-active={false}
|
||||
aria-label={t("notification.label")}
|
||||
onClick={onOpenNotifications}
|
||||
>
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<InboxIcon className="size-5" />
|
||||
</span>
|
||||
{notificationsCount > 0 && (
|
||||
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<UserMenuRoot variant="expanded-toolbar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-[1] flex min-h-screen w-full items-center justify-center px-5 py-10 pt-[calc(var(--nodedc-shell-height)+2.25rem)]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
|
|
@ -32,11 +32,25 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
|||
const [inviteModal, setInviteModal] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const {
|
||||
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
|
||||
project: {
|
||||
fetchProjectMembers,
|
||||
getFilteredProjectMemberDetails,
|
||||
getProjectMemberFetchStatus,
|
||||
getProjectMemberIds,
|
||||
filters,
|
||||
},
|
||||
} = useMember();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId.toString());
|
||||
const projectMemberIds = getProjectMemberIds(projectId.toString(), true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
void fetchProjectMembers(workspaceSlug.toString(), projectId.toString(), true).catch(console.error);
|
||||
}, [fetchProjectMembers, projectId, workspaceSlug]);
|
||||
|
||||
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
|
||||
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
|
||||
|
|
@ -53,7 +67,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
|||
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
|
||||
);
|
||||
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
// Handler for role filter updates
|
||||
const handleRoleFilterUpdate = (role: string) => {
|
||||
|
|
@ -90,7 +104,6 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
|||
className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -113,7 +126,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!projectMemberIds ? (
|
||||
{!hasFetchedProjectMembers ? (
|
||||
<MembersSettingsLoader />
|
||||
) : (
|
||||
<div className="nodedc-settings-card overflow-hidden px-1 py-1">
|
||||
|
|
|
|||
|
|
@ -21,21 +21,25 @@ type Props = {
|
|||
value: any;
|
||||
onChange: (val: string) => void;
|
||||
isDisabled?: boolean;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const MemberSelect = observer(function MemberSelect(props: Props) {
|
||||
const { value, onChange, isDisabled = false } = props;
|
||||
const { value, onChange, isDisabled = false, projectId: explicitProjectId } = props;
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
const { projectId: routeProjectId } = useParams();
|
||||
const projectId = explicitProjectId ?? routeProjectId?.toString();
|
||||
// store hooks
|
||||
const {
|
||||
project: { projectMemberIds, getProjectMemberDetails },
|
||||
project: { getProjectMemberDetails, getProjectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId, true) : null;
|
||||
|
||||
const options = projectMemberIds
|
||||
?.map((userId) => {
|
||||
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
|
||||
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId) : null;
|
||||
|
||||
if (!memberDetails?.member) return;
|
||||
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
|
||||
|
|
@ -59,7 +63,7 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
|||
content: React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
|
||||
const selectedOption = projectId ? getProjectMemberDetails(value, projectId) : null;
|
||||
|
||||
return (
|
||||
<SearchSelectionDropdown
|
||||
|
|
@ -81,7 +85,6 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
|||
}
|
||||
buttonClassName="nodedc-settings-select !w-full !justify-between !px-4 !py-3"
|
||||
options={
|
||||
options &&
|
||||
options && [
|
||||
...options,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("project_settings.general.toast.success"),
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
|
@ -131,6 +132,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("project_settings.general.toast.success"),
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
|
@ -154,6 +156,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
submitChanges({ project_lead: val });
|
||||
}}
|
||||
isDisabled={!isAdmin}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -178,6 +181,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
submitChanges({ default_assignee: val });
|
||||
}}
|
||||
isDisabled={!isAdmin}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, BellRing, FolderKanban, UserRound, UsersRound } from "lucide-react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
import { closeWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
|
||||
import { useNotification } from "@/hooks/store/notifications/use-notification";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { NotificationOption } from "../sidebar/notification-card/options";
|
||||
|
||||
type TNodeDCNotificationDetailProps = {
|
||||
notificationId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const NodeDCNotificationDetail = (props: TNodeDCNotificationDetailProps) => {
|
||||
const { notificationId, workspaceSlug } = props;
|
||||
const router = useAppRouter();
|
||||
const { asJson: notification } = useNotification(notificationId);
|
||||
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
|
||||
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
|
||||
|
||||
if (!notification?.id) return <></>;
|
||||
|
||||
const targetUrl = notification.data?.target_url;
|
||||
const isProjectTarget = !!notification.data?.project_id || notification.sender?.includes("project_");
|
||||
const actionLabel = isProjectTarget ? "Открыть проект" : "Перейти в пространство";
|
||||
const actor = notification.triggered_by_details;
|
||||
const contextItems = [
|
||||
{
|
||||
icon: <UsersRound className="size-4" />,
|
||||
label: "Workspace",
|
||||
value: notification.data?.workspace_name,
|
||||
},
|
||||
{
|
||||
icon: <FolderKanban className="size-4" />,
|
||||
label: "Проект",
|
||||
value: notification.data?.project_name,
|
||||
},
|
||||
{
|
||||
icon: <UserRound className="size-4" />,
|
||||
label: "Роль",
|
||||
value: notification.data?.role,
|
||||
},
|
||||
].filter((item) => item.value);
|
||||
|
||||
const handleOpenTarget = () => {
|
||||
if (!targetUrl) return;
|
||||
|
||||
closeWorkspaceNotificationsModal();
|
||||
router.push(targetUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-8 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-13 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
|
||||
NODE.DC уведомление
|
||||
</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
{notification.created_at ? calculateTimeAgo(notification.created_at) : "Новое событие"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{targetUrl && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleOpenTarget}
|
||||
className="nodedc-empty-state-primary min-w-[12rem]"
|
||||
appendIcon={<ArrowRight className="size-4" />}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<NotificationOption
|
||||
workspaceSlug={workspaceSlug}
|
||||
notificationId={notification.id}
|
||||
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
|
||||
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
|
||||
customSnoozeModal={customSnoozeModal}
|
||||
setCustomSnoozeModal={setCustomSnoozeModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="nodedc-glass-surface relative overflow-hidden rounded-[2rem] px-8 py-8">
|
||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/[0.045]">
|
||||
{actor ? (
|
||||
<Avatar
|
||||
name={actor.display_name || actor.first_name}
|
||||
src={getFileURL(actor.avatar_url)}
|
||||
size={64}
|
||||
shape="circle"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<BellRing className="size-8 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-24 font-semibold tracking-[-0.03em] text-primary">
|
||||
{notification.title || "Новое событие в Tasker"}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-15 leading-7 text-secondary">
|
||||
{notification.message_stripped ||
|
||||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{actor?.display_name && (
|
||||
<div className="mt-5 text-13 text-tertiary">
|
||||
Инициатор: <span className="text-secondary">{actor.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextItems.length > 0 && (
|
||||
<div className="mt-8 grid gap-3 md:grid-cols-3">
|
||||
{contextItems.map((item) => (
|
||||
<div key={item.label} className="rounded-[1.25rem] bg-white/[0.035] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-12 font-semibold tracking-[0.14em] text-tertiary uppercase">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-15 font-semibold text-primary">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -13,7 +13,9 @@ import { EmptyStateCompact } from "@plane/propel/empty-state";
|
|||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { NodeDCNotificationDetail } from "@/components/workspace-notifications/detail/nodedc-notification-detail";
|
||||
// hooks
|
||||
import { useNotification } from "@/hooks/store/notifications/use-notification";
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
|
@ -40,9 +42,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
} = useWorkspaceNotifications();
|
||||
const { fetchUserProjectInfo } = useUserPermissions();
|
||||
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
|
||||
const { asJson: selectedNotification } = useNotification(currentSelectedNotificationId);
|
||||
// derived values
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
|
||||
notificationLiteByNotificationId(currentSelectedNotificationId);
|
||||
const isNodeDCNotification = selectedNotification?.sender?.startsWith("in_app:nodedc:") ?? false;
|
||||
|
||||
// fetching workspace work item properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
|
|
@ -128,6 +132,8 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
/>
|
||||
)}
|
||||
</>
|
||||
) : isNodeDCNotification && workspaceSlug ? (
|
||||
<NodeDCNotificationDetail notificationId={currentSelectedNotificationId} workspaceSlug={workspaceSlug} />
|
||||
) : (
|
||||
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -160,10 +160,10 @@ export function NotificationContent({
|
|||
renderCommentBox?: boolean;
|
||||
}) {
|
||||
const { data, triggered_by_details: triggeredBy } = notification;
|
||||
const notificationField = data?.issue_activity.field;
|
||||
const newValue = data?.issue_activity.new_value;
|
||||
const oldValue = data?.issue_activity.old_value;
|
||||
const verb = data?.issue_activity.verb;
|
||||
const notificationField = data?.issue_activity?.field;
|
||||
const newValue = data?.issue_activity?.new_value;
|
||||
const oldValue = data?.issue_activity?.old_value;
|
||||
const verb = data?.issue_activity?.verb;
|
||||
|
||||
const fieldData: TNotificationFieldData = {
|
||||
field: notificationField,
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
const issueId = notification?.data?.issue?.id || undefined;
|
||||
const workspace = getWorkspaceBySlug(workspaceSlug);
|
||||
|
||||
const notificationField = notification?.data?.issue_activity.field || undefined;
|
||||
const notificationField = notification?.data?.issue_activity?.field || undefined;
|
||||
const notificationTriggeredBy = notification.triggered_by_details || undefined;
|
||||
const isNodeDCNotification = notification.sender?.startsWith("in_app:nodedc:") ?? false;
|
||||
const isIssueNotification = !!notificationField && !!projectId && !!issueId && notification.entity_name === "issue";
|
||||
|
||||
const handleNotificationIssuePeekOverview = async () => {
|
||||
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
|
||||
|
|
@ -65,8 +67,32 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
}
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
|
||||
return <></>;
|
||||
const handleNodeDCNotification = async () => {
|
||||
if (isSnoozeStateModalOpen || customSnoozeModal) return;
|
||||
|
||||
setPeekIssue(undefined);
|
||||
setCurrentSelectedNotificationId(notificationId);
|
||||
|
||||
if (notification.read_at === null) {
|
||||
try {
|
||||
await markNotificationAsRead(workspaceSlug);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = () => {
|
||||
if (isIssueNotification) {
|
||||
void handleNotificationIssuePeekOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNodeDCNotification) void handleNodeDCNotification();
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !notificationId || !notification?.id || !workspace?.id) return <></>;
|
||||
if (!isIssueNotification && !isNodeDCNotification) return <></>;
|
||||
|
||||
return (
|
||||
<Row
|
||||
|
|
@ -77,7 +103,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
"bg-accent-primary/5": notification.read_at === null,
|
||||
}
|
||||
)}
|
||||
onClick={handleNotificationIssuePeekOverview}
|
||||
onClick={handleNotificationClick}
|
||||
>
|
||||
{notification.read_at === null && (
|
||||
<div className="absolute top-[50%] left-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-primary" />
|
||||
|
|
@ -85,7 +111,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
|
||||
<div className="relative flex w-full gap-2">
|
||||
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
|
||||
{notificationTriggeredBy && (
|
||||
{notificationTriggeredBy ? (
|
||||
<Avatar
|
||||
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
|
||||
src={getFileURL(notificationTriggeredBy.avatar_url)}
|
||||
|
|
@ -93,18 +119,29 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
shape="circle"
|
||||
className="bg-layer-1 text-body-sm-medium"
|
||||
/>
|
||||
) : (
|
||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="h-6 w-auto opacity-80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 w-full space-y-1">
|
||||
<div className="relative flex h-8 items-center gap-3">
|
||||
<div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
|
||||
{isIssueNotification && projectId ? (
|
||||
<NotificationContent
|
||||
notification={notification}
|
||||
workspaceId={workspace.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{notificationTriggeredBy?.display_name && (
|
||||
<span className="font-medium text-primary">{notificationTriggeredBy.display_name} </span>
|
||||
)}
|
||||
<span className="text-tertiary">{notification.title}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<NotificationOption
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
|
@ -118,8 +155,17 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
|||
|
||||
<div className="relative flex items-center gap-3 text-caption-sm-regular text-secondary">
|
||||
<div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
|
||||
{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(" · ")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{notification?.snoozed_till ? (
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(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: (
|
||||
<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 => {
|
||||
let redirectionRoute = "/create-workspace";
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue