mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
[WEB-2932] chore: member validation and improvement (#2139)
* feat: add member validation * fix: member validation * fix: remove workspace guests from all team level dropdowns * chore: add or remove members from create update modal * chore: team member role update * chore: remove team pages/ views favorite option * chore: minor UI fixes * fix: team member statistics * chore: update members leave/ role update error message * chore: remove member validation for activity * chore: member removal --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -21,13 +22,14 @@ from plane.app.permissions import (
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
|
||||
from plane.ee.models import TeamSpaceMember, TeamSpaceProject
|
||||
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
|
||||
from plane.ee.bgtasks.project_activites_task import project_activity
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
@@ -55,6 +57,35 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
def get_team_error_message(
|
||||
self,
|
||||
is_singular,
|
||||
team_count,
|
||||
type,
|
||||
project_member_id=None,
|
||||
requested_project_member_id=None,
|
||||
):
|
||||
if type == "update_role":
|
||||
return (
|
||||
"This project is linked to a team they are in. Remove them from that team first."
|
||||
if is_singular
|
||||
else f"This project is linked to {team_count} teams they are in. Remove them from those teams first."
|
||||
)
|
||||
|
||||
return (
|
||||
(
|
||||
"This project is linked to a team you are in. Remove yourself from that team first."
|
||||
if is_singular
|
||||
else f"This project is linked to {team_count} teams you are in. Remove yourself from those teams first."
|
||||
)
|
||||
if str(project_member_id) == str(requested_project_member_id)
|
||||
else (
|
||||
"This project is linked to a team they are in. Remove them from that team first."
|
||||
if is_singular
|
||||
else f"This project is linked to {team_count} teams they are in. Remove them from those teams first."
|
||||
)
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
# Get the list of members to be added to the project and their roles i.e. the user_id and the role
|
||||
@@ -245,6 +276,28 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
team_ids = TeamSpaceMember.objects.filter(
|
||||
workspace__slug=slug, member_id=project_member.member_id
|
||||
).values_list("team_space_id", flat=True)
|
||||
team_project_ids = TeamSpaceProject.objects.filter(
|
||||
project_id=project_id, team_space_id__in=team_ids
|
||||
).values_list("team_space_id", flat=True)
|
||||
if (
|
||||
"role" in request.data
|
||||
and team_project_ids.exists()
|
||||
and int(request.data.get("role")) < 10
|
||||
):
|
||||
team_count = team_project_ids.count()
|
||||
is_singular = team_count == 1
|
||||
return Response(
|
||||
{
|
||||
"error": self.get_team_error_message(
|
||||
is_singular, team_count, "update_role"
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(
|
||||
project_member, data=request.data, partial=True
|
||||
)
|
||||
@@ -285,6 +338,32 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if this user is part of any team which is part of this project
|
||||
team_ids = TeamSpaceMember.objects.filter(
|
||||
workspace__slug=slug, member_id=project_member.member_id
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
team_project_ids = TeamSpaceProject.objects.filter(
|
||||
project_id=project_id, team_space_id__in=team_ids
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
# Check if this project is part of any team
|
||||
if team_project_ids.exists():
|
||||
team_count = team_project_ids.count()
|
||||
is_singular = team_count == 1
|
||||
return Response(
|
||||
{
|
||||
"error": self.get_team_error_message(
|
||||
is_singular,
|
||||
team_count,
|
||||
"leave",
|
||||
project_member.id,
|
||||
requesting_project_member.id,
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
project_activity.delay(
|
||||
@@ -293,11 +372,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
actor_id=str(request.user.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"members": [str(project_member.member_id)],
|
||||
"removed": True
|
||||
}
|
||||
),
|
||||
{"members": [str(project_member.member_id)], "removed": True}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
@@ -323,10 +399,40 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin"
|
||||
"error": """
|
||||
You cannot leave the project as your the only admin of the project
|
||||
you will have to either delete the project or create an another admin
|
||||
"""
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if this user is part of any team which is part of this project
|
||||
team_ids = TeamSpaceMember.objects.filter(
|
||||
workspace__slug=slug, member_id=request.user.id
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
team_project_ids = TeamSpaceProject.objects.filter(
|
||||
project_id=project_id, team_space_id__in=team_ids
|
||||
).values_list("team_space_id", flat=True)
|
||||
|
||||
# Check if this project is part of any team
|
||||
if team_project_ids.exists():
|
||||
team_count = team_project_ids.count()
|
||||
is_singular = team_count == 1
|
||||
return Response(
|
||||
{
|
||||
"error": self.get_team_error_message(
|
||||
is_singular,
|
||||
team_count,
|
||||
"leave",
|
||||
project_member.id,
|
||||
project_member.id,
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Deactivate the user
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
@@ -336,11 +442,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
actor_id=str(request.user.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"members": [str(request.user.id)],
|
||||
"removed": False
|
||||
}
|
||||
),
|
||||
{"members": [str(request.user.id)], "removed": False}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
|
||||
@@ -14,7 +14,12 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Cycle, Issue, IssueView, Page, IssueRelation, IssueAssignee
|
||||
from plane.ee.models import TeamSpacePage, TeamSpaceProject, TeamSpaceView
|
||||
from plane.ee.models import (
|
||||
TeamSpacePage,
|
||||
TeamSpaceProject,
|
||||
TeamSpaceView,
|
||||
TeamSpaceMember,
|
||||
)
|
||||
from plane.ee.permissions import TeamSpacePermission, WorkspaceUserPermission
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
from plane.payment.flags.flag_decorator import check_feature_flag
|
||||
@@ -341,7 +346,12 @@ class TeamSpaceStatisticsEndpoint(TeamBaseEndpoint):
|
||||
)
|
||||
return Response(issue_map, status=status.HTTP_200_OK)
|
||||
|
||||
def member_tree(self, project_ids, filters):
|
||||
def member_tree(self, team_space_id, project_ids, filters):
|
||||
# Get all team member ids
|
||||
team_member_ids = TeamSpaceMember.objects.filter(
|
||||
team_space_id=team_space_id
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
issue_ids = (
|
||||
Issue.issue_objects.filter(project_id__in=project_ids)
|
||||
.filter(**filters)
|
||||
@@ -349,7 +359,9 @@ class TeamSpaceStatisticsEndpoint(TeamBaseEndpoint):
|
||||
)
|
||||
# Get the issue map:
|
||||
issue_map = (
|
||||
IssueAssignee.objects.filter(issue__in=issue_ids)
|
||||
IssueAssignee.objects.filter(
|
||||
issue__in=issue_ids, assignee_id__in=team_member_ids
|
||||
)
|
||||
.annotate(identifier=F("assignee_id"))
|
||||
.values("identifier")
|
||||
.annotate(count=Count("id"))
|
||||
@@ -372,4 +384,4 @@ class TeamSpaceStatisticsEndpoint(TeamBaseEndpoint):
|
||||
if data_key == "projects":
|
||||
return self.project_tree(project_ids, filters)
|
||||
else:
|
||||
return self.member_tree(project_ids, filters)
|
||||
return self.member_tree(team_space_id, project_ids, filters)
|
||||
|
||||
@@ -105,13 +105,14 @@ class TeamSpaceMembersEndpoint(TeamBaseEndpoint):
|
||||
team_space = TeamSpace.objects.get(workspace__slug=slug, pk=team_space_id)
|
||||
|
||||
# Get the current team space members
|
||||
current_team_space_members = list(
|
||||
TeamSpaceMember.objects.filter(
|
||||
current_members = list(
|
||||
str(member_id) for member_id in TeamSpaceMember.objects.filter(
|
||||
workspace__slug=slug, team_space_id=team_space_id
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
{"member_ids": current_team_space_members}, cls=DjangoJSONEncoder
|
||||
{"member_ids": current_members}, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
# Get the list of team space members
|
||||
@@ -123,9 +124,20 @@ class TeamSpaceMembersEndpoint(TeamBaseEndpoint):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
# Set of newly added members
|
||||
added_members = set(member_ids) - set(current_members)
|
||||
|
||||
# Set of dropped members
|
||||
dropped_members = set(current_members) - set(member_ids)
|
||||
|
||||
# Remove dropped members from team
|
||||
TeamSpaceMember.objects.filter(
|
||||
workspace__slug=slug, team_space_id=team_space_id, member_id__in=dropped_members
|
||||
).delete()
|
||||
|
||||
# Create team space members
|
||||
bulk_team_space_members = []
|
||||
for member in member_ids:
|
||||
for member in added_members:
|
||||
sort_order = next(
|
||||
(
|
||||
member.get("sort_order")
|
||||
@@ -157,11 +169,6 @@ class TeamSpaceMembersEndpoint(TeamBaseEndpoint):
|
||||
workspace__slug=slug, team_space_id=team_space_id
|
||||
)
|
||||
|
||||
# add current members also to requested data to make it consistent
|
||||
request.data["member_ids"] = [
|
||||
str(mem) for mem in current_team_space_members
|
||||
] + request.data.get("member_ids", [])
|
||||
|
||||
# Create activity
|
||||
team_space_activity.delay(
|
||||
type="team_space.activity.updated",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
// plane web imports
|
||||
import { EUserPermissions } from "@/plane-web/constants";
|
||||
import { useTeams } from "@/plane-web/hooks/store";
|
||||
|
||||
type Props = {
|
||||
@@ -32,51 +34,53 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [teamMemberIds, setTeamMemberIds] = useState<string[]>([]);
|
||||
const [memberIdsToInvite, setMemberIdsToInvite] = useState<string[]>([]);
|
||||
// store hooks
|
||||
const {
|
||||
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
|
||||
} = useMember();
|
||||
const { getTeamById, getTeamMemberIds, addTeamMembers } = useTeams();
|
||||
const { getTeamById, getTeamMemberIds, updateTeamMembers } = useTeams();
|
||||
// derived values
|
||||
const teamDetail = teamId ? getTeamById(teamId) : undefined;
|
||||
const memberIds = teamId ? getTeamMemberIds(teamId) : [];
|
||||
const teamMemberIds = teamId ? getTeamMemberIds(teamId) : [];
|
||||
const uninvitedPeople = useMemo(
|
||||
() =>
|
||||
workspaceMemberIds?.filter((userId) => {
|
||||
const isInvited = memberIds?.find((u) => u === userId) || teamMemberIds?.find((u) => u === userId);
|
||||
const memberDetails = getWorkspaceMemberDetails(userId);
|
||||
if (memberDetails?.role === EUserPermissions.GUEST) return false;
|
||||
const isInvited = teamMemberIds?.find((u) => u === userId) || memberIdsToInvite?.find((u) => u === userId);
|
||||
return !isInvited;
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[teamMemberIds]
|
||||
[memberIdsToInvite]
|
||||
);
|
||||
const isButtonDisabled = useMemo(
|
||||
() => teamMemberIds.length === 0 || teamMemberIds.some((id) => !id),
|
||||
[teamMemberIds]
|
||||
() => memberIdsToInvite.length === 0 || memberIdsToInvite.some((id) => !id),
|
||||
[memberIdsToInvite]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
if (teamDetail) {
|
||||
setTeamMemberIds([""]);
|
||||
setMemberIdsToInvite([""]);
|
||||
} else {
|
||||
setTeamMemberIds([""]);
|
||||
setMemberIdsToInvite([""]);
|
||||
}
|
||||
}
|
||||
}, [teamDetail, isModalOpen]);
|
||||
|
||||
const handleModalClearAndClose = () => {
|
||||
setTeamMemberIds([]);
|
||||
setMemberIdsToInvite([]);
|
||||
handleModalClose();
|
||||
};
|
||||
|
||||
const handleAddTeamMembers = async () => {
|
||||
if (!teamId || !teamMemberIds) return;
|
||||
if (!teamId || !memberIdsToInvite) return;
|
||||
// clean up team member ids
|
||||
setTeamMemberIds(teamMemberIds.filter((id) => id !== ""));
|
||||
setMemberIdsToInvite(memberIdsToInvite.filter((id) => id !== ""));
|
||||
|
||||
setIsSubmitting(true);
|
||||
await addTeamMembers(workspaceSlug?.toString(), teamId, teamMemberIds)
|
||||
await updateTeamMembers(workspaceSlug?.toString(), teamId, uniq([...(teamMemberIds ?? []), ...memberIdsToInvite]))
|
||||
.then(() => {
|
||||
handleModalClearAndClose();
|
||||
setToast({
|
||||
@@ -140,7 +144,7 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Add coworkers, clients, and consultants</h3>
|
||||
<div className="mb-3 space-y-4">
|
||||
{teamMemberIds.map((memberId, index) => {
|
||||
{memberIdsToInvite.map((memberId, index) => {
|
||||
const selectedMember = getWorkspaceMemberDetails(memberId);
|
||||
return (
|
||||
<div key={memberId} className="group mb-1 flex items-center justify-between gap-x-4 text-sm w-full">
|
||||
@@ -164,11 +168,11 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
</button>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
setTeamMemberIds(
|
||||
teamMemberIds.map((_, i) => {
|
||||
setMemberIdsToInvite(
|
||||
memberIdsToInvite.map((_, i) => {
|
||||
if (i === index) {
|
||||
// Don't add if value already exists in array
|
||||
if (teamMemberIds.includes(val)) return "";
|
||||
if (memberIdsToInvite.includes(val)) return "";
|
||||
return val;
|
||||
}
|
||||
return _;
|
||||
@@ -180,12 +184,12 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0 ">
|
||||
{teamMemberIds.length > 1 && (
|
||||
{memberIdsToInvite.length > 1 && (
|
||||
<div className="flex-item flex w-6">
|
||||
<button
|
||||
type="button"
|
||||
className="place-items-center self-center rounded"
|
||||
onClick={() => setTeamMemberIds(teamMemberIds.filter((_, i) => i !== index))}
|
||||
onClick={() => setMemberIdsToInvite(memberIdsToInvite.filter((_, i) => i !== index))}
|
||||
>
|
||||
<X className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
@@ -201,7 +205,7 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 bg-transparent py-2 pr-3 text-sm font-medium text-custom-primary outline-custom-primary"
|
||||
onClick={() => setTeamMemberIds([...teamMemberIds, ""])}
|
||||
onClick={() => setMemberIdsToInvite([...memberIdsToInvite, ""])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add another
|
||||
@@ -219,8 +223,8 @@ export const AddTeamMembersModal: FC<Props> = observer((props) => {
|
||||
onClick={handleAddTeamMembers}
|
||||
>
|
||||
{isSubmitting
|
||||
? `${teamMemberIds && teamMemberIds.length > 1 ? "Adding members" : "Adding member"}`
|
||||
: `${teamMemberIds && teamMemberIds.length > 1 ? "Add members" : "Add member"}`}
|
||||
? `${memberIdsToInvite && memberIdsToInvite.length > 1 ? "Adding members" : "Adding member"}`
|
||||
: `${memberIdsToInvite && memberIdsToInvite.length > 1 ? "Add members" : "Add member"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
// types
|
||||
import { TTeam } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
// ui
|
||||
import { Button, CustomEmojiIconPicker, Input, Logo, Tooltip } from "@plane/ui";
|
||||
import { Button, CustomEmojiIconPicker, Input, Logo } from "@plane/ui";
|
||||
// components
|
||||
import { MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
|
||||
import { RichTextEditor } from "@/components/editor";
|
||||
@@ -17,8 +16,9 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// store hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
import { useMember, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { EUserPermissions } from "@/plane-web/constants";
|
||||
import { useTeams } from "@/plane-web/hooks/store";
|
||||
import { useEditorMentionSearch } from "@/plane-web/hooks/use-editor-mention-search";
|
||||
// services
|
||||
@@ -44,9 +44,20 @@ export const CreateOrUpdateTeamForm: React.FC<Props> = observer((props) => {
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
|
||||
} = useMember();
|
||||
const { getTeamMemberIds } = useTeams();
|
||||
// derived values
|
||||
const teamMemberIds = teamDetail?.id ? (getTeamMemberIds(teamDetail.id) ?? []) : (formData?.member_ids ?? []);
|
||||
const userIdsWithAdminOrMemberRole = useMemo(
|
||||
() =>
|
||||
workspaceMemberIds?.filter((userId) => {
|
||||
const memberDetails = getWorkspaceMemberDetails(userId);
|
||||
return memberDetails?.role === EUserPermissions.GUEST ? false : true;
|
||||
}),
|
||||
[workspaceMemberIds, getWorkspaceMemberDetails]
|
||||
);
|
||||
// use editor mention search
|
||||
const { searchEntity } = useEditorMentionSearch({
|
||||
memberIds: teamMemberIds,
|
||||
@@ -161,6 +172,7 @@ export const CreateOrUpdateTeamForm: React.FC<Props> = observer((props) => {
|
||||
<p className="text-sm text-custom-text-300">Team lead</p>
|
||||
<MemberDropdown
|
||||
value={formData.lead_id ?? ""}
|
||||
memberIds={userIdsWithAdminOrMemberRole}
|
||||
onChange={(val) => {
|
||||
if (val && val !== formData.lead_id) {
|
||||
handleFormDataChange("lead_id", val);
|
||||
@@ -180,21 +192,12 @@ export const CreateOrUpdateTeamForm: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="flex gap-1.5 items-center text-sm text-custom-text-300">
|
||||
<span>{teamDetail?.id ? "Add team members" : "Team members"}</span>
|
||||
{teamDetail?.id && (
|
||||
<Tooltip
|
||||
position="right"
|
||||
tooltipContent="Team members cannot be removed directly from here. Please visit the team details page sidebar to manage removals."
|
||||
>
|
||||
<InfoIcon className="size-3.5 text-custom-text-400 hover:text-custom-text-300 cursor-help outline-none" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</p>
|
||||
<p className="flex gap-1.5 items-center text-sm text-custom-text-300">Team members</p>
|
||||
<MemberDropdown
|
||||
value={formData.member_ids ?? []}
|
||||
memberIds={userIdsWithAdminOrMemberRole}
|
||||
onChange={(val) => {
|
||||
handleFormDataChange("member_ids", Array.from(new Set([...(teamDetail?.member_ids ?? []), ...val])));
|
||||
handleFormDataChange("member_ids", val);
|
||||
}}
|
||||
multiple
|
||||
buttonVariant="border-with-text"
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Props } from "@/components/icons/types";
|
||||
// hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { EUserPermissions } from "@/plane-web/constants";
|
||||
import { useTeams } from "@/plane-web/hooks/store";
|
||||
// local components
|
||||
import { TeamsPropertiesList } from "./list";
|
||||
@@ -34,6 +36,9 @@ export const TeamsOverviewSidebarProperties = observer((props: TTeamsOverviewSid
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const {
|
||||
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
|
||||
} = useMember();
|
||||
const { getTeamById, getTeamEntitiesById, getTeamEntitiesLoaderById, updateTeam } = useTeams();
|
||||
// derived values
|
||||
const team = getTeamById(teamId);
|
||||
@@ -41,6 +46,14 @@ export const TeamsOverviewSidebarProperties = observer((props: TTeamsOverviewSid
|
||||
const teamEntitiesLoader = getTeamEntitiesLoaderById(teamId);
|
||||
const linkedEntitiesCount = teamEntities?.linked_entities.total;
|
||||
const teamEntitiesCount = teamEntities?.team_entities.total;
|
||||
const userIdsWithAdminOrMemberRole = useMemo(
|
||||
() =>
|
||||
workspaceMemberIds?.filter((userId) => {
|
||||
const memberDetails = getWorkspaceMemberDetails(userId);
|
||||
return memberDetails?.role === EUserPermissions.GUEST ? false : true;
|
||||
}),
|
||||
[workspaceMemberIds, getWorkspaceMemberDetails]
|
||||
);
|
||||
|
||||
if (!team) return null;
|
||||
|
||||
@@ -64,6 +77,7 @@ export const TeamsOverviewSidebarProperties = observer((props: TTeamsOverviewSid
|
||||
value: (
|
||||
<MemberDropdown
|
||||
value={team.lead_id ?? null}
|
||||
memberIds={userIdsWithAdminOrMemberRole}
|
||||
onChange={handleTeamLeadChange}
|
||||
multiple={false}
|
||||
buttonVariant="transparent-with-text"
|
||||
@@ -78,7 +92,7 @@ export const TeamsOverviewSidebarProperties = observer((props: TTeamsOverviewSid
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleTeamLeadChange, team.lead_id, isEditingAllowed]
|
||||
[handleTeamLeadChange, team.lead_id, userIdsWithAdminOrMemberRole, isEditingAllowed]
|
||||
);
|
||||
|
||||
const LINKED_ENTITIES: TPropertyListItem[] = useMemo(
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// common classNames
|
||||
const ICON_CLASSNAME = "size-2.5 rounded-full";
|
||||
const TITLE_CLASSNAME = "font-light text-custom-text-200";
|
||||
const ITEM_CONTAINER_CLASSNAME = "py-1";
|
||||
const VALUE_CLASSNAME = "font-semibold text-custom-text-300";
|
||||
|
||||
type TWorkloadDetailItemProps = {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
titleClassName?: string;
|
||||
valueClassName?: string;
|
||||
};
|
||||
|
||||
const WorkloadDetailItem = (props: TWorkloadDetailItemProps) => {
|
||||
const { title, icon, value, containerClassName, titleClassName, valueClassName } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-sm", containerClassName)}>
|
||||
<span className={cn("grow flex gap-2 items-center", titleClassName)}>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<span className={cn("flex-shrink-0 flex items-center", valueClassName)}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TTeamWorkloadDetailProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export const TeamWorkloadDetail: React.FC<TTeamWorkloadDetailProps> = observer((props) => {
|
||||
const { teamId } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<h4 className="text-sm text-custom-text-300 font-medium">Summary of team's issues</h4>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-2 py-2.5 border-b border-custom-border-100 text-[#FF9500]">
|
||||
<TriangleAlert size={16} />
|
||||
<span>12 issues are not assigned due date</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-b border-custom-border-100 px-2 pb-2">
|
||||
<WorkloadDetailItem title="States on chart" value="Issues" containerClassName="text-custom-text-300" />
|
||||
<WorkloadDetailItem
|
||||
title="Overdue"
|
||||
icon={<span className={cn(ICON_CLASSNAME, "bg-[#FFCCCC]")} />}
|
||||
value="12"
|
||||
titleClassName={TITLE_CLASSNAME}
|
||||
containerClassName={ITEM_CONTAINER_CLASSNAME}
|
||||
valueClassName={cn(VALUE_CLASSNAME, "bg-red-500 text-white px-0.5 rounded")}
|
||||
/>
|
||||
<WorkloadDetailItem
|
||||
title="Pending"
|
||||
icon={<span className={cn(ICON_CLASSNAME, "bg-custom-background-80/80")} />}
|
||||
value={
|
||||
<>
|
||||
<div className={"flex-shrink-0 absolute -top-0.5 -right-0.5 size-1.5 bg-red-500 rounded-full"} />
|
||||
120
|
||||
</>
|
||||
}
|
||||
titleClassName={TITLE_CLASSNAME}
|
||||
containerClassName={ITEM_CONTAINER_CLASSNAME}
|
||||
valueClassName={cn(VALUE_CLASSNAME, "relative bg-custom-background-80/80 px-0.5 rounded")}
|
||||
/>
|
||||
<WorkloadDetailItem
|
||||
title="Completed"
|
||||
icon={<span className={cn(ICON_CLASSNAME, "bg-[#004EFF]")} />}
|
||||
value="140"
|
||||
titleClassName={TITLE_CLASSNAME}
|
||||
containerClassName={ITEM_CONTAINER_CLASSNAME}
|
||||
valueClassName={cn(VALUE_CLASSNAME)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 lg:border-b border-custom-border-100 px-2 pb-2">
|
||||
<WorkloadDetailItem title="Other states" containerClassName="text-custom-text-300" />
|
||||
<WorkloadDetailItem
|
||||
title="Backlog"
|
||||
value="14"
|
||||
titleClassName={TITLE_CLASSNAME}
|
||||
containerClassName={ITEM_CONTAINER_CLASSNAME}
|
||||
valueClassName={cn(VALUE_CLASSNAME)}
|
||||
/>
|
||||
<WorkloadDetailItem
|
||||
title="Cancelled"
|
||||
value="3"
|
||||
titleClassName={TITLE_CLASSNAME}
|
||||
containerClassName={ITEM_CONTAINER_CLASSNAME}
|
||||
valueClassName={cn(VALUE_CLASSNAME)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export const TeamWorkloadSummary: React.FC<TTeamWorkloadSummaryProps> = observer
|
||||
{isUpdating && <Spinner size={12} className="animate-spin flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{teamWorkloadSummary && teamWorkloadSummary.no_due_date_issues > 0 ? (
|
||||
{teamWorkloadSummary ? (
|
||||
<div className="flex items-center gap-1.5 py-2.5 border-b border-custom-border-100 text-sm font-medium text-[#FF9500]">
|
||||
<TriangleAlert size={16} strokeWidth={2} />
|
||||
<span>{teamWorkloadSummary.no_due_date_issues} issues are not assigned due date</span>
|
||||
|
||||
@@ -58,13 +58,13 @@ export const TeamViewListItemAction: FC<Props> = observer((props) => {
|
||||
|
||||
// handlers
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug || !teamId) return;
|
||||
if (!workspaceSlug || !teamId || view.is_team_view) return;
|
||||
|
||||
addViewToFavorites(workspaceSlug.toString(), teamId.toString(), view.id);
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !teamId) return;
|
||||
if (!workspaceSlug || !teamId || view.is_team_view) return;
|
||||
|
||||
removeViewFromFavorites(workspaceSlug.toString(), teamId.toString(), view.id);
|
||||
};
|
||||
@@ -96,7 +96,7 @@ export const TeamViewListItemAction: FC<Props> = observer((props) => {
|
||||
{/* created by */}
|
||||
{<ButtonAvatars showTooltip={false} userIds={ownedByDetails?.id ?? []} />}
|
||||
|
||||
{isEditingAllowed && (
|
||||
{isEditingAllowed && !view.is_team_view && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -143,8 +143,9 @@ export class TeamPage extends BasePage implements TTeamPage {
|
||||
* @description returns true if the current logged in user can favorite the page
|
||||
*/
|
||||
get canCurrentUserFavoritePage() {
|
||||
const userRole = this.getUserWorkspaceRole();
|
||||
return !!userRole && userRole >= EUserPermissions.MEMBER;
|
||||
return false;
|
||||
// const userRole = this.getUserWorkspaceRole();
|
||||
// return !!userRole && userRole >= EUserPermissions.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import set from "lodash/set";
|
||||
import uniq from "lodash/uniq";
|
||||
import update from "lodash/update";
|
||||
import xor from "lodash/xor";
|
||||
import { makeObservable, action, computed, observable, runInAction } from "mobx";
|
||||
@@ -50,7 +51,7 @@ export interface ITeamStore {
|
||||
// CURD actions
|
||||
createTeam: (workspaceSlug: string, data: Partial<TTeam>) => Promise<TTeam>;
|
||||
updateTeam: (workspaceSlug: string, teamId: string, data: Partial<TTeam>) => Promise<TTeam>;
|
||||
addTeamMembers: (
|
||||
updateTeamMembers: (
|
||||
workspaceSlug: string,
|
||||
teamId: string,
|
||||
memberIds: string[],
|
||||
@@ -100,7 +101,7 @@ export class TeamStore implements ITeamStore {
|
||||
// CURD actions
|
||||
createTeam: action,
|
||||
updateTeam: action,
|
||||
addTeamMembers: action,
|
||||
updateTeamMembers: action,
|
||||
removeTeamMember: action,
|
||||
joinTeam: action,
|
||||
deleteTeam: action,
|
||||
@@ -378,8 +379,8 @@ export class TeamStore implements ITeamStore {
|
||||
// create team
|
||||
const team = await this.teamService.createTeam(workspaceSlug, data);
|
||||
// add team members
|
||||
if (data.member_ids) {
|
||||
await this.addTeamMembers(workspaceSlug, team.id, data.member_ids, false);
|
||||
if (data.member_ids && data.member_ids.length > 0) {
|
||||
await this.updateTeamMembers(workspaceSlug, team.id, data.member_ids, false);
|
||||
}
|
||||
// set team map along with member_ids
|
||||
runInAction(() => {
|
||||
@@ -407,21 +408,20 @@ export class TeamStore implements ITeamStore {
|
||||
const teamDetailsBeforeUpdate = this.getTeamById(teamId);
|
||||
// remove member_ids from data
|
||||
const { member_ids, ...payload } = data;
|
||||
// get member ids to be added
|
||||
const memberIdsToBeAdded = member_ids
|
||||
? member_ids.filter((memberId) => !teamDetailsBeforeUpdate.member_ids?.includes(memberId))
|
||||
: [];
|
||||
// Check if team projects have been updated
|
||||
const areProjectsUpdated =
|
||||
!!data.project_ids && xor(teamDetailsBeforeUpdate.project_ids, data.project_ids).length > 0;
|
||||
|
||||
// Check if team members have been updated
|
||||
const areMembersUpdated = !!member_ids && xor(teamDetailsBeforeUpdate.member_ids, member_ids).length > 0;
|
||||
try {
|
||||
// update team map
|
||||
runInAction(() => {
|
||||
update(this.teamMap, [teamId], (team) => ({ ...team, ...data }));
|
||||
});
|
||||
// add team members if any
|
||||
if (memberIdsToBeAdded.length > 0) {
|
||||
await this.addTeamMembers(workspaceSlug, teamId, memberIdsToBeAdded, false);
|
||||
if (areMembersUpdated) {
|
||||
await this.updateTeamMembers(workspaceSlug, teamId, member_ids, false);
|
||||
}
|
||||
// update team
|
||||
const team = await this.teamService.updateTeam(workspaceSlug, teamId, payload);
|
||||
@@ -445,14 +445,14 @@ export class TeamStore implements ITeamStore {
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds team members to a team and updates the store
|
||||
* Updates team members of a team and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param teamId
|
||||
* @param memberIds
|
||||
* @param updateTeamDetails
|
||||
* @returns Promise<TTeamMember[]>
|
||||
*/
|
||||
addTeamMembers = async (
|
||||
updateTeamMembers = async (
|
||||
workspaceSlug: string,
|
||||
teamId: string,
|
||||
memberIds: string[],
|
||||
@@ -461,8 +461,10 @@ export class TeamStore implements ITeamStore {
|
||||
try {
|
||||
// add team members
|
||||
const teamMembers = await this.teamService.addTeamMembers(workspaceSlug, teamId, memberIds);
|
||||
// set team members map
|
||||
runInAction(() => {
|
||||
// clear team members map
|
||||
set(this.teamMembersMap, [teamId], {});
|
||||
// set team members map
|
||||
teamMembers.forEach((member) => {
|
||||
set(this.teamMembersMap, [teamId, member.member], member);
|
||||
});
|
||||
@@ -529,8 +531,10 @@ export class TeamStore implements ITeamStore {
|
||||
return;
|
||||
}
|
||||
this.loader = "mutation";
|
||||
// get team member ids
|
||||
const teamMemberIds = this.getTeamMemberIds(teamId);
|
||||
// join team
|
||||
await this.addTeamMembers(workspaceSlug, teamId, [currentUserId]);
|
||||
await this.updateTeamMembers(workspaceSlug, teamId, uniq([...(teamMemberIds ?? []), currentUserId]), false);
|
||||
this.loader = "loaded";
|
||||
} catch (error) {
|
||||
console.error("Failed to join team", error);
|
||||
|
||||
Reference in New Issue
Block a user