Compare commits

..

1 Commits

Author SHA1 Message Date
sriram veeraghanta dcf04b21f9 chore: minor optimizations 2023-11-01 12:56:06 +05:30
195 changed files with 11682 additions and 7730 deletions
+14 -2
View File
@@ -4,15 +4,17 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
ProjectPublicCoverImagesEndpoint
)
@@ -51,7 +53,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberViewSet.as_view({"get": "list", "post": "create"}),
ProjectMemberViewSet.as_view({"get": "list"}),
name="project-member",
),
path(
@@ -65,6 +67,16 @@ urlpatterns = [
),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
+6
View File
@@ -5,6 +5,7 @@ from plane.api.views import (
WorkSpaceViewSet,
InviteWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -85,6 +86,11 @@ urlpatterns = [
),
name="workspace-member",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
+3
View File
@@ -7,12 +7,14 @@ from .project import (
ProjectMemberInvitationsViewset,
ProjectMemberInviteDetailViewSet,
ProjectIdentifierEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
@@ -51,6 +53,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
+3 -3
View File
@@ -249,11 +249,11 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
"".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
)
ri = redis_instance()
-4
View File
@@ -39,7 +39,6 @@ from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.api.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
@@ -120,9 +119,6 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
+82 -77
View File
@@ -482,83 +482,6 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
def create(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
bulk_issue_props = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
bulk_issue_props.append(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
member=request.user, workspace__slug=slug, project_id=project_id
)
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
if project_member.role > 10:
serializer = ProjectMemberAdminSerializer(project_members, many=True)
else:
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
@@ -644,6 +567,73 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
bulk_issue_props = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
bulk_issue_props.append(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
@@ -943,6 +933,21 @@ class ProjectDeployBoardViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
+15 -20
View File
@@ -472,7 +472,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
model = WorkspaceMember
permission_classes = [
WorkspaceEntityPermission,
WorkSpaceAdminPermission,
]
search_fields = [
@@ -489,25 +489,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.select_related("member")
)
def list(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
if workspace_member.role > 10:
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
else:
serializer = WorkSpaceMemberSerializer(
workspace_members,
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
if request.user.id == workspace_member.member_id:
@@ -1271,6 +1252,20 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
return Response(labels, status=status.HTTP_200_OK)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
class LeaveWorkspaceEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
+2 -2
View File
@@ -198,7 +198,7 @@ def filter_start_date(params, filter, method):
date_filter(filter=filter, date_term="start_date", queries=start_dates)
else:
if params.get("start_date", None) and len(params.get("start_date")):
filter["start_date"] = params.get("start_date")
date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", []))
return filter
@@ -209,7 +209,7 @@ def filter_target_date(params, filter, method):
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):
filter["target_date"] = params.get("target_date")
date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", []))
return filter
+3 -3
View File
@@ -10,13 +10,13 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react-color": "^3.0.9",
"@types/react-color" : "^3.0.9",
"@types/node": "^20.5.2",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
-85
View File
@@ -1,85 +0,0 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
// types
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
type Props = {
/**
* The children of the avatar group.
* These should ideally should be `Avatar` components
*/
children: React.ReactNode;
/**
* The maximum number of avatars to display.
* If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars.
* @default 2
*/
max?: number;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
};
export const AvatarGroup: React.FC<Props> = (props) => {
const { children, max = 2, showTooltip = true, size = "md" } = props;
// calculate total length of avatars inside the group
const totalAvatars = React.Children.toArray(children).length;
// slice the children to the maximum number of avatars
const avatars = React.Children.toArray(children).slice(0, max);
// assign the necessary props from the AvatarGroup component to the Avatar components
const avatarsWithUpdatedProps = avatars.map((avatar) => {
const updatedProps: Partial<Props> = {
showTooltip,
size,
};
return React.cloneElement(avatar as React.ReactElement, updatedProps);
});
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<div className={`flex ${sizeInfo.spacing}`}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div key={index} className="ring-1 ring-custom-border-200 rounded-full">
{avatar}
</div>
))}
{max < totalAvatars && (
<Tooltip
tooltipContent={`${totalAvatars} total`}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} ring-1 ring-custom-border-200 bg-custom-primary-500 text-white rounded-full grid place-items-center text-[9px]`}
style={
isAValidNumber(size)
? {
width: `${size}px`,
height: `${size}px`,
}
: {}
}
>
+{totalAvatars - max}
</div>
</Tooltip>
)}
</div>
);
};
-168
View File
@@ -1,168 +0,0 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
type Props = {
/**
* The name of the avatar which will be displayed on the tooltip
*/
name?: string;
/**
* The background color if the avatar image fails to load
*/
fallbackBackgroundColor?: string;
/**
* The text to display if the avatar image fails to load
*/
fallbackText?: string;
/**
* The text color if the avatar image fails to load
*/
fallbackTextColor?: string;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
/**
* The shape of the avatar
* Possible values: "circle", "square"
* @default "circle"
*/
shape?: "circle" | "square";
/**
* The source of the avatar image
*/
src?: string;
};
/**
* Get the size details based on the size prop
* @param size The size of the avatar
* @returns The size details
*/
export const getSizeInfo = (size: TAvatarSize) => {
switch (size) {
case "sm":
return {
avatarSize: "h-4 w-4",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "md":
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "base":
return {
avatarSize: "h-6 w-6",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
case "lg":
return {
avatarSize: "h-7 w-7",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
default:
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
}
};
/**
* Get the border radius based on the shape prop
* @param shape The shape of the avatar
* @returns The border radius
*/
export const getBorderRadius = (shape: "circle" | "square") => {
switch (shape) {
case "circle":
return "rounded-full";
case "square":
return "rounded-md";
default:
return "rounded-full";
}
};
/**
* Check if the value is a valid number
* @param value The value to check
* @returns Whether the value is a valid number or not
*/
export const isAValidNumber = (value: any) => {
return typeof value === "number" && !isNaN(value);
};
export const Avatar: React.FC<Props> = (props) => {
const {
name,
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
showTooltip = true,
size = "md",
shape = "circle",
src,
} = props;
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<Tooltip
tooltipContent={fallbackText ?? name ?? "?"}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} overflow-hidden grid place-items-center ${getBorderRadius(shape)}`}
style={
isAValidNumber(size)
? {
height: `${size}px`,
width: `${size}px`,
}
: {}
}
>
{src ? (
<img
src={src}
className={`h-full w-full ${getBorderRadius(shape)}`}
alt={name}
/>
) : (
<div
className={`${
sizeInfo.fontSize
} grid place-items-center h-full w-full ${getBorderRadius(shape)}`}
style={{
backgroundColor:
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
color: fallbackTextColor ?? "#ffffff",
}}
>
{name ? name[0].toUpperCase() : fallbackText ?? "?"}
</div>
)}
</div>
</Tooltip>
);
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./avatar-group";
export * from "./avatar";
@@ -1,10 +1,9 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
export * from "./icons";
export * from "./progress";
export * from "./spinners";
export * from "./tooltip";
export * from "./loader";
export * from "./tooltip";
export * from "./icons";
export * from "./breadcrumbs";
export * from "./dropdowns";
@@ -9,7 +9,7 @@ import useProjectMembers from "hooks/use-project-members";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
// icons
import { Check } from "lucide-react";
// types
@@ -31,13 +31,13 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
const options =
members?.map(({ member }) => ({
members?.map(({ member }: any) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
<Avatar user={member} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
+8 -4
View File
@@ -1,14 +1,18 @@
import React from "react";
import { X } from "lucide-react";
// icons
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// ui
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
import { X } from "lucide-react";
type Props = {
filters: Partial<IIssueFilterOptions>;
@@ -145,7 +149,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
@@ -169,7 +173,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar name={member?.display_name} src={member?.avatar} />
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
+1
View File
@@ -1,3 +1,4 @@
export * from "./date-filter-modal";
export * from "./date-filter-select";
export * from "./filters-list";
export * from "./workspace-filters-list";
@@ -0,0 +1,347 @@
import { FC } from "react";
// icons
import { X } from "lucide-react";
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// ui
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueLabels, IProject, IUserLite, IWorkspaceIssueFilterOptions, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
filters: Partial<IWorkspaceIssueFilterOptions>;
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
stateGroup: string[] | undefined;
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: FC<Props> = (props) => {
const { filters, setFilters, clearAllFilters, labels, members, project } = props;
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((filterKey) => {
const key = filterKey as keyof typeof filters;
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key]) ? (
<div className="space-x-2">
<div className="flex flex-wrap items-center gap-1">
{key === "state_group"
? filters.state_group?.map((stateGroup) => {
const group = stateGroup as TStateGroups;
return (
<p
key={group}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
style={{
color: STATE_GROUP_COLORS[group],
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
priority: filters.priority?.filter((p: any) => p !== priority),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
))
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "created_by"
? filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "labels"
? filters.labels?.map((labelId: string) => {
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
style={{
color: color,
backgroundColor: `${color}20`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span>{label.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
labels: filters.labels?.filter((l: any) => l !== labelId),
})
}
>
<X
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})
: key === "start_date"
? filters.start_date?.map((date: string) => {
if (filters.start_date && filters.start_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
start_date: filters.start_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "target_date"
? filters.target_date?.map((date: string) => {
if (filters.target_date && filters.target_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
target_date: filters.target_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>
<X className="h-3 w-3" />
</button>
)}
</div>
);
};
@@ -10,9 +10,10 @@ import useIssuesView from "hooks/use-issues-view";
import emptyLabel from "public/empty-state/empty_label.svg";
import emptyMembers from "public/empty-state/empty_members.svg";
// components
import { StateGroupIcon } from "@plane/ui";
import { SingleProgressStats } from "components/core";
// ui
import { Avatar, StateGroupIcon } from "@plane/ui";
import { Avatar } from "components/ui";
// types
import {
IModule,
@@ -137,7 +138,17 @@ export const SidebarProgressStats: React.FC<Props> = ({
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
height="18px"
width="18px"
/>
<span>{assignee.display_name}</span>
</div>
}
+12 -14
View File
@@ -1,14 +1,16 @@
import { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// services
import { CycleService } from "services/cycle.service";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { AssigneesList } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
import {
AvatarGroup,
Loader,
Tooltip,
LinearProgressIndicator,
@@ -17,7 +19,6 @@ import {
LayersIcon,
StateGroupIcon,
PriorityIcon,
Avatar,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
@@ -30,7 +31,9 @@ import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } fro
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
import { ICycle, IIssue } from "types";
// fetch-keys
import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
const stateGroups = [
{
@@ -66,6 +69,9 @@ interface IActiveCycleDetails {
}
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
// services
const cycleService = new CycleService();
const router = useRouter();
const { workspaceSlug, projectId } = props;
@@ -300,11 +306,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
{cycle.assignees.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={cycle.assignees} length={4} />
</div>
)}
</div>
@@ -404,11 +406,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
<div className={`flex items-center gap-2 text-custom-text-200`}>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AvatarGroup showTooltip={false}>
{issue.assignee_details.map((assignee: any) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={issue.assignee_details} length={3} showLength={false} />
</div>
) : (
""
+28 -7
View File
@@ -1,14 +1,16 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
// types
import { ICycle } from "types";
// types
type Props = {
cycle: ICycle;
};
@@ -69,7 +71,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List>
{cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
{cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
@@ -77,8 +82,15 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.display_name}</span>
</div>
}
@@ -93,7 +105,13 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
<img
src="/user.png"
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</div>
@@ -104,7 +122,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
);
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
{cycle.distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
+6 -6
View File
@@ -1,12 +1,16 @@
import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
// next imports
import Link from "next/link";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
// icons
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
@@ -193,11 +197,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
{cycle.assignees.length > 0 && (
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center gap-1 cursor-default">
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={cycle.assignees} length={3} />
</div>
</Tooltip>
)}
+3 -6
View File
@@ -8,8 +8,9 @@ import { useMobxStore } from "lib/mobx/store-provider";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { AssigneesList } from "components/ui";
// ui
import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui";
import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon } from "@plane/ui";
// icons
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// helpers
@@ -206,11 +207,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{cycle.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={cycle.assignees} length={2} />
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
+6 -6
View File
@@ -25,7 +25,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
const { cycle: cycleStore } = useMobxStore();
// api call to fetch cycles list
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null,
workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null
);
@@ -36,10 +36,10 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
<>
{layout === "list" && (
<>
{cyclesList ? (
{!isLoading ? (
<CyclesList cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4 p-8">
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
@@ -50,7 +50,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && (
<>
{cyclesList ? (
{!isLoading ? (
<CyclesBoard
cycles={cyclesList}
filter={filter}
@@ -59,7 +59,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
peekCycle={peekCycle}
/>
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3 p-8">
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
@@ -70,7 +70,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "gantt" && (
<>
{cyclesList ? (
{!isLoading ? (
<CyclesListGanttChartView cycles={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
+2
View File
@@ -7,6 +7,8 @@ export * from "./form";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-card";
export * from "./single-cycle-list";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./cycles-list";
+40 -36
View File
@@ -15,8 +15,8 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
import { Avatar, CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
// helpers
@@ -137,7 +137,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
await submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -211,7 +211,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
await submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -349,37 +349,41 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</Popover.Button>
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
@@ -437,7 +441,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<div className="flex items-center w-1/2 rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleDetails.owned_by.display_name} src={cycleDetails.owned_by.avatar} />
<Avatar user={cycleDetails.owned_by} />
<span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
</div>
@@ -493,7 +497,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{isStartValid && isEndValid ? (
<div className="h-full w-full pt-4">
<div className=" h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
+389
View File
@@ -0,0 +1,389 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { AssigneesList } from "components/ui/avatar";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const SingleCycleCard: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
return (
<div>
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)}
</div>
<div className="flex justify-between items-end">
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</div>
);
};
+369
View File
@@ -0,0 +1,369 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
type progress = {
progress: number;
};
function RadialProgressBar({ progress }: progress) {
const [circumference, setCircumference] = useState(0);
useEffect(() => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
setCircumference(circumference);
}, []);
const progressOffset = ((100 - progress) / 100) * circumference;
return (
<div className="relative h-4 w-4">
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
<circle
className={"stroke-current opacity-10"}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
/>
<circle
className={`stroke-current`}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={progressOffset}
transform="rotate(-90 50 50)"
/>
</svg>
</div>
);
}
export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const completedIssues = cycle.completed_issues + cycle.cancelled_issues;
const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0;
return (
<div>
<div className="flex flex-col text-xs hover:bg-custom-background-80">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<div className="flex items-start gap-2">
<ContrastIcon
className="mt-1 h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<div className="max-w-2xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words w-full text-base font-semibold">{truncateText(cycle.name, 60)}</h3>
</Tooltip>
<p className="mt-2 text-custom-text-200 break-words w-full">{cycle.description}</p>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-4">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{Math.round(percentage)} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
<div className="flex items-center">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</a>
</Link>
</div>
</div>
);
};
@@ -1,11 +1,15 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import { ProjectEstimateService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
// ui
@@ -13,15 +17,29 @@ import { Button, Input, TextArea } from "@plane/ui";
// helpers
import { checkDuplicates } from "helpers/array.helper";
// types
import { IEstimate, IEstimateFormData } from "types";
import { IUser, IEstimate, IEstimateFormData } from "types";
// fetch-keys
import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
user: IUser | undefined;
};
const defaultValues = {
type FormValues = {
name: string;
description: string;
value1: string;
value2: string;
value3: string;
value4: string;
value5: string;
value6: string;
};
const defaultValues: Partial<FormValues> = {
name: "",
description: "",
value1: "",
@@ -32,18 +50,10 @@ const defaultValues = {
value6: "",
};
type FormValues = typeof defaultValues;
export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
const { handleClose, data, isOpen } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
// services
const projectEstimateService = new ProjectEstimateService();
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen, user }) => {
const {
formState: { errors, isSubmitting },
handleSubmit,
@@ -58,47 +68,71 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
reset();
};
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const createEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId) return;
await projectEstimatesStore
.createEstimate(workspaceSlug.toString(), projectId.toString(), payload)
await projectEstimateService
.createEstimate(workspaceSlug as string, projectId as string, payload, user)
.then(() => {
mutate(ESTIMATES_LIST(projectId as string));
onClose();
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message:
errorString ?? err.status === 400
? "Estimate with that name already exists. Please try again with another name."
: "Estimate could not be created. Please try again.",
});
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate with that name already exists. Please try again with another name.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be created. Please try again.",
});
});
};
const updateEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId || !data) return;
await projectEstimatesStore
.updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload)
mutate<IEstimate[]>(
ESTIMATES_LIST(projectId.toString()),
(prevData) =>
prevData?.map((p) => {
if (p.id === data.id)
return {
...p,
name: payload.estimate.name,
description: payload.estimate.description,
points: p.points.map((point, index) => ({
...point,
value: payload.estimate_points[index].value,
})),
};
return p;
}),
false
);
await projectEstimateService
.patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload, user)
.then(() => {
mutate(ESTIMATES_LIST(projectId.toString()));
mutate(ESTIMATE_DETAILS(data.id));
handleClose();
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be updated. Please try again.",
message: "Estimate could not be updated. Please try again.",
});
});
@@ -257,38 +291,151 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
)}
/>
</div>
{/* list of all the points */}
{/* since they are all the same, we can use a loop to render them */}
<div className="grid grid-cols-3 gap-3">
{Array(6)
.fill(0)
.map((_, i) => (
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">{i + 1}</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name={`value${i + 1}` as keyof FormValues}
render={({ field: { value, onChange, ref } }) => (
<Input
ref={ref}
type="text"
value={value}
onChange={onChange}
id={`value${i + 1}`}
name={`value${i + 1}`}
placeholder={`Point ${i + 1}`}
className="rounded-l-none w-full"
hasError={Boolean(errors[`value${i + 1}` as keyof FormValues])}
/>
)}
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">1</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value1"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value1"
name="value1"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value1)}
placeholder="Point 1"
className="rounded-l-none w-full"
/>
</span>
</span>
</div>
))}
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">2</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value2"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value2"
name="value2"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value2)}
placeholder="Point 2"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">3</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value3"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value3"
name="value3"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value3)}
placeholder="Point 3"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">4</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value4"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value4"
name="value4"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value4)}
placeholder="Point 4"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">5</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value5"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value5"
name="value5"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value5)}
placeholder="Point 5"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">6</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value6"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value6"
name="value6"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value6)}
placeholder="Point 6"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -314,4 +461,4 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
</Transition.Root>
</>
);
});
};
@@ -1,13 +1,10 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// types
import { IEstimate } from "types";
// icons
import { AlertTriangle } from "lucide-react";
// ui
@@ -15,43 +12,14 @@ import { Button } from "@plane/ui";
type Props = {
isOpen: boolean;
data: IEstimate | null;
handleClose: () => void;
data: IEstimate;
handleDelete: () => void;
};
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
const { isOpen, handleClose, data } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
// states
export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// hooks
const { setToastAlert } = useToast();
const handleEstimateDelete = () => {
if (!workspaceSlug || !projectId) return;
const estimateId = data?.id!;
projectEstimatesStore.deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be deleted. Please try again",
});
});
};
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
@@ -100,7 +68,7 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
<span>
<p className="break-words text-sm leading-7 text-custom-text-200">
Are you sure you want to delete estimate-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>
<span className="break-words font-medium text-custom-text-100">{data.name}</span>
{""}? All of the data related to the estiamte will be permanently removed. This action cannot be
undone.
</p>
@@ -113,7 +81,7 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
variant="danger"
onClick={() => {
setIsDeleteLoading(true);
handleEstimateDelete();
handleDelete();
}}
loading={isDeleteLoading}
>
@@ -128,4 +96,4 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};
-139
View File
@@ -1,139 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
//hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// icons
import { Plus } from "lucide-react";
// images
import emptyEstimate from "public/empty-state/estimate.svg";
// types
import { IEstimate } from "types";
export const EstimatesList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
// states
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
// hooks
const { setToastAlert } = useToast();
// derived values
const estimatesList = projectStore.projectEstimates;
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const editEstimate = (estimate: IEstimate) => {
setEstimateFormOpen(true);
setEstimateToUpdate(estimate);
};
const disableEstimates = () => {
if (!workspaceSlug || !projectId) return;
projectStore.updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be disabled. Please try again",
});
});
};
return (
<>
<CreateUpdateEstimateModal
isOpen={estimateFormOpen}
data={estimateToUpdate}
handleClose={() => {
setEstimateFormOpen(false);
setEstimateToUpdate(undefined);
}}
/>
<DeleteEstimateModal
isOpen={!!estimateToDelete}
handleClose={() => setEstimateToDelete(null)}
data={projectStore.getProjectEstimateById(estimateToDelete!)}
/>
<section className="flex items-center justify-between py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<Button
variant="primary"
onClick={() => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
}}
>
Add Estimate
</Button>
{projectDetails?.estimate && (
<Button variant="neutral-primary" onClick={disableEstimates}>
Disable Estimates
</Button>
)}
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="h-full bg-custom-background-100 overflow-y-auto">
{estimatesList.map((estimate) => (
<EstimateListItem
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
},
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
);
});
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./create-update-estimate-modal";
export * from "./delete-estimate-modal";
export * from "./estimate-select";
export * from "./estimate-list-item";
export * from "./single-estimate";
@@ -1,12 +1,14 @@
import React from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { ProjectService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { DeleteEstimateModal } from "components/estimates";
// ui
import { Button, CustomMenu } from "@plane/ui";
//icons
@@ -14,46 +16,48 @@ import { Pencil, Trash2 } from "lucide-react";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IEstimate } from "types";
import { IUser, IEstimate } from "types";
type Props = {
user: IUser | undefined;
estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void;
deleteEstimate: (estimateId: string) => void;
handleEstimateDelete: (estimateId: string) => void;
};
export const EstimateListItem: React.FC<Props> = observer((props) => {
const { estimate, editEstimate, deleteEstimate } = props;
// services
const projectService = new ProjectService();
export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate, handleEstimateDelete }) => {
const [isDeleteEstimateModalOpen, setIsDeleteEstimateModalOpen] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
const { setToastAlert } = useToast();
// derived values
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const handleUseEstimate = async () => {
if (!workspaceSlug || !projectId) return;
await projectStore
.updateProject(workspaceSlug.toString(), projectId.toString(), {
estimate: estimate.id,
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
const payload = {
estimate: estimate.id,
};
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate points could not be used. Please try again.",
});
mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id };
}, false);
await projectService.updateProject(workspaceSlug as string, projectId as string, payload, user).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate points could not be used. Please try again.",
});
});
};
return (
@@ -72,7 +76,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</p>
</div>
<div className="flex items-center gap-2">
{projectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && (
<Button variant="neutral-primary" onClick={handleUseEstimate}>
Use
</Button>
@@ -91,7 +95,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
{projectDetails?.estimate !== estimate.id && (
<CustomMenu.MenuItem
onClick={() => {
deleteEstimate(estimate.id);
setIsDeleteEstimateModalOpen(true);
}}
>
<div className="flex items-center justify-start gap-2">
@@ -103,7 +107,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</CustomMenu>
</div>
</div>
{estimate?.points?.length > 0 ? (
{estimate.points.length > 0 ? (
<div className="flex text-xs text-custom-text-200">
Estimate points (
<span className="flex gap-1">
@@ -122,6 +126,16 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</div>
)}
</div>
<DeleteEstimateModal
isOpen={isDeleteEstimateModalOpen}
handleClose={() => setIsDeleteEstimateModalOpen(false)}
data={estimate}
handleDelete={() => {
handleEstimateDelete(estimate.id);
setIsDeleteEstimateModalOpen(false);
}}
/>
</>
);
});
};
+1 -1
View File
@@ -130,7 +130,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
{projectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${projectDetails?.id}`}
className="group bg-custom-primary-100/10 text-custom-primary-100 px-2.5 py-1 text-xs flex items-center gap-1.5 rounded font-medium"
className="group bg-custom-primary-100/20 text-custom-primary-100 px-2.5 py-1 text-xs flex items-center gap-1.5 rounded font-medium"
target="_blank"
rel="noopener noreferrer"
>
@@ -1,9 +1,12 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { WorkspaceService } from "services/workspace.service";
// ui
import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
import { Avatar } from "components/ui";
import { CustomSelect, CustomSearchSelect, Input } from "@plane/ui";
// types
import { IGithubRepoCollaborator } from "types";
import { IUserDetails } from "./root";
@@ -49,7 +52,7 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
@@ -2,14 +2,15 @@ import { FC } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { useFormContext, useFieldArray, Controller } from "react-hook-form";
// services
import { WorkspaceService } from "services/workspace.service";
// ui
import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
// types
import { IJiraImporterForm } from "types";
// fetch keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
// services
import { WorkspaceService } from "services/workspace.service";
// components
import { Avatar } from "components/ui";
import { CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui";
// types
import { IJiraImporterForm } from "types";
const workspaceService = new WorkspaceService();
@@ -38,7 +39,7 @@ export const JiraImportUsers: FC = () => {
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
+7 -1
View File
@@ -297,11 +297,17 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
<>
{projectId && (
<>
<CreateStateModal isOpen={stateModal} handleClose={() => setStateModal(false)} projectId={projectId} />
<CreateStateModal
isOpen={stateModal}
handleClose={() => setStateModal(false)}
projectId={projectId}
user={user}
/>
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
user={user}
onSuccess={(response) => setValue("labels", [...watch("labels"), response.id])}
/>
</>
+7 -1
View File
@@ -249,11 +249,17 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
<>
{projectId && (
<>
<CreateStateModal isOpen={stateModal} handleClose={() => setStateModal(false)} projectId={projectId} />
<CreateStateModal
isOpen={stateModal}
handleClose={() => setStateModal(false)}
projectId={projectId}
user={user ?? undefined}
/>
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
user={user ?? undefined}
onSuccess={(response) => setValue("labels", [...watch("labels"), response.id])}
/>
</>
@@ -1,7 +1,9 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
// icons
import { X } from "lucide-react";
// types
import { IUserLite } from "types";
@@ -23,7 +25,7 @@ export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
return (
<div key={memberId} className="text-xs flex items-center gap-1 bg-custom-background-80 p-1 rounded">
<Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} />
<Avatar user={memberDetails} height="16px" width="16px" />
<span className="normal-case">{memberDetails.display_name}</span>
<button
type="button"
@@ -1,8 +1,10 @@
import React, { useState } from "react";
// components
import { FilterHeader, FilterOption } from "components/issues";
// ui
import { Avatar, Loader } from "@plane/ui";
import { Avatar } from "components/ui";
import { Loader } from "@plane/ui";
// types
import { IUserLite } from "types";
@@ -43,7 +45,7 @@ export const FilterAssignees: React.FC<Props> = (props) => {
key={`assignees-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="sm" />}
icon={<Avatar user={member} height="18px" width="18px" />}
title={member.display_name}
/>
))}
@@ -1,8 +1,10 @@
import React, { useState } from "react";
// components
import { FilterHeader, FilterOption } from "components/issues";
// ui
import { Avatar, Loader } from "@plane/ui";
import { Avatar } from "components/ui";
import { Loader } from "@plane/ui";
// types
import { IUserLite } from "types";
@@ -43,7 +45,7 @@ export const FilterCreatedBy: React.FC<Props> = (props) => {
key={`created-by-${member.id}`}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => handleUpdate(member.id)}
icon={<Avatar name={member.display_name} src={member.avatar} size="sm" />}
icon={<Avatar user={member} height="18px" width="18px" />}
title={member.display_name}
/>
))}
@@ -10,6 +10,4 @@ export * from "./gantt";
export * from "./kanban";
export * from "./spreadsheet";
export * from "./properties";
export * from "./roots";
@@ -2,7 +2,7 @@ import { Draggable } from "@hello-pangea/dnd";
// components
import { KanBanProperties } from "./properties";
// types
import { IIssueDisplayProperties, IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueDisplayProperties, IIssueLabels, IState, IUserLite } from "types";
interface IssueBlockProps {
sub_group_id: string;
@@ -18,10 +18,27 @@ interface IssueBlockProps {
) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
const { sub_group_id, columnId, index, issue, isDragDisabled, handleIssues, quickActions, displayProperties } = props;
const {
sub_group_id,
columnId,
index,
issue,
isDragDisabled,
handleIssues,
quickActions,
displayProperties,
states,
labels,
members,
estimates,
} = props;
const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => {
if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, "update");
@@ -65,6 +82,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
issue={issue}
handleIssues={updateIssue}
displayProperties={displayProperties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
</div>
</div>
@@ -1,6 +1,6 @@
// components
import { KanbanIssueBlock } from "components/issues";
import { IIssueDisplayProperties, IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueDisplayProperties, IIssueLabels, IState, IUserLite } from "types";
interface IssueBlocksListProps {
sub_group_id: string;
@@ -15,10 +15,26 @@ interface IssueBlocksListProps {
) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) => {
const { sub_group_id, columnId, issues, isDragDisabled, handleIssues, quickActions, displayProperties } = props;
const {
sub_group_id,
columnId,
issues,
isDragDisabled,
handleIssues,
quickActions,
displayProperties,
states,
labels,
members,
estimates,
} = props;
return (
<>
@@ -35,6 +51,10 @@ export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) =>
columnId={columnId}
sub_group_id={sub_group_id}
isDragDisabled={isDragDisabled}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
))}
</>
@@ -7,7 +7,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { KanBanGroupByHeaderRoot } from "./headers/group-by-root";
import { KanbanIssueBlocksList, BoardInlineCreateIssueForm } from "components/issues";
// types
import { IIssueDisplayProperties, IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueDisplayProperties, IIssueLabels, IProject, IState, IUserLite } from "types";
// constants
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES, getValueFromObject } from "constants/issue";
@@ -30,6 +30,11 @@ export interface IGroupByKanBan {
kanBanToggle: any;
handleKanBanToggle: any;
enableQuickIssueCreate?: boolean;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
priorities: any;
estimates: IEstimatePoint[] | null;
}
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
@@ -46,6 +51,11 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
displayProperties,
kanBanToggle,
handleKanBanToggle,
states,
labels,
members,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
@@ -95,6 +105,10 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
displayProperties={displayProperties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
) : (
isDragDisabled && (
@@ -140,6 +154,13 @@ export interface IKanBan {
displayProperties: IIssueDisplayProperties;
kanBanToggle: any;
handleKanBanToggle: any;
states: IState[] | null;
stateGroups: any;
priorities: any;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
enableQuickIssueCreate?: boolean;
}
@@ -154,6 +175,13 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
displayProperties,
kanBanToggle,
handleKanBanToggle,
states,
stateGroups,
priorities,
labels,
members,
projects,
estimates,
enableQuickIssueCreate,
} = props;
@@ -176,6 +204,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -194,6 +227,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -212,6 +250,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -230,6 +273,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -248,6 +296,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -266,6 +319,11 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
</div>
@@ -3,8 +3,7 @@ import { observer } from "mobx-react-lite";
// components
import { HeaderGroupByCard } from "./group-by-card";
import { HeaderSubGroupByCard } from "./sub-group-by-card";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
export interface IAssigneesHeader {
column_id: string;
@@ -17,7 +16,7 @@ export interface IAssigneesHeader {
handleKanBanToggle: any;
}
export const Icon = ({ user }: any) => <Avatar name={user.display_name} src={user.avatar} size="base" />;
export const Icon = ({ user }: any) => <Avatar user={user} height="22px" width="22px" fontSize="12px" />;
export const AssigneesHeader: FC<IAssigneesHeader> = observer((props) => {
const {
@@ -10,7 +10,15 @@ import { IssuePropertyAssignee } from "../properties/assignee";
import { IssuePropertyEstimates } from "../properties/estimates";
import { IssuePropertyDate } from "../properties/date";
import { Tooltip } from "@plane/ui";
import { IIssue, IIssueDisplayProperties, IState, TIssuePriorities } from "types";
import {
IEstimatePoint,
IIssue,
IIssueDisplayProperties,
IIssueLabels,
IState,
IUserLite,
TIssuePriorities,
} from "types";
export interface IKanBanProperties {
sub_group_id: string;
@@ -18,10 +26,24 @@ export interface IKanBanProperties {
issue: IIssue;
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => void;
displayProperties: IIssueDisplayProperties;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) => {
const { sub_group_id, columnId: group_id, issue, handleIssues, displayProperties } = props;
const {
sub_group_id,
columnId: group_id,
issue,
handleIssues,
displayProperties,
states,
labels,
members,
estimates,
} = props;
const handleState = (state: IState) => {
handleIssues(
@@ -85,9 +107,9 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* state */}
{displayProperties && displayProperties?.state && (
<IssuePropertyState
projectId={issue?.project_detail?.id || null}
value={issue?.state_detail || null}
onChange={handleState}
states={states}
disabled={false}
hideDropdownArrow
/>
@@ -106,14 +128,25 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* label */}
{displayProperties && displayProperties?.labels && (
<IssuePropertyLabels
projectId={issue?.project_detail?.id || null}
value={issue?.labels || null}
onChange={handleLabel}
labels={labels}
disabled={false}
hideDropdownArrow
/>
)}
{/* assignee */}
{displayProperties && displayProperties?.assignee && (
<IssuePropertyAssignee
value={issue?.assignees || null}
hideDropdownArrow
onChange={handleAssignee}
members={members}
disabled={false}
/>
)}
{/* start date */}
{displayProperties && displayProperties?.start_date && (
<IssuePropertyDate
@@ -137,9 +170,9 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* estimates */}
{displayProperties && displayProperties?.estimate && (
<IssuePropertyEstimates
projectId={issue?.project_detail?.id || null}
value={issue?.estimate_point || null}
onChange={handleEstimate}
estimatePoints={estimates}
disabled={false}
hideDropdownArrow
/>
@@ -175,18 +208,6 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
</div>
</Tooltip>
)}
{/* assignee */}
{displayProperties && displayProperties?.assignee && (
<IssuePropertyAssignee
projectId={issue?.project_detail?.id || null}
value={issue?.assignees || null}
hideDropdownArrow
onChange={handleAssignee}
disabled={false}
multiple
/>
)}
</div>
);
});
@@ -116,6 +116,13 @@ export const CycleKanBanLayout: React.FC = observer(() => {
displayProperties={displayProperties}
kanBanToggle={cycleIssueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -116,6 +116,13 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
displayProperties={displayProperties}
kanBanToggle={moduleIssueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -99,6 +99,13 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => {
displayProperties={displayProperties}
kanBanToggle={issueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={null}
/>
) : (
<KanBanSwimLanes
@@ -106,6 +106,14 @@ export const KanBanLayout: React.FC = observer(() => {
displayProperties={displayProperties}
kanBanToggle={issueKanBanViewStore?.kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members?.map((m) => m.member) ?? null}
projects={projects}
enableQuickIssueCreate
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -146,6 +146,13 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
displayProperties={displayProperties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
enableQuickIssueCreate
/>
</div>
@@ -4,7 +4,7 @@ import { IssuePeekOverview } from "components/issues/issue-peek-overview";
// ui
import { Tooltip } from "@plane/ui";
// types
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types";
interface IssueBlockProps {
columnId: string;
@@ -12,10 +12,14 @@ interface IssueBlockProps {
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
display_properties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
const { columnId, issue, handleIssues, quickActions, display_properties } = props;
const { columnId, issue, handleIssues, quickActions, display_properties, states, labels, members, estimates } = props;
const updateIssue = (group_by: string | null, issueToUpdate: IIssue) => {
handleIssues(group_by, issueToUpdate, "update");
@@ -50,6 +54,10 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
issue={issue}
handleIssues={updateIssue}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
{quickActions(!columnId && columnId === "null" ? null : columnId, issue)}
</div>
@@ -2,7 +2,7 @@ import { FC } from "react";
// components
import { IssueBlock } from "components/issues";
// types
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types";
interface Props {
columnId: string;
@@ -10,10 +10,15 @@ interface Props {
handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void;
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
display_properties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const IssueBlocksList: FC<Props> = (props) => {
const { columnId, issues, handleIssues, quickActions, display_properties } = props;
const { columnId, issues, handleIssues, quickActions, display_properties, states, labels, members, estimates } =
props;
return (
<div className="w-full h-full relative divide-y-[0.5px] divide-custom-border-200">
@@ -26,6 +31,10 @@ export const IssueBlocksList: FC<Props> = (props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
))
) : (
@@ -17,7 +17,14 @@ export interface IGroupByList {
quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode;
display_properties: any;
is_list?: boolean;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
stateGroups: any;
priorities: any;
enableQuickIssueCreate?: boolean;
estimates: IEstimatePoint[] | null;
}
const GroupByList: React.FC<IGroupByList> = observer((props) => {
@@ -30,6 +37,13 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
quickActions,
display_properties,
is_list = false,
states,
labels,
members,
projects,
stateGroups,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
@@ -39,7 +53,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
list.length > 0 &&
list.map((_list: any) => (
<div key={getValueFromObject(_list, listKey) as string} className={`flex-shrink-0 flex flex-col`}>
<div className="flex-shrink-0 w-full py-1 sticky top-0 z-[2] px-3 bg-custom-background-90">
<div className="flex-shrink-0 w-full py-1 sticky top-0 z-[2] px-3">
<ListGroupByHeaderRoot
column_id={getValueFromObject(_list, listKey) as string}
column_value={_list}
@@ -56,6 +70,10 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
)}
{enableQuickIssueCreate && (
@@ -103,7 +121,7 @@ export const List: React.FC<IList> = observer((props) => {
projects,
stateGroups,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
@@ -119,6 +137,13 @@ export const List: React.FC<IList> = observer((props) => {
quickActions={quickActions}
display_properties={display_properties}
is_list
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -132,6 +157,13 @@ export const List: React.FC<IList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -145,6 +177,13 @@ export const List: React.FC<IList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -158,6 +197,13 @@ export const List: React.FC<IList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -171,6 +217,13 @@ export const List: React.FC<IList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -184,6 +237,13 @@ export const List: React.FC<IList> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -193,10 +253,17 @@ export const List: React.FC<IList> = observer((props) => {
issues={issues}
group_by={group_by}
list={members}
listKey={`id`}
listKey={`member.id`}
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -206,10 +273,17 @@ export const List: React.FC<IList> = observer((props) => {
issues={issues}
group_by={group_by}
list={members}
listKey={`id`}
listKey={`member.id`}
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
projects={projects}
stateGroups={stateGroups}
priorities={priorities}
estimates={estimates}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
)}
@@ -2,8 +2,7 @@ import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { HeaderGroupByCard } from "./group-by-card";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
export interface IAssigneesHeader {
column_id: string;
@@ -11,7 +10,7 @@ export interface IAssigneesHeader {
issues_count: number;
}
export const Icon = ({ user }: any) => <Avatar name={user.display_name} src={user.avatar} size="md" />;
export const Icon = ({ user }: any) => <Avatar user={user} height="22px" width="22px" fontSize="12px" />;
export const AssigneesHeader: FC<IAssigneesHeader> = observer((props) => {
const { column_id, column_value, issues_count } = props;
@@ -21,7 +20,11 @@ export const AssigneesHeader: FC<IAssigneesHeader> = observer((props) => {
return (
<>
{assignee && (
<HeaderGroupByCard icon={<Icon user={assignee} />} title={assignee?.display_name || ""} count={issues_count} />
<HeaderGroupByCard
icon={<Icon user={assignee?.member} />}
title={assignee?.member?.display_name || ""}
count={issues_count}
/>
)}
</>
);
@@ -19,8 +19,8 @@ export const CreatedByHeader: FC<ICreatedByHeader> = observer((props) => {
<>
{createdBy && (
<HeaderGroupByCard
icon={<Icon user={createdBy} />}
title={createdBy?.display_name || ""}
icon={<Icon user={createdBy?.member} />}
title={createdBy?.member?.display_name || ""}
count={issues_count}
/>
)}
@@ -11,17 +11,21 @@ import { IssuePropertyDate } from "../properties/date";
// ui
import { Tooltip } from "@plane/ui";
// types
import { IIssue, IState, TIssuePriorities } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite, TIssuePriorities } from "types";
export interface IKanBanProperties {
columnId: string;
issue: IIssue;
handleIssues: (group_by: string | null, issue: IIssue) => void;
display_properties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
const { columnId: group_id, issue, handleIssues, display_properties } = props;
const { columnId: group_id, issue, handleIssues, display_properties, states, labels, members, estimates } = props;
const handleState = (state: IState) => {
handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, state: state.id });
@@ -55,13 +59,13 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
<div className="relative flex gap-2 overflow-x-auto whitespace-nowrap">
{/* basic properties */}
{/* state */}
{display_properties && display_properties?.state && (
{display_properties && display_properties?.state && states && (
<IssuePropertyState
projectId={issue?.project_detail?.id || null}
value={issue?.state_detail || null}
hideDropdownArrow
onChange={handleState}
disabled={false}
states={states}
/>
)}
@@ -76,25 +80,24 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
)}
{/* label */}
{display_properties && display_properties?.labels && (
{display_properties && display_properties?.labels && labels && (
<IssuePropertyLabels
projectId={issue?.project_detail?.id || null}
value={issue?.labels || null}
onChange={handleLabel}
labels={labels}
disabled={false}
hideDropdownArrow
/>
)}
{/* assignee */}
{display_properties && display_properties?.assignee && (
{display_properties && display_properties?.assignee && members && (
<IssuePropertyAssignee
projectId={issue?.project_detail?.id || null}
value={issue?.assignees || null}
hideDropdownArrow
onChange={handleAssignee}
disabled={false}
multiple
members={members}
/>
)}
@@ -121,8 +124,8 @@ export const KanBanProperties: FC<IKanBanProperties> = observer((props) => {
{/* estimates */}
{display_properties && display_properties?.estimate && (
<IssuePropertyEstimates
projectId={issue?.project_detail?.id || null}
value={issue?.estimate_point || null}
estimatePoints={estimates}
hideDropdownArrow
onChange={handleEstimate}
disabled={false}
@@ -1,185 +1,28 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search, User2 } from "lucide-react";
// ui
import { Avatar, AvatarGroup, Tooltip } from "@plane/ui";
// components
import { MembersSelect } from "components/project";
// types
import { Placement } from "@popperjs/core";
import { IUserLite } from "types";
export interface IIssuePropertyAssignee {
view?: "profile" | "workspace" | "project";
projectId: string | null;
value: string[] | string;
value: string[];
onChange: (data: string[]) => void;
members: IUserLite[] | null;
disabled?: boolean;
hideDropdownArrow?: boolean;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
multiple?: true;
}
export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer((props) => {
const {
view,
projectId,
value,
onChange,
disabled = false,
hideDropdownArrow = false,
className,
buttonClassName,
optionsClassName,
placement,
multiple = false,
} = props;
const { workspace: workspaceStore, project: projectStore } = useMobxStore();
const workspaceSlug = workspaceStore?.workspaceSlug;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const projectMembers = projectId ? projectStore?.members?.[projectId] : undefined;
const fetchProjectMembers = () =>
workspaceSlug && projectId && projectStore.fetchProjectMembers(workspaceSlug, projectId);
const options = (projectMembers ?? [])?.map((member) => ({
value: member.member.id,
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member.member.display_name} src={member.member.avatar} showTooltip={false} />
{member.member.display_name}
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const label = (
<Tooltip
tooltipHeading="Assignee"
tooltipContent={
value && value.length > 0
? (projectMembers ? projectMembers : [])
?.filter((m) => value.includes(m.member.display_name))
.map((m) => m.member.display_name)
.join(", ")
: "No Assignee"
}
position="top"
>
<div className="flex items-center cursor-pointer h-full w-full gap-2 text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? (
<AvatarGroup showTooltip={false}>
{value.map((assigneeId) => {
const member = projectMembers?.find((m) => m.member.id === assigneeId)?.member;
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
) : (
<span
className="flex items-center justify-between gap-1 h-full w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none
"
>
<User2 className="h-3 w-3" />
</span>
)}
</div>
</Tooltip>
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const comboboxProps: any = { value, onChange, disabled };
if (multiple) comboboxProps.multiple = true;
const { value, onChange, members, disabled = false, hideDropdownArrow = false } = props;
return (
<Combobox as="div" className={`flex-shrink-0 text-left ${className}`} {...comboboxProps}>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={() => fetchProjectMembers()}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active && !selected ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
<MembersSelect
value={value}
onChange={onChange}
members={members ?? undefined}
disabled={disabled}
hideDropdownArrow={hideDropdownArrow}
multiple
/>
);
});
@@ -42,7 +42,7 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
<>
<Popover.Button
ref={dropdownBtn}
className={`px-2.5 py-1 h-5 flex items-center rounded border-[0.5px] border-custom-border-300 duration-300 outline-none w-full ${
className={`px-2.5 py-1 h-5 flex items-center rounded border-[0.5px] border-custom-border-300 duration-300 outline-none ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
}`}
>
@@ -1,168 +1,28 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { usePopper } from "react-popper";
import useEstimateOption from "hooks/use-estimate-option";
// ui
import { Check, ChevronDown, Search, Triangle } from "lucide-react";
import { Combobox } from "@headlessui/react";
import { Tooltip } from "@plane/ui";
// components
import { EstimateSelect } from "components/estimates";
// types
import { Placement } from "@popperjs/core";
import { IEstimatePoint } from "types";
export interface IIssuePropertyEstimates {
view?: "profile" | "workspace" | "project";
projectId: string | null;
value: number | null;
onChange: (value: number | null) => void;
estimatePoints: IEstimatePoint[] | null;
disabled?: boolean;
hideDropdownArrow?: boolean;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
}
export const IssuePropertyEstimates: React.FC<IIssuePropertyEstimates> = observer((props) => {
const {
view,
projectId,
value,
onChange,
disabled,
hideDropdownArrow = false,
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
} = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { isEstimateActive, estimatePoints } = useEstimateOption();
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const options: { value: number | null; query: string; content: any }[] | undefined = (estimatePoints ?? []).map(
(estimate) => ({
value: estimate.key,
query: estimate.value,
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
{estimate.value}
</div>
),
})
);
options?.unshift({
value: null,
query: "none",
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
None
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const selectedEstimate = estimatePoints?.find((e) => e.key === value);
const label = (
<Tooltip tooltipHeading="Estimate" tooltipContent={selectedEstimate?.value ?? "None"} position="top">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<Triangle className="h-3 w-3" strokeWidth={2} />
<span className="truncate">{selectedEstimate?.value ?? "None"}</span>
</div>
</Tooltip>
);
const { value, onChange, estimatePoints, disabled, hideDropdownArrow = false } = props;
return (
<Combobox
as="div"
className={`flex-shrink-0 text-left ${className}`}
<EstimateSelect
value={value}
onChange={(val) => onChange(val as number | null)}
onChange={onChange}
estimatePoints={estimatePoints ?? undefined}
buttonClassName="h-5"
disabled={disabled}
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className="h-3.5 w-3.5" />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
hideDropdownArrow={hideDropdownArrow}
/>
);
});
@@ -1,6 +0,0 @@
export * from "./assignee";
export * from "./date";
export * from "./estimates";
export * from "./labels";
export * from "./priority";
export * from "./state";
@@ -1,212 +1,28 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import { usePopper } from "react-popper";
// components
import { Combobox } from "@headlessui/react";
import { Tooltip } from "@plane/ui";
import { Check, ChevronDown, Search } from "lucide-react";
import { LabelSelect } from "components/labels";
// types
import { Placement } from "@popperjs/core";
import { RootStore } from "store/root";
import { IIssueLabels } from "types";
export interface IIssuePropertyLabels {
view?: "profile" | "workspace" | "project";
projectId: string | null;
value: string[];
onChange: (data: string[]) => void;
labels: IIssueLabels[] | null;
disabled?: boolean;
hideDropdownArrow?: boolean;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
maxRender?: number;
}
export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((props) => {
const {
view,
projectId,
value,
onChange,
disabled,
hideDropdownArrow = false,
className,
buttonClassName,
optionsClassName,
placement,
maxRender = 2,
} = props;
const { workspace: workspaceStore, project: projectStore }: RootStore = useMobxStore();
const workspaceSlug = workspaceStore?.workspaceSlug;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const projectLabels = projectId && projectStore?.labels?.[projectId];
const fetchProjectLabels = () =>
workspaceSlug && projectId && projectStore.fetchProjectLabels(workspaceSlug, projectId);
const options = (projectLabels ? projectLabels : []).map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center justify-start gap-2">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<span>{label.name}</span>
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const { value, onChange, labels, disabled, hideDropdownArrow = false } = props;
return (
<Combobox
as="div"
className={`flex-shrink-0 text-left ${className}`}
<LabelSelect
value={value}
onChange={onChange}
labels={labels ?? undefined}
buttonClassName="h-5"
disabled={disabled}
multiple
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: value.length <= maxRender
? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={() => fetchProjectLabels()}
>
<div className="flex items-center gap-2 text-custom-text-200 h-full">
{value.length > 0 ? (
value.length <= maxRender ? (
<>
{(projectLabels ? projectLabels : [])
?.filter((l) => value.includes(l.id))
.map((label) => (
<div
key={label.id}
className="flex cursor-default items-center flex-shrink-0 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs h-full"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color ?? "#000000",
}}
/>
{label.name}
</div>
</div>
))}
</>
) : (
<div className="h-full flex cursor-default items-center flex-shrink-0 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs">
<Tooltip
position="top"
tooltipHeading="Labels"
tooltipContent={(projectLabels ? projectLabels : [])
?.filter((l) => value.includes(l.id))
.map((l) => l.name)
.join(", ")}
>
<div className="h-full flex items-center gap-1.5 text-custom-text-200">
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
{`${value.length} Labels`}
</div>
</Tooltip>
</div>
)
) : (
<div className="h-full flex items-center justify-center text-xs rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 hover:bg-custom-background-80">
Select labels
</div>
)}
</div>
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options>
<div
className={`z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className={`h-3.5 w-3.5`} />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
hideDropdownArrow={hideDropdownArrow}
/>
);
});
@@ -1,175 +1,28 @@
import { Fragment, useState } from "react";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import { usePopper } from "react-popper";
// ui
import { Combobox } from "@headlessui/react";
import { StateGroupIcon, Tooltip } from "@plane/ui";
import { Check, ChevronDown, Search } from "lucide-react";
// components
import { StateSelect } from "components/states";
// types
import { IState } from "types";
import { Placement } from "@popperjs/core";
import { RootStore } from "store/root";
export interface IIssuePropertyState {
view?: "profile" | "workspace" | "project";
projectId: string | null;
value: IState;
onChange: (state: IState) => void;
states: IState[] | null;
disabled?: boolean;
hideDropdownArrow?: boolean;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
}
export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props) => {
const {
view,
projectId,
value,
onChange,
disabled,
hideDropdownArrow = false,
className,
buttonClassName,
optionsClassName,
placement,
} = props;
const { workspace: workspaceStore, project: projectStore }: RootStore = useMobxStore();
const workspaceSlug = workspaceStore?.workspaceSlug;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const projectStates: IState[] = [];
const projectStatesByGroup = projectId && projectStore?.states?.[projectId];
if (projectStatesByGroup)
for (const group in projectStatesByGroup) projectStates.push(...projectStatesByGroup[group]);
const fetchProjectStates = () =>
workspaceSlug && projectId && projectStore.fetchProjectStates(workspaceSlug, projectId);
const dropdownOptions = projectStates?.map((state) => ({
value: state.id,
query: state.name,
content: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state.group} color={state.color} />
{state.name}
</div>
),
}));
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const filteredOptions =
query === ""
? dropdownOptions
: dropdownOptions?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const label = (
<Tooltip tooltipHeading="State" tooltipContent={value?.name ?? ""} position="top">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
{value && <StateGroupIcon stateGroup={value.group} color={value.color} />}
<span className="truncate">{value?.name ?? "State"}</span>
</div>
</Tooltip>
);
const { value, onChange, states, disabled, hideDropdownArrow = false } = props;
return (
<>
{workspaceSlug && projectId && (
<Combobox
as="div"
className={`flex-shrink-0 text-left ${className}`}
value={value.id}
onChange={(data: string) => {
const selectedState = projectStates?.find((state) => state.id === data);
if (selectedState) onChange(selectedState);
}}
disabled={disabled}
>
<Combobox.Button as={Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
onClick={() => fetchProjectStates()}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className="h-3.5 w-3.5" />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
)}
</>
<StateSelect
value={value}
onChange={onChange}
states={states ?? undefined}
buttonClassName="h-5"
disabled={disabled}
hideDropdownArrow={hideDropdownArrow}
/>
);
});
@@ -33,6 +33,8 @@ export const ProjectLayoutRoot: React.FC = observer(() => {
const issueCount = issueStore.getIssuesCount;
console.log("issueCount", issueCount);
return (
<div className="relative w-full h-full flex flex-col overflow-hidden">
<ProjectAppliedFiltersRoot />
@@ -1,7 +1,7 @@
import React from "react";
// components
import { IssuePropertyAssignee } from "../../properties";
import { MembersSelect } from "components/project";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
@@ -21,11 +21,11 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = ({ issue, members, onC
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
return (
<>
<IssuePropertyAssignee
projectId={issue.project_detail.id ?? null}
<div className="flex items-center h-full px-4">
<MembersSelect
value={issue.assignees}
onChange={(data) => onChange({ assignees: data })}
members={members ?? []}
buttonClassName="!p-0 !rounded-none !border-0"
hideDropdownArrow
disabled={disabled}
@@ -46,6 +46,6 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = ({ issue, members, onC
disabled={disabled}
/>
))}
</>
</div>
);
};
@@ -1,5 +1,5 @@
// components
import { IssuePropertyEstimates } from "../../properties";
import { EstimateSelect } from "components/estimates";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
@@ -21,12 +21,14 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = (props) => {
return (
<>
<IssuePropertyEstimates
projectId={issue.project_detail.id ?? null}
<EstimateSelect
value={issue.estimate_point}
onChange={(data) => onChange({ estimate_point: data })}
hideDropdownArrow
className="h-full"
buttonClassName="!border-0 !h-full !w-full !rounded-none px-4"
estimatePoints={undefined}
disabled={disabled}
hideDropdownArrow
/>
{isExpanded &&
@@ -1,7 +1,7 @@
import React from "react";
// components
import { IssuePropertyLabels } from "../../properties";
import { LabelSelect } from "components/labels";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
@@ -24,10 +24,10 @@ export const SpreadsheetLabelColumn: React.FC<Props> = (props) => {
return (
<>
<IssuePropertyLabels
projectId={issue.project_detail.id ?? null}
<LabelSelect
value={issue.labels}
onChange={(data) => onChange({ labels: data })}
labels={labels ?? []}
className="h-full"
buttonClassName="!border-0 !h-full !w-full !rounded-none"
hideDropdownArrow
@@ -1,9 +1,11 @@
import React from "react";
// components
import { IssuePropertyState } from "../../properties";
import { StateSelect } from "components/states";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IIssue, IStateResponse } from "types";
@@ -22,13 +24,16 @@ export const SpreadsheetStateColumn: React.FC<Props> = (props) => {
const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded);
const statesList = getStatesList(states);
return (
<>
<IssuePropertyState
projectId={issue.project_detail.id ?? null}
<StateSelect
value={issue.state_detail}
onChange={(data) => onChange({ state: data.id, state_detail: data })}
buttonClassName="!shadow-none !border-0"
states={statesList}
className="h-full"
buttonClassName="!border-0 !h-full !w-full !rounded-none"
hideDropdownArrow
disabled={disabled}
/>
@@ -83,7 +83,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
ref={containerRef}
className="flex max-h-full h-full overflow-y-auto divide-x-[0.5px] divide-custom-border-200"
>
{issues && issues.length > 0 ? (
{issues ? (
<>
<div className="sticky left-0 w-[28rem] z-[2]">
<div
@@ -34,6 +34,8 @@ export const IssueActivityCard: FC<IssueActivityCard> = (props) => {
issueCommentReactionRemove,
} = props;
console.log("issueComments", issueComments);
return (
<div className="flow-root">
<ul role="list" className="-mb-4">
@@ -36,8 +36,8 @@ export const IssueComment: FC<IIssueComment> = (props) => {
};
return (
<div className="flex flex-col gap-3 border-t py-6 border-custom-border-200">
<div className="font-medium text-lg">Activity</div>
<div className="space-y-4">
<div className="font-medium text-xl">Activity</div>
<div className="space-y-2">
<IssueCommentEditor
@@ -1,17 +1,16 @@
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FC, useCallback, useEffect, useState } from "react";
// packages
import { RichTextEditor } from "@plane/rich-text-editor";
// components
import { TextArea } from "@plane/ui";
import { IssueReaction } from "./reactions";
// hooks
import { useDebouncedCallback } from "use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
// types
import { IIssue } from "types";
// services
import { FileService } from "services/file.service";
import { useForm, Controller } from "react-hook-form";
import useReloadConfirmations from "hooks/use-reload-confirmation";
const fileService = new FileService();
@@ -27,45 +26,24 @@ interface IPeekOverviewIssueDetails {
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => {
const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props;
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations();
const {
handleSubmit,
watch,
reset,
control,
formState: { errors },
} = useForm<IIssue>({
const { handleSubmit, watch, reset, control } = useForm<IIssue>({
defaultValues: {
name: "",
description_html: "",
},
});
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => {
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
const { setShowAlert } = useReloadConfirmations();
await issueUpdate({
...issue,
name: formData.name ?? "",
description_html: formData.description_html ?? "<p></p>",
});
},
[issue, issueUpdate]
);
useEffect(() => {
if (!issue) return;
const debouncedIssueDescription = useDebouncedCallback(async (_data: any) => {
issueUpdate({ ...issue, description_html: _data });
}, 1500);
const debouncedTitleSave = useDebouncedCallback(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 1500);
reset({
...issue,
});
}, [issue, reset]);
useEffect(() => {
if (isSubmitting === "submitted") {
@@ -78,80 +56,62 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
}
}, [isSubmitting, setShowAlert]);
// reset form values
useEffect(() => {
if (!issue) return;
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => {
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
reset({
...issue,
});
}, [issue, reset]);
issueUpdate({ name: formData.name ?? "", description_html: formData.description_html });
},
[issueUpdate]
);
const debouncedIssueFormSave = useDebouncedCallback(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 1500);
return (
<>
<span className="font-medium text-base text-custom-text-400">
<div className="space-y-4">
<div className="font-medium text-sm text-custom-text-200">
{issue?.project_detail?.identifier}-{issue?.sequence_id}
</span>
</div>
<div className="relative">
{true ? (
<div className="font-medium text-xl">{watch("name")}</div>
<div className="space-y-2">
<div className="relative">
<Controller
name="name"
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="name"
name="name"
<RichTextEditor
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
placeholder="Enter issue name"
onFocus={() => setCharacterLimit(true)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setCharacterLimit(false);
onChange={(description: Object, description_html: string) => {
setIsSubmitting("submitting");
debouncedTitleSave();
onChange(e.target.value);
onChange(description_html);
debouncedIssueFormSave();
}}
required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary !p-0 focus:!px-3 focus:!py-2"
hasError={Boolean(errors?.description)}
role="textbox"
disabled={!true}
customClassName="p-3 min-h-[80px] shadow-sm"
/>
)}
/>
) : (
<h4 className="break-words text-2xl font-semibold">{issue.name}</h4>
)}
{characterLimit && true && (
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
<span className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""}`}>
{watch("name").length}
</span>
/255
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
)}
</div>
<span>{errors.name ? errors.name.message : null}</span>
</div>
<span className="text-black">
<RichTextEditor
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={issue?.description_html}
debouncedUpdatesEnabled={false}
onChange={(description: Object, description_html: string) => {
debouncedIssueDescription(description_html);
}}
customClassName="mt-0"
<IssueReaction
issueReactions={issueReactions}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
</span>
<IssueReaction
issueReactions={issueReactions}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
</>
</div>
</div>
);
};
@@ -1,358 +1,139 @@
import { FC, useState } from "react";
import { mutate } from "swr";
import { useRouter } from "next/router";
import { FC } from "react";
// ui icons
import { DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
import { CalendarDays, ContrastIcon, Link2, Plus, Signal, Tag, Triangle, User2 } from "lucide-react";
import {
SidebarAssigneeSelect,
SidebarCycleSelect,
SidebarEstimateSelect,
SidebarLabelSelect,
SidebarModuleSelect,
SidebarParentSelect,
SidebarPrioritySelect,
SidebarStateSelect,
} from "../sidebar-select";
// hooks
import useToast from "hooks/use-toast";
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
import { CalendarDays, Signal } from "lucide-react";
// components
import { CustomDatePicker } from "components/ui";
import { LinkModal, LinksList } from "components/core";
import { IssuePropertyState } from "components/issues/issue-layouts/properties/state";
import { IssuePropertyPriority } from "components/issues/issue-layouts/properties/priority";
import { IssuePropertyAssignee } from "components/issues/issue-layouts/properties/assignee";
import { IssuePropertyDate } from "components/issues/issue-layouts/properties/date";
// types
import { ICycle, IIssue, IIssueLink, IModule, TIssuePriorities, linkDetails } from "types";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
import { IssueService } from "services/issue";
import { IIssue, IState, IUserLite, TIssuePriorities } from "types";
interface IPeekOverviewProperties {
issue: IIssue;
issueUpdate: (issue: Partial<IIssue>) => void;
user: any;
states: IState[] | null;
members: IUserLite[] | null;
priorities: any;
}
const issueService = new IssueService();
export const PeekOverviewProperties: FC<IPeekOverviewProperties> = (props) => {
const { issue, issueUpdate, user } = props;
const [linkModal, setLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
const { issue, issueUpdate, states, members, priorities } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { memberRole } = useProjectMyMembership();
const handleState = (_state: string) => {
issueUpdate({ ...issue, state: _state });
const handleState = (_state: IState) => {
issueUpdate({ ...issue, state: _state.id });
};
const handlePriority = (_priority: TIssuePriorities) => {
issueUpdate({ ...issue, priority: _priority });
};
const handleAssignee = (_assignees: string[]) => {
issueUpdate({ ...issue, assignees: _assignees });
};
const handleEstimate = (_estimate: number | null) => {
issueUpdate({ ...issue, estimate_point: _estimate });
};
const handleStartDate = (_startDate: string | null) => {
const handleStartDate = (_startDate: string) => {
issueUpdate({ ...issue, start_date: _startDate });
};
const handleTargetDate = (_targetDate: string | null) => {
const handleTargetDate = (_targetDate: string) => {
issueUpdate({ ...issue, target_date: _targetDate });
};
const handleParent = (_parent: string) => {
issueUpdate({ ...issue, parent: _parent });
};
const handleCycle = (_cycle: ICycle) => {
issueUpdate({ ...issue, cycle: _cycle.id });
};
const handleModule = (_module: IModule) => {
issueUpdate({ ...issue, module: _module.id });
};
const handleLabels = (formData: Partial<IIssue>) => {
issueUpdate({ ...issue, ...formData });
};
const handleCreateLink = async (formData: IIssueLink) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { metadata: {}, ...formData };
await issueService
.createIssueLink(workspaceSlug as string, projectId as string, issue.id, payload)
.then(() => mutate(ISSUE_DETAILS(issue.id)))
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "This URL already exists for this issue.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
const handleUpdateLink = async (formData: IIssueLink, linkId: string) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { metadata: {}, ...formData };
const updatedLinks = issue.issue_link.map((l) =>
l.id === linkId
? {
...l,
title: formData.title,
url: formData.url,
}
: l
);
mutate<IIssue>(
ISSUE_DETAILS(issue.id),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issueService
.updateIssueLink(workspaceSlug as string, projectId as string, issue.id, linkId, payload)
.then(() => {
mutate(ISSUE_DETAILS(issue.id));
})
.catch((err) => {
console.log(err);
});
};
const handleEditLink = (link: linkDetails) => {
setSelectedLinkToUpdate(link);
setLinkModal(true);
};
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issue) return;
const updatedLinks = issue.issue_link.filter((l) => l.id !== linkId);
mutate<IIssue>(
ISSUE_DETAILS(issue.id),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issueService
.deleteIssueLink(workspaceSlug as string, projectId as string, issue.id, linkId)
.then(() => {
mutate(ISSUE_DETAILS(issue.id));
})
.catch((err) => {
console.log(err);
});
};
const minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
const isNotAllowed = user?.memberRole?.isGuest || user?.memberRole?.isViewer;
return (
<>
<LinkModal
isOpen={linkModal}
handleClose={() => {
setLinkModal(false);
setSelectedLinkToUpdate(null);
}}
data={selectedLinkToUpdate}
status={selectedLinkToUpdate ? true : false}
createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink}
/>
<div className="flex flex-col">
<div className="flex flex-col gap-5 py-5 w-full">
{/* state */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div>
<SidebarStateSelect value={issue?.state || ""} onChange={handleState} disabled={isNotAllowed} />
</div>
</div>
{/* assignee */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div>
<SidebarAssigneeSelect value={issue.assignees || []} onChange={handleAssignee} disabled={isNotAllowed} />
</div>
</div>
{/* priority */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<Signal className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div>
<SidebarPrioritySelect value={issue.priority || ""} onChange={handlePriority} disabled={isNotAllowed} />
</div>
</div>
{/* estimate */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<Triangle className="h-4 w-4 flex-shrink-0 " />
<p>Estimate</p>
</div>
<div>
<SidebarEstimateSelect value={issue.estimate_point} onChange={handleEstimate} disabled={isNotAllowed} />
</div>
</div>
{/* start date */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<CalendarDays className="h-4 w-4 flex-shrink-0" />
<p>Start date</p>
</div>
<div>
<CustomDatePicker
placeholder="Start date"
value={issue.start_date}
onChange={handleStartDate}
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
maxDate={maxDate ?? undefined}
disabled={isNotAllowed}
/>
</div>
</div>
{/* due date */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<CalendarDays className="h-4 w-4 flex-shrink-0" />
<p>Due date</p>
</div>
<div>
<CustomDatePicker
placeholder="Due date"
value={issue.target_date}
onChange={handleTargetDate}
className="bg-custom-background-80 border-none !px-2.5 !py-0.5"
minDate={minDate ?? undefined}
disabled={isNotAllowed}
/>
</div>
</div>
{/* parent */}
<div className="flex items-center gap-2 w-full">
<div className="flex items-center gap-2 w-40">
<User2 className="h-4 w-4 flex-shrink-0" />
<p>Parent</p>
</div>
<div>
<SidebarParentSelect onChange={handleParent} issueDetails={issue} disabled={isNotAllowed} />
</div>
<div className="space-y-4">
{/* state */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2 w-48 whitespace-nowrap">
<div className="w-4 h-4 flex justify-center items-center overflow-hidden">
<DoubleCircleIcon className="h-3.5 w-3.5 flex-shrink-0" />
</div>
<div className="font-medium text-custom-text-200 line-clamp-1">State</div>
</div>
<span className="border-t border-custom-border-200" />
<div className="flex flex-col gap-5 py-5 w-full">
<div className="flex items-center gap-2 w-80">
<div className="flex items-center gap-2 w-40">
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div>
<SidebarCycleSelect issueDetail={issue} handleCycleChange={handleCycle} disabled={isNotAllowed} />
</div>
</div>
<div className="flex items-center gap-2 w-80">
<div className="flex items-center gap-2 w-40">
<DiceIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div>
<SidebarModuleSelect issueDetail={issue} handleModuleChange={handleModule} disabled={isNotAllowed} />
</div>
</div>
<div className="flex items-start gap-2 w-full">
<div className="flex items-center gap-2 w-40 flex-shrink-0">
<Tag className="h-4 w-4 flex-shrink-0" />
<p>Label</p>
</div>
<div className="flex flex-col gap-3 w-full">
<SidebarLabelSelect
issueDetails={issue}
labelList={issue.labels}
submitChanges={handleLabels}
isNotAllowed={isNotAllowed}
uneditable={isNotAllowed}
/>
</div>
</div>
</div>
<span className="border-t border-custom-border-200" />
<div className="flex flex-col gap-5 pt-5 w-full">
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2 w-80">
<div className="flex items-center gap-2 w-40">
<Link2 className="h-4 w-4 rotate-45 flex-shrink-0" />
<p>Links</p>
</div>
<div>
{!isNotAllowed && (
<button
type="button"
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-90"
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs hover:text-custom-text-200 text-custom-text-300`}
onClick={() => setLinkModal(true)}
disabled={false}
>
<Plus className="h-3 w-3" /> New
</button>
)}
</div>
</div>
<div className="flex flex-col gap-3">
{issue?.issue_link && issue.issue_link.length > 0 ? (
<LinksList
links={issue.issue_link}
handleDeleteLink={handleDeleteLink}
handleEditLink={handleEditLink}
userAuth={memberRole}
/>
) : null}
</div>
</div>
<div className="w-full">
<IssuePropertyState
value={issue?.state_detail || null}
onChange={handleState}
states={states}
disabled={false}
hideDropdownArrow
/>
</div>
</div>
</>
{/* assignees */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2 w-48 whitespace-nowrap">
<div className="w-4 h-4 flex justify-center items-center overflow-hidden">
<UserGroupIcon className="h-3.5 w-3.5" />
</div>
<div className="font-medium text-custom-text-200 line-clamp-1">Assignees</div>
</div>
<div className="w-full">
<IssuePropertyAssignee
value={issue?.assignees || null}
onChange={(ids: string[]) => handleAssignee(ids)}
disabled={false}
hideDropdownArrow
members={members}
/>
</div>
</div>
{/* priority */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2 w-48 whitespace-nowrap">
<div className="w-4 h-4 flex justify-center items-center overflow-hidden">
<Signal className="h-3.5 w-3.5" />
</div>
<div className="font-medium text-custom-text-200 line-clamp-1">Priority</div>
</div>
<div className="w-full">
<IssuePropertyPriority
value={issue?.priority || null}
onChange={handlePriority}
disabled={false}
hideDropdownArrow
/>
</div>
</div>
{/* start_date */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2 w-48 whitespace-nowrap">
<div className="w-4 h-4 flex justify-center items-center overflow-hidden">
<CalendarDays className="h-3.5 w-3.5" />
</div>
<div className="font-medium text-custom-text-200 line-clamp-1">Start date</div>
</div>
<div className="w-full">
<IssuePropertyDate
value={issue?.start_date || null}
onChange={(date: string) => handleStartDate(date)}
disabled={false}
placeHolder={`Start date`}
/>
</div>
</div>
{/* target_date */}
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-center gap-2 w-48 whitespace-nowrap">
<div className="w-4 h-4 flex justify-center items-center overflow-hidden">
<CalendarDays className="h-3.5 w-3.5" />
</div>
<div className="font-medium text-custom-text-200 line-clamp-1">Target date</div>
</div>
<div className="w-full">
<IssuePropertyDate
value={issue?.target_date || null}
onChange={(date: string) => handleTargetDate(date)}
disabled={false}
placeHolder={`Target date`}
/>
</div>
</div>
</div>
);
};
@@ -26,13 +26,13 @@ export const IssueReactionPreview: FC<IIssueReactionPreview> = (props) => {
type="button"
onClick={() => handleReaction(reaction)}
key={reaction}
className={`flex items-center gap-1.5 text-custom-text-100 text-sm h-full px-2 py-1 rounded ${
className={`flex items-center gap-1.5 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
isUserReacted(issueReactions[reaction])
? `bg-custom-primary-100/10 hover:bg-custom-primary-100/30`
? `bg-custom-primary-100/20 hover:bg-custom-primary-100/30`
: `bg-custom-background-90 hover:bg-custom-background-100/30`
}`}
>
<span className="text-sm">{renderEmoji(reaction)}</span>
<span>{renderEmoji(reaction)}</span>
<span
className={`${
isUserReacted(issueReactions[reaction]) ? `text-custom-primary-100 hover:text-custom-primary-200` : ``
@@ -23,11 +23,15 @@ export const IssueReactionSelector: FC<IIssueReactionSelector> = (props) => {
<>
<Popover.Button
className={`${
open ? "" : "bg-custom-background-80"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none transition-all hover:bg-custom-background-90`}
open ? "" : "bg-custom-background-90"
} group inline-flex items-center rounded-md bg-custom-background-90 focus:outline-none transition-all hover:bg-custom-background-100`}
>
<span className={`flex justify-center items-center rounded px-2 py-1.5`}>
<SmilePlus className={`${size === "sm" ? "w-3 h-3" : size === "md" ? "w-3.5 h-3.5" : "w-4 h-4"}`} />
<span
className={`flex justify-center items-center rounded-md px-2 ${
size === "sm" ? "w-6 h-6" : size === "md" ? "w-7 h-7" : "w-8 h-8"
}`}
>
<SmilePlus className="text-custom-text-100 h-3.5 w-3.5" />
</span>
</Popover.Button>
<Transition
@@ -7,6 +7,8 @@ import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IIssue } from "types";
import { RootStore } from "store/root";
// constants
import { ISSUE_PRIORITIES } from "constants/issue";
interface IIssuePeekOverview {
workspaceSlug: string;
@@ -19,7 +21,11 @@ interface IIssuePeekOverview {
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const { workspaceSlug, projectId, issueId, handleIssue, children } = props;
const { issueDetail: issueDetailStore }: RootStore = useMobxStore();
const { project: projectStore, issueDetail: issueDetailStore }: RootStore = useMobxStore();
const states = projectStore?.projectStates || undefined;
const members = projectStore?.projectMembers || undefined;
const priorities = ISSUE_PRIORITIES || undefined;
const issueUpdate = (_data: Partial<IIssue>) => {
if (handleIssue) {
@@ -49,17 +55,14 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const issueCommentReactionRemove = (commentId: string, reaction: string) =>
issueDetailStore.removeIssueCommentReaction(workspaceSlug, projectId, issueId, commentId, reaction);
const issueSubscriptionCreate = () => issueDetailStore.createIssueSubscription(workspaceSlug, projectId, issueId);
const issueSubscriptionRemove = () => issueDetailStore.removeIssueSubscription(workspaceSlug, projectId, issueId);
const handleDeleteIssue = () => issueDetailStore.deleteIssue(workspaceSlug, projectId, issueId);
return (
<IssueView
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
states={states}
members={members}
priorities={priorities}
issueUpdate={issueUpdate}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
@@ -68,9 +71,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
issueCommentRemove={issueCommentRemove}
issueCommentReactionCreate={issueCommentReactionCreate}
issueCommentReactionRemove={issueCommentReactionRemove}
issueSubscriptionCreate={issueSubscriptionCreate}
issueSubscriptionRemove={issueSubscriptionRemove}
handleDeleteIssue={handleDeleteIssue}
>
{children}
</IssueView>
+128 -169
View File
@@ -1,22 +1,17 @@
import { FC, ReactNode, useState } from "react";
import { FC, ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { PanelRightOpen, Square, SquareCode, MoveRight, MoveDiagonal, Bell, Link2, Trash2 } from "lucide-react";
import { Maximize2, ArrowRight, Link, Trash, PanelRightOpen, Square, SquareCode } from "lucide-react";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// components
import { PeekOverviewIssueDetails } from "./issue-detail";
import { PeekOverviewProperties } from "./properties";
import { IssueComment } from "./activity";
import { Button, CustomSelect, FullScreenPeekIcon, ModalPeekIcon, SidePeekIcon } from "@plane/ui";
import { DeleteIssueModal } from "../delete-issue-modal";
// types
import { IIssue } from "types";
import { RootStore } from "store/root";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
import useToast from "hooks/use-toast";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
interface IIssueView {
workspaceSlug: string;
@@ -30,9 +25,9 @@ interface IIssueView {
issueCommentRemove: (commentId: string) => void;
issueCommentReactionCreate: (commentId: string, reaction: string) => void;
issueCommentReactionRemove: (commentId: string, reaction: string) => void;
issueSubscriptionCreate: () => void;
issueSubscriptionRemove: () => void;
handleDeleteIssue: () => Promise<void>;
states: any;
members: any;
priorities: any;
children: ReactNode;
}
@@ -41,17 +36,17 @@ type TPeekModes = "side-peek" | "modal" | "full-screen";
const peekOptions: { key: TPeekModes; icon: any; title: string }[] = [
{
key: "side-peek",
icon: SidePeekIcon,
icon: PanelRightOpen,
title: "Side Peek",
},
{
key: "modal",
icon: ModalPeekIcon,
icon: Square,
title: "Modal",
},
{
key: "full-screen",
icon: FullScreenPeekIcon,
icon: SquareCode,
title: "Full Screen",
},
];
@@ -69,9 +64,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
issueCommentRemove,
issueCommentReactionCreate,
issueCommentReactionRemove,
issueSubscriptionCreate,
issueSubscriptionRemove,
handleDeleteIssue,
states,
members,
priorities,
children,
} = props;
@@ -81,20 +76,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const { user: userStore, issueDetail: issueDetailStore }: RootStore = useMobxStore();
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const { setToastAlert } = useToast();
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues/${peekIssueId}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
const handlePeekMode = (_peek: TPeekModes) => {
if (peekMode != _peek) setPeekMode(_peek);
};
const updateRoutePeekId = () => {
@@ -134,129 +117,128 @@ export const IssueView: FC<IIssueView> = observer((props) => {
}
);
useSWR(
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
? `ISSUE_PEEK_OVERVIEW_SUBSCRIPTION_${workspaceSlug}_${projectId}_${peekIssueId}`
: null,
async () => {
if (workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId) {
await issueDetailStore.fetchIssueSubscription(workspaceSlug, projectId, issueId);
}
}
);
const issue = issueDetailStore.getIssue;
const issueReactions = issueDetailStore.getIssueReactions;
const issueComments = issueDetailStore.getIssueComments;
const issueSubscription = issueDetailStore.getIssueSubscription;
const user = userStore?.currentUser;
const currentMode = peekOptions.find((m) => m.key === peekMode);
return (
<>
{issue && (
<DeleteIssueModal
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
data={issue}
onSubmit={handleDeleteIssue}
/>
)}
<div className="w-full !text-base">
<div onClick={updateRoutePeekId} className="w-full cursor-pointer">
{children}
</div>
<div className="w-full !text-base">
<div onClick={updateRoutePeekId} className="w-full cursor-pointer">
{children}
</div>
{issueId === peekIssueId && (
<div
className={`fixed z-20 overflow-hidden bg-custom-background-100 flex flex-col transition-all duration-300 border border-custom-border-200 rounded
{issueId === peekIssueId && (
<div
className={`fixed z-50 overflow-hidden bg-custom-background-80 flex flex-col transition-all duration-300 border border-custom-border-200 rounded shadow-custom-shadow-2xl
${peekMode === "side-peek" ? `w-full md:w-[50%] top-0 right-0 bottom-0` : ``}
${peekMode === "modal" ? `top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] w-5/6 h-5/6` : ``}
${peekMode === "full-screen" ? `top-0 right-0 bottom-0 left-0 m-4` : ``}
`}
style={{
boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
}}
>
{/* header */}
<div className="relative flex items-center justify-between p-5 border-b border-custom-border-200">
<div className="flex items-center gap-4">
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
<button onClick={redirectToIssueDetail}>
<MoveDiagonal className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
{currentMode && (
<div className="flex-shrink-0 flex items-center gap-2">
<CustomSelect
value={currentMode}
onChange={(val: any) => setPeekMode(val)}
customButton={
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
}
>
{peekOptions.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div className="flex items-center gap-1.5">
<mode.icon className={`h-4 w-4 flex-shrink-0 -my-1 `} />
{mode.title}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<div className="flex items-center gap-4">
<Button
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
onClick={() =>
issueSubscription && issueSubscription.subscribed
? issueSubscriptionRemove
: issueSubscriptionCreate
}
>
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
</Button>
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200 -rotate-45" />
</button>
<button onClick={() => setDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
</button>
</div>
>
{/* header */}
<div className="flex-shrink-0 w-full p-4 py-3 relative flex items-center gap-2 border-b border-custom-border-200">
<div
className="flex-shrink-0 overflow-hidden w-6 h-6 flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100"
onClick={removeRoutePeekId}
>
<ArrowRight width={12} strokeWidth={2} />
</div>
{/* content */}
<div className="w-full h-full overflow-hidden overflow-y-auto">
{issueDetailStore?.loader && !issue ? (
<div className="text-center py-10">Loading...</div>
) : (
issue && (
<>
{["side-peek", "modal"].includes(peekMode) ? (
<div className="flex flex-col gap-3 px-10 py-6">
<div
className="flex-shrink-0 overflow-hidden w-6 h-6 flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100"
onClick={redirectToIssueDetail}
>
<Maximize2 width={12} strokeWidth={2} />
</div>
<div className="flex-shrink-0 flex items-center gap-2">
{peekOptions.map((_option) => (
<div
key={_option?.key}
className={`px-1.5 min-w-6 h-6 flex justify-center items-center gap-1 rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100
${peekMode === _option?.key ? `bg-custom-background-100` : ``}
`}
onClick={() => handlePeekMode(_option?.key)}
>
<_option.icon width={14} strokeWidth={2} />
<div className="text-xs font-medium">{_option?.title}</div>
</div>
))}
</div>
<div className="w-full flex justify-end items-center gap-2">
<div className="px-1.5 min-w-6 h-6 text-xs font-medium flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100">
Subscribe
</div>
<div className="overflow-hidden w-6 h-6 flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100">
<Link width={12} strokeWidth={2} />
</div>
<div className="overflow-hidden w-6 h-6 flex justify-center items-center rounded-sm transition-all duration-100 border border-custom-border-200 cursor-pointer hover:bg-custom-background-100">
<Trash width={12} strokeWidth={2} />
</div>
</div>
</div>
{/* content */}
<div className="w-full h-full overflow-hidden overflow-y-auto">
{issueDetailStore?.loader && !issue ? (
<div className="text-center py-10">Loading...</div>
) : (
issue && (
<>
{["side-peek", "modal"].includes(peekMode) ? (
<div className="space-y-6 p-4 py-5">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
issue={issue}
issueUpdate={issueUpdate}
issueReactions={issueReactions}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
<PeekOverviewProperties
issue={issue}
issueUpdate={issueUpdate}
states={states}
members={members}
priorities={priorities}
/>
<div className="border-t border-custom-border-400" />
<IssueComment
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
user={user}
issueComments={issueComments}
issueCommentCreate={issueCommentCreate}
issueCommentUpdate={issueCommentUpdate}
issueCommentRemove={issueCommentRemove}
issueCommentReactionCreate={issueCommentReactionCreate}
issueCommentReactionRemove={issueCommentReactionRemove}
/>
</div>
) : (
<div className="w-full h-full flex">
<div className="w-full h-full space-y-6 p-4 py-5">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
issue={issue}
issueUpdate={issueUpdate}
issueReactions={issueReactions}
issueUpdate={issueUpdate}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
<PeekOverviewProperties issue={issue} issueUpdate={issueUpdate} user={user} />
<div className="border-t border-custom-border-400" />
<IssueComment
workspaceSlug={workspaceSlug}
@@ -271,46 +253,23 @@ export const IssueView: FC<IIssueView> = observer((props) => {
issueCommentReactionRemove={issueCommentReactionRemove}
/>
</div>
) : (
<div className="w-full h-full flex">
<div className="w-full h-full space-y-6 p-4 py-5">
<PeekOverviewIssueDetails
workspaceSlug={workspaceSlug}
issue={issue}
issueReactions={issueReactions}
issueUpdate={issueUpdate}
user={user}
issueReactionCreate={issueReactionCreate}
issueReactionRemove={issueReactionRemove}
/>
<div className="border-t border-custom-border-400" />
<IssueComment
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
user={user}
issueComments={issueComments}
issueCommentCreate={issueCommentCreate}
issueCommentUpdate={issueCommentUpdate}
issueCommentRemove={issueCommentRemove}
issueCommentReactionCreate={issueCommentReactionCreate}
issueCommentReactionRemove={issueCommentReactionRemove}
/>
</div>
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-4 py-5">
<PeekOverviewProperties issue={issue} issueUpdate={issueUpdate} user={user} />
</div>
<div className="flex-shrink-0 !w-[400px] h-full border-l border-custom-border-200 p-4 py-5">
<PeekOverviewProperties
issue={issue}
issueUpdate={issueUpdate}
states={states}
members={members}
priorities={priorities}
/>
</div>
)}
</>
)
)}
</div>
</div>
)}
</>
)
)}
</div>
)}
</div>
</>
</div>
)}
</div>
);
});
+6 -11
View File
@@ -1,9 +1,12 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, AvatarGroup, CustomSearchSelect } from "@plane/ui";
import { AssigneesList, Avatar } from "components/ui";
import { CustomSearchSelect } from "@plane/ui";
import { User2 } from "lucide-react";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@@ -32,7 +35,7 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} showTooltip={false} />
<Avatar user={member.member} />
{member.member.is_bot ? member.member.first_name : member.member.display_name}
</div>
),
@@ -47,15 +50,7 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
<div className="flex items-center gap-2 cursor-pointer text-xs text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AvatarGroup showTooltip={false}>
{value.map((assigneeId) => {
const member = members?.find((m) => m.member.id === assigneeId)?.member;
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
<AssigneesList userIds={value} length={3} showLength />
</div>
) : (
<div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-300 hover:bg-custom-background-80">
@@ -1,10 +1,14 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, AvatarGroup, CustomSearchSelect } from "@plane/ui";
import { CustomSearchSelect } from "@plane/ui";
import { AssigneesList, Avatar } from "components/ui/avatar";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@@ -33,7 +37,7 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, disabl
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
@@ -46,15 +50,7 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, disabl
<>
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2">
<AvatarGroup>
{value.map((assigneeId) => {
const member = members?.find((m) => m.member.id === assigneeId)?.member;
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
<AssigneesList userIds={value} length={3} showLength={false} />
<span className="text-custom-text-100 text-xs">{value.length} Assignees</span>
</div>
) : (
@@ -16,20 +16,13 @@ type Props = {
export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabled = false }) => {
const { estimatePoints } = useEstimateOption();
const currentEstimate = estimatePoints?.find((e) => e.key === value)?.value;
return (
<CustomSelect
value={value}
customButton={
<div className="flex items-center gap-1.5 text-xs bg-custom-background-80 rounded px-2.5 py-0.5">
{currentEstimate ? (
<>
<Triangle className={`h-3 w-3 ${value !== null ? "text-custom-text-100" : "text-custom-text-200"}`} />
{currentEstimate}
</>
) : (
"No Estimate"
)}
<div className="flex items-center gap-1.5 !text-sm bg-custom-background-80 rounded px-2.5 py-0.5">
<Triangle className={`h-4 w-4 ${value !== null ? "text-custom-text-100" : "text-custom-text-200"}`} />
{estimatePoints?.find((e) => e.key === value)?.value ?? "No estimate"}
</div>
}
onChange={onChange}
@@ -38,7 +31,7 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabl
<CustomSelect.Option value={null}>
<>
<span>
<Triangle className="h-3.5 w-3" />
<Triangle className="h-4 w-4" />
</span>
None
</>
@@ -48,7 +41,7 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabl
<CustomSelect.Option key={point.key} value={point.key}>
<>
<span>
<Triangle className="h-3.5 w-3.5" />
<Triangle className="h-4 w-4" />
</span>
{point.value}
</>
+161 -144
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Controller, useForm } from "react-hook-form";
import { Controller, UseFormWatch, useForm } from "react-hook-form";
import { TwitterPicker } from "react-color";
// headless ui
import { Listbox, Popover, Transition } from "@headlessui/react";
@@ -12,7 +12,7 @@ import useUser from "hooks/use-user";
// ui
import { Input, Spinner } from "@plane/ui";
// icons
import { Component, Plus, X } from "lucide-react";
import { Component, Plus, Tag, X } from "lucide-react";
// types
import { IIssue, IIssueLabels } from "types";
// fetch-keys
@@ -20,7 +20,8 @@ import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
issueDetails: IIssue | undefined;
labelList: string[];
issueControl: any;
watchIssue: UseFormWatch<IIssue>;
submitChanges: (formData: any) => void;
isNotAllowed: boolean;
uneditable: boolean;
@@ -35,7 +36,8 @@ const issueLabelService = new IssueLabelService();
export const SidebarLabelSelect: React.FC<Props> = ({
issueDetails,
labelList,
issueControl,
watchIssue,
submitChanges,
isNotAllowed,
uneditable,
@@ -89,152 +91,167 @@ export const SidebarLabelSelect: React.FC<Props> = ({
}, [createLabelForm, reset, setFocus]);
return (
<div className={`flex flex-col gap-3 ${uneditable ? "opacity-60" : ""}`}>
<div className="flex flex-wrap gap-1">
{labelList?.map((labelId) => {
const label = issueLabels?.find((l) => l.id === labelId);
<div className={`space-y-3 py-3 ${uneditable ? "opacity-60" : ""}`}>
<div className="flex items-start justify-between">
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-custom-text-200">
<Tag className="h-4 w-4" />
<p>Label</p>
</div>
<div className="basis-1/2">
<div className="flex flex-wrap gap-1">
{watchIssue("labels")?.map((labelId) => {
const label = issueLabels?.find((l) => l.id === labelId);
if (label)
return (
<span
key={label.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-100 px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
onClick={() => {
const updatedLabels = labelList?.filter((l) => l !== labelId);
submitChanges({
labels: updatedLabels,
});
}}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
<X className="h-2 w-2 group-hover:text-red-500" />
</span>
);
})}
<Listbox
as="div"
value={issueDetails?.labels ?? []}
onChange={(val: any) => submitChanges({ labels: val })}
className="flex-shrink-0"
multiple
disabled={isNotAllowed || uneditable}
>
{({ open }) => (
<div className="relative">
<Listbox.Button
if (label)
return (
<span
key={label.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-100 px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
onClick={() => {
const updatedLabels = watchIssue("labels")?.filter((l) => l !== labelId);
submitChanges({
labels: updatedLabels,
});
}}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
<X className="h-2 w-2 group-hover:text-red-500" />
</span>
);
})}
<Controller
control={issueControl}
name="labels"
render={({ field: { value } }) => (
<Listbox
as="div"
value={value}
onChange={(val: any) => submitChanges({ labels: val })}
className="flex-shrink-0"
multiple
disabled={isNotAllowed || uneditable}
>
{({ open }) => (
<div className="relative">
<Listbox.Button
className={`flex ${
isNotAllowed || uneditable
? "cursor-not-allowed"
: "cursor-pointer hover:bg-custom-background-90"
} items-center gap-2 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
>
Select Label
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-custom-background-80 py-1 text-xs shadow-lg border border-custom-border-100 focus:outline-none">
<div className="py-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => {
const children = issueLabels?.filter((l) => l.parent === label.id);
if (children.length === 0) {
if (!label.parent)
return (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${active || selected ? "bg-custom-background-90" : ""} ${
selected ? "" : "text-custom-text-200"
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
}
value={label.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</Listbox.Option>
);
} else
return (
<div className="border-y border-custom-border-100 bg-custom-background-90">
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-custom-text-100">
<Component className="h-3 w-3" />
{label.name}
</div>
<div>
{children.map((child) => (
<Listbox.Option
key={child.id}
className={({ active, selected }) =>
`${active || selected ? "bg-custom-background-100" : ""} ${
selected ? "" : "text-custom-text-200"
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
}
value={child.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child?.color ?? "black",
}}
/>
{child.name}
</Listbox.Option>
))}
</div>
</div>
);
})
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
/>
{!isNotAllowed && (
<button
type="button"
className={`flex ${
isNotAllowed || uneditable ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-90"
} items-center gap-2 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs hover:text-custom-text-200 text-custom-text-300`}
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
onClick={() => setCreateLabelForm((prevData) => !prevData)}
disabled={uneditable}
>
Select Label
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-custom-background-80 py-1 text-xs shadow-lg border border-custom-border-100 focus:outline-none">
<div className="py-1">
{issueLabels ? (
issueLabels.length > 0 ? (
issueLabels.map((label: IIssueLabels) => {
const children = issueLabels?.filter((l) => l.parent === label.id);
if (children.length === 0) {
if (!label.parent)
return (
<Listbox.Option
key={label.id}
className={({ active, selected }) =>
`${active || selected ? "bg-custom-background-90" : ""} ${
selected ? "" : "text-custom-text-200"
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
}
value={label.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000",
}}
/>
{label.name}
</Listbox.Option>
);
} else
return (
<div className="border-y border-custom-border-100 bg-custom-background-90">
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-custom-text-100">
<Component className="h-3 w-3" />
{label.name}
</div>
<div>
{children.map((child) => (
<Listbox.Option
key={child.id}
className={({ active, selected }) =>
`${active || selected ? "bg-custom-background-100" : ""} ${
selected ? "" : "text-custom-text-200"
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
}
value={child.id}
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: child?.color ?? "black",
}}
/>
{child.name}
</Listbox.Option>
))}
</div>
</div>
);
})
) : (
<div className="text-center">No labels found</div>
)
) : (
<Spinner />
)}
</div>
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
{!isNotAllowed && (
<button
type="button"
className={`flex ${
isNotAllowed || uneditable ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-90"
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs hover:text-custom-text-200 text-custom-text-300`}
onClick={() => setCreateLabelForm((prevData) => !prevData)}
disabled={uneditable}
>
{createLabelForm ? (
<>
<X className="h-3 w-3" /> Cancel
</>
) : (
<>
<Plus className="h-3 w-3" /> New
</>
{createLabelForm ? (
<>
<X className="h-3 w-3" /> Cancel
</>
) : (
<>
<Plus className="h-3 w-3" /> New
</>
)}
</button>
)}
</button>
)}
</div>
</div>
</div>
{createLabelForm && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
<div>
+14 -21
View File
@@ -32,7 +32,7 @@ import {
// ui
import { CustomDatePicker } from "components/ui";
// icons
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react";
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Trash2, Triangle, User2 } from "lucide-react";
import { ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
@@ -333,7 +333,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="state"
@@ -354,7 +354,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="assignees"
@@ -375,7 +375,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<Signal className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="priority"
@@ -583,7 +583,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div className="space-y-1">
<div className="space-y-1 sm:w-1/2">
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
@@ -598,7 +598,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<DiceIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1">
<div className="space-y-1 sm:w-1/2">
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
@@ -611,21 +611,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)}
</div>
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
<div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<Tag className="h-4 w-4 flex-shrink-0" />
<p>Label</p>
</div>
<div className="space-y-1 sm:w-1/2">
<SidebarLabelSelect
issueDetails={issueDetail}
labelList={issueDetail?.labels ?? []}
submitChanges={submitChanges}
isNotAllowed={isNotAllowed}
uneditable={uneditable ?? false}
/>
</div>
</div>
<SidebarLabelSelect
issueDetails={issueDetail}
issueControl={control}
watchIssue={watchIssue}
submitChanges={submitChanges}
isNotAllowed={isNotAllowed}
uneditable={uneditable ?? false}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
+18 -13
View File
@@ -8,12 +8,14 @@ import { IssueService } from "services/issue";
import { TrackEventService } from "services/track_event.service";
// components
import { ViewDueDateSelect, ViewStartDateSelect } from "components/issues";
import { PrioritySelect } from "components/project";
import { MembersSelect, PrioritySelect } from "components/project";
import { StateSelect } from "components/states";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IUser, IIssue, IState } from "types";
// fetch-keys
import { SUB_ISSUES } from "constants/fetch-keys";
import { IssuePropertyAssignee, IssuePropertyState } from "../issue-layouts/properties";
export interface IIssueProperty {
workspaceSlug: string;
@@ -30,7 +32,7 @@ const trackEventService = new TrackEventService();
export const IssueProperty: React.FC<IIssueProperty> = observer((props) => {
const { workspaceSlug, parentIssue, issue, user, editable } = props;
const { issueFilter: issueFilterStore } = useMobxStore();
const { project: projectStore, issueFilter: issueFilterStore } = useMobxStore();
const displayProperties = issueFilterStore.userDisplayProperties ?? {};
@@ -115,6 +117,8 @@ export const IssueProperty: React.FC<IIssueProperty> = observer((props) => {
);
};
const statesList = getStatesList(projectStore.states?.[issue.project]);
return (
<div className="relative flex items-center gap-1">
{displayProperties.priority && (
@@ -130,12 +134,12 @@ export const IssueProperty: React.FC<IIssueProperty> = observer((props) => {
{displayProperties.state && (
<div className="flex-shrink-0">
<IssuePropertyState
projectId={issue?.project_detail?.id || null}
value={issue?.state_detail || null}
<StateSelect
value={issue.state_detail}
states={statesList}
onChange={(data) => handleStateChange(data)}
disabled={false}
hideDropdownArrow={true}
hideDropdownArrow
disabled={!editable}
/>
</div>
)}
@@ -164,12 +168,13 @@ export const IssueProperty: React.FC<IIssueProperty> = observer((props) => {
{displayProperties.assignee && (
<div className="flex-shrink-0">
<IssuePropertyAssignee
projectId={issue?.project_detail?.id || null}
value={issue?.assignees || null}
hideDropdownArrow={true}
<MembersSelect
value={issue.assignees}
onChange={(val) => handleAssigneeChange(val)}
disabled={false}
members={projectStore.members ? (projectStore.members[issue.project] ?? []).map((m) => m.member) : []}
hideDropdownArrow
disabled={!editable}
multiple
/>
</div>
)}
+12 -13
View File
@@ -1,19 +1,19 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Controller, useForm } from "react-hook-form";
import { TwitterPicker } from "react-color";
import { Dialog, Popover, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueLabelService } from "services/issue";
// ui
import { Button, Input } from "@plane/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
import type { IIssueLabels, IState } from "types";
import type { IUser, IIssueLabels, IState } from "types";
// constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label";
// types
@@ -22,6 +22,7 @@ type Props = {
projectId: string;
handleClose: () => void;
onSuccess?: (response: IIssueLabels) => void;
user: IUser | undefined;
};
const defaultValues: Partial<IState> = {
@@ -29,15 +30,12 @@ const defaultValues: Partial<IState> = {
color: "rgb(var(--color-text-200))",
};
export const CreateLabelModal: React.FC<Props> = observer((props) => {
const { isOpen, projectId, handleClose, onSuccess } = props;
const issueLabelService = new IssueLabelService();
export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClose, user, onSuccess }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const { projectLabel: projectLabelStore } = useMobxStore();
const {
formState: { errors, isSubmitting },
handleSubmit,
@@ -61,9 +59,10 @@ export const CreateLabelModal: React.FC<Props> = observer((props) => {
const onSubmit = async (formData: IIssueLabels) => {
if (!workspaceSlug) return;
await projectLabelStore
.createLabel(workspaceSlug.toString(), projectId.toString(), formData)
await issueLabelService
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
.then((res) => {
mutate<IIssueLabels[]>(PROJECT_ISSUE_LABELS(projectId), (prevData) => [res, ...(prevData ?? [])], false);
onClose();
if (onSuccess) onSuccess(res);
})
@@ -198,4 +197,4 @@ export const CreateLabelModal: React.FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};
@@ -1,18 +1,21 @@
import React, { forwardRef, useEffect } from "react";
import { forwardRef, useEffect, Fragment } from "react";
import { useRouter } from "next/router";
import { TwitterPicker } from "react-color";
import { mutate } from "swr";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// stores
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useUserAuth from "hooks/use-user-auth";
// react-color
import { TwitterPicker } from "react-color";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// services
import { IssueLabelService } from "services/issue";
// ui
import { Button, Input } from "@plane/ui";
// types
import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label";
type Props = {
@@ -28,162 +31,171 @@ const defaultValues: Partial<IIssueLabels> = {
color: "rgb(var(--color-text-200))",
};
export const CreateUpdateLabelInline = observer(
forwardRef<HTMLFormElement, Props>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
const issueLabelService = new IssueLabelService();
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
// store
const { projectLabel: projectLabelStore } = useMobxStore();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
handleSubmit,
control,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
setFocus,
} = useForm<IIssueLabels>({
defaultValues,
});
const { user } = useUserAuth();
const handleClose = () => {
setLabelForm(false);
reset(defaultValues);
if (onClose) onClose();
};
const {
handleSubmit,
control,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
} = useForm<IIssueLabels>({
defaultValues,
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const handleClose = () => {
setLabelForm(false);
reset(defaultValues);
if (onClose) onClose();
};
await projectLabelStore.createLabel(workspaceSlug.toString(), projectId.toString(), formData).then(() => {
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issueLabelService
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
handleClose();
reset(defaultValues);
});
};
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await projectLabelStore
.updateLabel(workspaceSlug.toString(), projectId.toString(), labelToUpdate?.id!, formData)
.then(() => {
reset(defaultValues);
handleClose();
});
};
await issueLabelService
.patchIssueLabel(workspaceSlug as string, projectId as string, labelToUpdate?.id ?? "", formData, user)
.then(() => {
reset(defaultValues);
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
false
);
handleClose();
});
};
/**
* For settings focus on name input
*/
useEffect(() => {
setFocus("name");
}, [setFocus, labelForm]);
useEffect(() => {
if (!labelForm && isUpdating) return;
useEffect(() => {
if (!labelToUpdate) return;
reset();
}, [labelForm, isUpdating, reset]);
setValue("name", labelToUpdate.name);
useEffect(() => {
if (!labelToUpdate) return;
setValue("color", labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000");
setValue("name", labelToUpdate.name);
}, [labelToUpdate, setValue]);
useEffect(() => {
if (labelToUpdate) {
setValue("color", labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000");
}, [labelToUpdate, setValue]);
return;
}
useEffect(() => {
if (labelToUpdate) {
setValue("color", labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000");
return;
}
setValue("color", getRandomLabelColor());
}, [labelToUpdate, setValue]);
setValue("color", getRandomLabelColor());
}, [labelToUpdate, setValue]);
return (
<div
className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${
labelForm ? "" : "hidden"
}`}
ref={ref}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-4 w-4 rounded-full"
style={{
backgroundColor: watch("color"),
}}
/>
</Popover.Button>
return (
<form
ref={ref}
onSubmit={(e) => {
e.preventDefault();
handleSubmit(isUpdating ? handleLabelUpdate : handleLabelCreate)();
}}
className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${
labelForm ? "" : "hidden"
}`}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-4 w-4 rounded-full"
style={{
backgroundColor: watch("color"),
}}
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
colors={LABEL_COLOR_OPTIONS}
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
colors={LABEL_COLOR_OPTIONS}
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Controller
control={control}
name="name"
rules={{
required: "Label title is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="labelName"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Label title"
className="w-full"
/>
)}
/>
</div>
<Button variant="neutral-primary" onClick={() => handleClose()}>
Cancel
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Controller
control={control}
name="name"
rules={{
required: "Label title is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="labelName"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Label title"
className="w-full"
/>
)}
/>
</div>
<Button variant="neutral-primary" onClick={() => handleClose()}>
Cancel
</Button>
{isUpdating ? (
<Button variant="primary" onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update"}
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{isUpdating ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"}
) : (
<Button variant="primary" onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</Button>
</form>
);
})
);
)}
</div>
);
});
+30 -25
View File
@@ -1,39 +1,40 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { AlertTriangle } from "lucide-react";
// services
import { IssueLabelService } from "services/issue";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import type { IIssueLabels } from "types";
import type { IUser, IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
onClose: () => void;
data: IIssueLabels | null;
user: IUser | undefined;
};
export const DeleteLabelModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, data } = props;
// services
const issueLabelService = new IssueLabelService();
export const DeleteLabelModal: React.FC<Props> = ({ isOpen, onClose, data, user }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectLabel: projectLabelStore } = useMobxStore();
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// hooks
const { setToastAlert } = useToast();
const handleClose = () => {
@@ -46,19 +47,23 @@ export const DeleteLabelModal: React.FC<Props> = observer((props) => {
setIsDeleteLoading(true);
await projectLabelStore
.deleteLabel(workspaceSlug.toString(), projectId.toString(), data.id)
.then(() => {
handleClose();
})
.catch((err) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId.toString()),
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
false
);
await issueLabelService
.deleteIssueLabel(workspaceSlug.toString(), projectId.toString(), data.id, user)
.then(() => handleClose())
.catch(() => {
setIsDeleteLoading(false);
const error = err?.error || "Label could not be deleted. Please try again.";
mutate(PROJECT_ISSUE_LABELS(projectId.toString()));
setToastAlert({
type: "error",
title: "Error!",
message: error,
message: "Label could not be deleted. Please try again.",
});
});
};
@@ -124,4 +129,4 @@ export const DeleteLabelModal: React.FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};
+2 -3
View File
@@ -3,6 +3,5 @@ export * from "./create-update-label-inline";
export * from "./delete-label-modal";
export * from "./label-select";
export * from "./labels-list-modal";
export * from "./project-setting-label-group";
export * from "./project-setting-label-list-item";
export * from "./project-setting-label-list";
export * from "./single-label-group";
export * from "./single-label";
+36 -27
View File
@@ -2,47 +2,38 @@ import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// icons
import { LayerStackIcon } from "@plane/ui";
import { Search } from "lucide-react";
// services
import { IssueLabelService } from "services/issue";
// types
import { IIssueLabels } from "types";
import { IUser, IIssueLabels } from "types";
// constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
parent: IIssueLabels | undefined;
user: IUser | undefined;
};
export const LabelsListModal: React.FC<Props> = observer((props) => {
const { isOpen, handleClose, parent } = props;
const issueLabelService = new IssueLabelService();
export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent, user }) => {
const [query, setQuery] = useState("");
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectLabel: projectLabelStore, project: projectStore } = useMobxStore();
// states
const [query, setQuery] = useState("");
// api call to fetch project details
useSWR(
workspaceSlug && projectId ? "PROJECT_LABELS" : null,
const { data: issueLabels, mutate } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => projectStore.fetchProjectLabels(workspaceSlug.toString(), projectId.toString())
? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId as string)
: null
);
// derived values
const issueLabels = projectStore.labels?.[projectId?.toString()!] ?? null;
const filteredLabels: IIssueLabels[] =
query === ""
? issueLabels ?? []
@@ -56,9 +47,27 @@ export const LabelsListModal: React.FC<Props> = observer((props) => {
const addChildLabel = async (label: IIssueLabels) => {
if (!workspaceSlug || !projectId) return;
await projectLabelStore.updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, {
parent: parent?.id!,
});
mutate(
(prevData: any) =>
prevData?.map((l: any) => {
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
return l;
}),
false
);
await issueLabelService
.patchIssueLabel(
workspaceSlug as string,
projectId as string,
label.id,
{
parent: parent?.id ?? "",
},
user
)
.then(() => mutate());
};
return (
@@ -113,7 +122,7 @@ export const LabelsListModal: React.FC<Props> = observer((props) => {
if (
(label.parent === "" || label.parent === null) && // issue does not have any other parent
label.id !== parent?.id && // issue is not itself
children?.length === 0 // issue doesn't have any other children
children?.length === 0 // issue doesn't have any othe children
)
return (
<Combobox.Option
@@ -164,4 +173,4 @@ export const LabelsListModal: React.FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};
@@ -1,166 +0,0 @@
import React, { useState, useRef } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import {
CreateUpdateLabelInline,
DeleteLabelModal,
LabelsListModal,
ProjectSettingLabelItem,
ProjectSettingLabelGroup,
} from "components/labels";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// images
import emptyLabel from "public/empty-state/label.svg";
// types
import { IIssueLabels } from "types";
export const ProjectSettingsLabelList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
// states
const [labelForm, setLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [labelsListModal, setLabelsListModal] = useState(false);
const [labelToUpdate, setLabelToUpdate] = useState<IIssueLabels | null>(null);
const [parentLabel, setParentLabel] = useState<IIssueLabels | undefined>(undefined);
const [selectDeleteLabel, setSelectDeleteLabel] = useState<IIssueLabels | null>(null);
// ref
const scrollToRef = useRef<HTMLFormElement>(null);
// api call to fetch project details
useSWR(
workspaceSlug && projectId ? "PROJECT_LABELS" : null,
workspaceSlug && projectId
? () => projectStore.fetchProjectLabels(workspaceSlug.toString(), projectId.toString())
: null
);
// derived values
const issueLabels = projectStore.labels?.[projectId?.toString()!] ?? null;
const newLabel = () => {
setIsUpdating(false);
setLabelForm(true);
};
const addLabelToGroup = (parentLabel: IIssueLabels) => {
setLabelsListModal(true);
setParentLabel(parentLabel);
};
const editLabel = (label: IIssueLabels) => {
setLabelForm(true);
setIsUpdating(true);
setLabelToUpdate(label);
};
return (
<>
<LabelsListModal isOpen={labelsListModal} parent={parentLabel} handleClose={() => setLabelsListModal(false)} />
<DeleteLabelModal
isOpen={!!selectDeleteLabel}
data={selectDeleteLabel ?? null}
onClose={() => setSelectDeleteLabel(null)}
/>
<div className="flex items-center py-3.5 border-b border-custom-border-200 justify-between">
<h3 className="text-xl font-medium">Labels</h3>
<Button variant="primary" onClick={newLabel} size="sm">
Add label
</Button>
</div>
<div className="space-y-3 py-6 h-full w-full">
{labelForm && (
<CreateUpdateLabelInline
labelForm={labelForm}
setLabelForm={setLabelForm}
isUpdating={isUpdating}
labelToUpdate={labelToUpdate}
ref={scrollToRef}
onClose={() => {
setLabelForm(false);
setIsUpdating(false);
setLabelToUpdate(null);
}}
/>
)}
{/* labels */}
{issueLabels &&
issueLabels.map((label) => {
const children = issueLabels?.filter((l) => l.parent === label.id);
if (children && children.length === 0) {
if (!label.parent)
return (
<ProjectSettingLabelItem
key={label.id}
label={label}
addLabelToGroup={() => addLabelToGroup(label)}
editLabel={(label) => {
editLabel(label);
scrollToRef.current?.scrollIntoView({
behavior: "smooth",
});
}}
handleLabelDelete={() => setSelectDeleteLabel(label)}
/>
);
} else {
return (
<ProjectSettingLabelGroup
key={label.id}
label={label}
labelChildren={children}
addLabelToGroup={addLabelToGroup}
editLabel={(label) => {
editLabel(label);
scrollToRef.current?.scrollIntoView({
behavior: "smooth",
});
}}
handleLabelDelete={() => setSelectDeleteLabel(label)}
/>
);
}
})}
{/* loading state */}
{!issueLabels && (
<Loader className="space-y-5">
<Loader.Item height="42px" />
<Loader.Item height="42px" />
<Loader.Item height="42px" />
<Loader.Item height="42px" />
</Loader>
)}
{/* empty state */}
{issueLabels && issueLabels.length === 0 && (
<EmptyState
title="No labels yet"
description="Create labels to help organize and filter issues in you project"
image={emptyLabel}
primaryButton={{
text: "Add label",
onClick: () => newLabel(),
}}
/>
)}
</div>
</>
);
});
@@ -1,41 +1,72 @@
import React from "react";
import { useRouter } from "next/router";
import { Disclosure, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import { IssueLabelService } from "services/issue";
// ui
import { CustomMenu } from "components/ui";
import { CustomMenu } from "@plane/ui";
// icons
import { ChevronDown, Component, Pencil, Plus, Trash2, X } from "lucide-react";
// types
import { IIssueLabels } from "types";
import { IUser, IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
label: IIssueLabels;
labelChildren: IIssueLabels[];
handleLabelDelete: () => void;
editLabel: (label: IIssueLabels) => void;
addLabelToGroup: (parentLabel: IIssueLabels) => void;
editLabel: (label: IIssueLabels) => void;
handleLabelDelete: () => void;
user: IUser | undefined;
};
export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
const { label, labelChildren, addLabelToGroup, editLabel, handleLabelDelete } = props;
// services
const issueLabelService = new IssueLabelService();
// router
export const SingleLabelGroup: React.FC<Props> = ({
label,
labelChildren,
addLabelToGroup,
editLabel,
handleLabelDelete,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectLabel: projectLabelStore } = useMobxStore();
const removeFromGroup = (label: IIssueLabels) => {
if (!workspaceSlug || !projectId) return;
projectLabelStore.updateLabel(workspaceSlug.toString(), projectId.toString(), label.id, {
parent: null,
});
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) =>
prevData?.map((l) => {
if (l.id === label.id) return { ...l, parent: null };
return l;
}),
false
);
issueLabelService
.patchIssueLabel(
workspaceSlug as string,
projectId as string,
label.id,
{
parent: null,
},
user
)
.then(() => {
mutate(PROJECT_ISSUE_LABELS(projectId as string));
});
};
return (
@@ -132,7 +163,7 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
<div className="flex items-center">
<button className="flex items-center justify-start gap-2" onClick={handleLabelDelete}>
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
<X className="h-4 w-4 text-custom-sidebar-text-400 flex-shrink-0" />
</button>
</div>
</div>
@@ -145,4 +176,4 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
)}
</Disclosure>
);
});
};
@@ -3,11 +3,11 @@ import React, { useRef, useState } from "react";
//hook
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { CustomMenu } from "components/ui";
import { CustomMenu } from "@plane/ui";
// types
import { IIssueLabels } from "types";
//icons
import { Component, X, Pencil } from "lucide-react";
import { Component, Pencil, X } from "lucide-react";
type Props = {
label: IIssueLabels;
@@ -16,9 +16,7 @@ type Props = {
handleLabelDelete: () => void;
};
export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
const { label, addLabelToGroup, editLabel, handleLabelDelete } = props;
export const SingleLabel: React.FC<Props> = ({ label, addLabelToGroup, editLabel, handleLabelDelete }) => {
const [isMenuActive, setIsMenuActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
@@ -35,7 +33,6 @@ export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
/>
<h6 className="text-sm">{label.name}</h6>
</div>
<div
ref={actionSectionRef}
className={`absolute -top-0.5 right-3 flex items-start gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 ${
+3 -6
View File
@@ -9,7 +9,8 @@ import useToast from "hooks/use-toast";
// components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
// ui
import { Avatar, AvatarGroup, CustomMenu, LayersIcon, Tooltip } from "@plane/ui";
import { AssigneesList } from "components/ui";
import { CustomMenu, LayersIcon, Tooltip } from "@plane/ui";
// icons
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
@@ -163,11 +164,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
{module.members_detail.length > 0 && (
<Tooltip tooltipContent={`${module.members_detail.length} Members`}>
<div className="flex items-center gap-1 cursor-default">
<AvatarGroup showTooltip={false}>
{module.members_detail.map((member) => (
<Avatar key={member.id} name={member.display_name} src={member.avatar} />
))}
</AvatarGroup>
<AssigneesList users={module.members_detail} length={3} />
</div>
</Tooltip>
)}
+3 -6
View File
@@ -9,7 +9,8 @@ import useToast from "hooks/use-toast";
// components
import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules";
// ui
import { Avatar, AvatarGroup, CircularProgressIndicator, CustomMenu, Tooltip } from "@plane/ui";
import { AssigneesList } from "components/ui";
import { CircularProgressIndicator, CustomMenu, Tooltip } from "@plane/ui";
// icons
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// helpers
@@ -173,11 +174,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<Tooltip tooltipContent={`${module.members_detail.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{module.members_detail.length > 0 ? (
<AvatarGroup showTooltip={false}>
{module.members_detail.map((member) => (
<Avatar key={member.id} name={member.display_name} src={member.avatar} />
))}
</AvatarGroup>
<AssigneesList users={module.members_detail} length={2} />
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
+4 -7
View File
@@ -4,7 +4,8 @@ import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, CustomSearchSelect } from "@plane/ui";
import { Avatar } from "components/ui";
import { CustomSearchSelect } from "@plane/ui";
// icons
import { UserCircle } from "lucide-react";
// fetch-keys
@@ -33,7 +34,7 @@ export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
@@ -47,11 +48,7 @@ export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
value={value}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<Avatar name={selectedOption.display_name} src={selectedOption.avatar} />
) : (
<UserCircle className="h-4 w-4 text-custom-text-200" />
)}
{selectedOption ? <Avatar user={selectedOption} /> : <UserCircle className="h-4 w-4 text-custom-text-200" />}
{selectedOption ? selectedOption?.display_name : <span className="text-custom-text-200">Lead</span>}
</div>
}
+5 -11
View File
@@ -4,7 +4,9 @@ import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, AvatarGroup, CustomSearchSelect, UserGroupIcon } from "@plane/ui";
import { AssigneesList, Avatar } from "components/ui";
// icons
import { CustomSearchSelect, UserGroupIcon } from "@plane/ui";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@@ -30,7 +32,7 @@ export const ModuleMembersSelect: React.FC<Props> = ({ value, onChange }) => {
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} />
{member.member.display_name}
</div>
),
@@ -43,15 +45,7 @@ export const ModuleMembersSelect: React.FC<Props> = ({ value, onChange }) => {
<div className="flex items-center gap-2 text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? (
<div className="flex items-center justify-center gap-2">
<AvatarGroup>
{value.map((assigneeId) => {
const member = members?.find((m) => m.member.id === assigneeId)?.member;
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
<AssigneesList userIds={value} length={3} showLength={false} />
<span className="text-custom-text-200">{value.length} Assignees</span>
</div>
) : (
@@ -4,7 +4,8 @@ import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, CustomSearchSelect } from "@plane/ui";
import { Avatar } from "components/ui";
import { CustomSearchSelect } from "@plane/ui";
// icons
import { ChevronDown, UserCircle2 } from "lucide-react";
// fetch-keys
@@ -35,7 +36,7 @@ export const SidebarLeadSelect: FC<Props> = (props) => {
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} />
<Avatar user={member.member} height="18px" width="18px" />
{member.member.display_name}
</div>
),
@@ -57,7 +58,7 @@ export const SidebarLeadSelect: FC<Props> = (props) => {
customButton={
selectedOption ? (
<div className="flex items-center justify-start gap-2 p-0.5 w-full">
<Avatar name={selectedOption.display_name} src={selectedOption.avatar} />
<Avatar user={selectedOption} />
<span className="text-sm text-custom-text-200">{selectedOption?.display_name}</span>
</div>
) : (
@@ -1,10 +1,14 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { ProjectService } from "services/project";
// ui
import { Avatar, AvatarGroup, CustomSearchSelect, UserGroupIcon } from "@plane/ui";
import { AssigneesList, Avatar } from "components/ui";
import { CustomSearchSelect, UserGroupIcon } from "@plane/ui";
// icons
import { ChevronDown } from "lucide-react";
// fetch-keys
@@ -34,7 +38,7 @@ export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar name={member?.member.display_name} src={member?.member.avatar} showTooltip={false} />
<Avatar user={member.member} height="18px" width="18px" />
{member.member.display_name}
</div>
),
@@ -53,16 +57,8 @@ export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
customButtonClassName="rounded-sm"
customButton={
value && value.length > 0 && Array.isArray(value) ? (
<div className="px-1">
<AvatarGroup showTooltip={false}>
{value.map((assigneeId) => {
const member = members?.find((m) => m.member.id === assigneeId)?.member;
if (!member) return null;
return <Avatar key={member.id} name={member.display_name} src={member.avatar} />;
})}
</AvatarGroup>
<div className="flex items-center gap-2 p-0.5 w-full">
<AssigneesList userIds={value} length={2} />
</div>
) : (
<div className="group flex items-center justify-between gap-2 p-1 text-sm text-custom-text-400 w-full">
+5 -14
View File
@@ -8,15 +8,16 @@ import { RootStore } from "store/root";
import { LinkIcon, Lock, Pencil, Star } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { DeleteProjectModal, JoinProjectModal } from "components/project";
// ui
import { Avatar, AvatarGroup, Button, Tooltip } from "@plane/ui";
import { Button, Tooltip } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import type { IProject } from "types";
// components
import { DeleteProjectModal, JoinProjectModal } from "components/project";
import { AssigneesList } from "components/ui";
export type ProjectCardProps = {
project: IProject;
@@ -176,17 +177,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
>
{projectMembersIds.length > 0 ? (
<div className="flex items-center cursor-pointer gap-2 text-custom-text-200">
<AvatarGroup showTooltip={false}>
{projectMembersIds.map((memberId) => {
const member = project.members?.find((m) => m.id === memberId);
if (!member) return null;
return (
<Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
);
})}
</AvatarGroup>
<AssigneesList userIds={projectMembersIds} length={3} showLength />
</div>
) : (
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
@@ -13,9 +13,7 @@ type Props = {
data?: any;
};
export const ConfirmProjectMemberRemove: React.FC<Props> = (props) => {
const { isOpen, onClose, data, handleDelete } = props;
const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const handleClose = () => {
@@ -91,3 +89,5 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = (props) => {
</Transition.Root>
);
};
export default ConfirmProjectMemberRemove;

Some files were not shown because too many files have changed in this diff Show More