Compare commits

...
Author SHA1 Message Date
pablohashescobar bac8aeb4ad dev: improve performance for cycle apis 2024-02-19 11:35:35 +05:30
sriram veeraghanta ce9ed6b25e Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-18 15:29:19 +05:30
10057377dc fix: improved issue description editor focus and state management (#3690)
* chore: issue input and editor reload alert issue resolved

* chore: issue description mutation issue in inbox

* fix: reload confirmation alert and stay focused after saving

* chore: updated the renderOnPropChange prop in the description-input

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-18 15:28:37 +05:30
sriram veeraghantaandGitHub eba5ed24ad fix: color pick background color on change (#3691) 2024-02-18 15:26:50 +05:30
12 changed files with 436 additions and 345 deletions
+50 -36
View File
@@ -3,10 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .issue import IssueStateSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
Cycle,
CycleIssue,
@@ -14,7 +11,6 @@ from plane.db.models import (
CycleUserProperties,
)
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if (
@@ -33,62 +29,80 @@ class CycleWriteSerializer(BaseSerializer):
class CycleSerializer(BaseSerializer):
# workspace and project ids
workspace_id = serializers.PrimaryKeyRelatedField(read_only=True)
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
owned_by_id = serializers.PrimaryKeyRelatedField(read_only=True)
# favorite
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
# state group wise distribution
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
#TODO: Remove once confirmed # estimates
# total_estimates = serializers.IntegerField(read_only=True)
# completed_estimates = serializers.IntegerField(read_only=True)
# started_estimates = serializers.IntegerField(read_only=True)
# method fields
assignees = serializers.SerializerMethodField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
# active | draft | upcoming | completed
status = serializers.CharField(read_only=True)
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError(
"Start date cannot exceed end date"
)
return data
def get_assignees(self, obj):
# Get all the members
members = [
{
"avatar": assignee.avatar,
"display_name": assignee.display_name,
"id": assignee.id,
}
{
"id": assignee.id,
"display_name": assignee.display_name,
"avatar": assignee.avatar,
}
for issue_cycle in obj.issue_cycle.prefetch_related(
"issue__assignees"
).all()
for assignee in issue_cycle.issue.assignees.all()
]
# Use a set comprehension to return only the unique objects
unique_objects = {frozenset(item.items()) for item in members}
# Convert the set back to a list of dictionaries
unique_list = [dict(item) for item in unique_objects]
unique_list = [dict(item) for item in {frozenset(item.items()) for item in members}]
return unique_list
class Meta:
model = Cycle
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"owned_by",
fields = [
# necessary fields
"id",
"workspace_id",
"project_id",
# model fields
"name",
"description",
"start_date",
"end_date",
"owned_by_id",
"view_props",
"sort_order",
"external_source",
"external_id",
"progress_snapshot",
# meta fields
"is_favorite",
"total_issues",
"cancelled_issues",
"completed_issues",
"started_issues",
"unstarted_issues",
"backlog_issues",
# "total_estimates",
# "completed_estimates",
# "started_estimates",
"assignees",
"status",
]
read_only_fields = fields
class CycleIssueSerializer(BaseSerializer):
+152 -166
View File
@@ -33,7 +33,6 @@ from plane.app.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueSerializer,
IssueStateSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
)
@@ -51,7 +50,6 @@ from plane.db.models import (
IssueAttachment,
Label,
CycleUserProperties,
IssueSubscriber,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
@@ -73,7 +71,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
def get_queryset(self):
subquery = CycleFavorite.objects.filter(
favorite_subquery = CycleFavorite.objects.filter(
user=self.request.user,
cycle_id=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
@@ -85,10 +83,24 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.annotate(is_favorite=Exists(subquery))
.select_related("project", "workspace", "owned_by")
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle",
@@ -148,29 +160,29 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
# .annotate(
# total_estimates=Sum("issue_cycle__issue__estimate_point")
# )
# .annotate(
# completed_estimates=Sum(
# "issue_cycle__issue__estimate_point",
# filter=Q(
# issue_cycle__issue__state__group="completed",
# issue_cycle__issue__archived_at__isnull=True,
# issue_cycle__issue__is_draft=False,
# ),
# )
# )
# .annotate(
# started_estimates=Sum(
# "issue_cycle__issue__estimate_point",
# filter=Q(
# issue_cycle__issue__state__group="started",
# issue_cycle__issue__archived_at__isnull=True,
# issue_cycle__issue__is_draft=False,
# ),
# )
# )
.annotate(
status=Case(
When(
@@ -190,22 +202,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__labels",
queryset=Label.objects.only(
"name", "color", "id"
).distinct(),
)
)
.order_by("-is_favorite", "name")
.distinct()
)
@@ -213,12 +209,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
@@ -230,7 +222,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
data = CycleSerializer(queryset, many=True).data
if len(data):
if data:
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=data[0]["id"],
@@ -315,13 +307,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
data[0]["distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
cycle_id=data[0]["id"],
)
)
return Response(data, status=status.HTTP_200_OK)
@@ -375,7 +367,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
and cycle.end_date < timezone.now().date()
):
if "sort_order" in request_data:
# Can only change sort order
# Can only change sort order for a completed cycle``
request_data = {
"sort_order": request_data.get(
"sort_order", cycle.sort_order
@@ -591,20 +583,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
"assignees",
"labels",
"issue_module__module",
"issue_cycle__cycle",
)
.order_by(order_by)
.filter(**filters)
.annotate(module_ids=F("issue_module__module_id"))
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@@ -621,11 +611,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
serializer = IssueSerializer(
@@ -636,7 +627,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
def create(self, request, slug, project_id, cycle_id):
issues = request.data.get("issues", [])
if not len(issues):
if not issues:
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
@@ -658,51 +649,49 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
update_cycle_issue_activity = []
record_to_create = []
records_to_update = []
cycle_issues = list(
CycleIssue.objects.filter(
~Q(cycle_id=cycle_id), issue_id__in=issues
)
)
existing_issues = [
str(cycle_issue.issue_id) for cycle_issue in cycle_issues
]
new_issues = list(set(issues) - set(existing_issues))
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
cycle_id=cycle_id,
issue_id=issue,
)
for issue in new_issues
],
batch_size=10,
)
CycleIssue.objects.bulk_create(
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
batch_size=10,
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues
for cycle_issue in cycle_issues:
# Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list
updated_records.append(cycle_issue)
# Record the update activity
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue.cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Capture Issue Activity
issue_activity.delay(
@@ -715,7 +704,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
"json", created_records
),
}
),
@@ -723,16 +712,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
issues = self.get_queryset().values_list("issue_id", flat=True)
return Response(
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
)
return Response({"message": "success"}, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
@@ -776,6 +756,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
& Q(project_id=project_id)
@@ -785,7 +766,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
| Q(start_date__gte=start_date, end_date__lte=end_date)
)
).exclude(pk=cycle_id)
if cycles.exists():
return Response(
{
@@ -909,29 +889,29 @@ class TransferCycleIssueEndpoint(BaseAPIView):
),
)
)
.annotate(
total_estimates=Sum("issue_cycle__issue__estimate_point")
)
.annotate(
completed_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_estimates=Sum(
"issue_cycle__issue__estimate_point",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
# .annotate(
# total_estimates=Sum("issue_cycle__issue__estimate_point")
# )
# .annotate(
# completed_estimates=Sum(
# "issue_cycle__issue__estimate_point",
# filter=Q(
# issue_cycle__issue__state__group="completed",
# issue_cycle__issue__archived_at__isnull=True,
# issue_cycle__issue__is_draft=False,
# ),
# )
# )
# .annotate(
# started_estimates=Sum(
# "issue_cycle__issue__estimate_point",
# filter=Q(
# issue_cycle__issue__state__group="started",
# issue_cycle__issue__archived_at__isnull=True,
# issue_cycle__issue__is_draft=False,
# ),
# )
# )
)
# Pass the new_cycle queryset to burndown_plot
@@ -942,6 +922,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
cycle_id=cycle_id,
)
# Get the assignee distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@@ -980,7 +961,22 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
@@ -1019,24 +1015,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
.order_by("label_name")
)
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None,
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": str(item["label_id"]) if item["label_id"] else None,
"label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
@@ -1058,7 +1044,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
"total_estimates": old_cycle.first().total_estimates,
"completed_estimates": old_cycle.first().completed_estimates,
"started_estimates": old_cycle.first().started_estimates,
"distribution":{
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
@@ -66,7 +66,6 @@ export const CustomThemeSelector: React.FC = observer(() => {
const handleValueChange = (val: string | undefined, onChange: any) => {
let hex = val;
// prepend a hashtag if it doesn't exist
if (val && val[0] !== "#") hex = `#${val}`;
@@ -94,7 +93,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("background"),
color: watch("text"),
}}
hasError={Boolean(errors?.background)}
@@ -120,8 +119,8 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("background"),
color: value,
backgroundColor: watch("text"),
color: watch("background"),
}}
hasError={Boolean(errors?.text)}
/>
@@ -146,7 +145,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#3f76ff"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("primary"),
color: watch("text"),
}}
hasError={Boolean(errors?.primary)}
@@ -172,7 +171,7 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
backgroundColor: watch("sidebarBackground"),
color: watch("sidebarText"),
}}
hasError={Boolean(errors?.sidebarBackground)}
@@ -200,8 +199,8 @@ export const CustomThemeSelector: React.FC = observer(() => {
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("sidebarBackground"),
color: value,
backgroundColor: watch("sidebarText"),
color: watch("sidebarBackground"),
}}
hasError={Boolean(errors?.sidebarText)}
/>
+22 -8
View File
@@ -12,12 +12,12 @@ const fileService = new FileService();
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueDescriptionInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
@@ -28,21 +28,34 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props;
// states
const [descriptionHTML, setDescriptionHTML] = useState(value);
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: typeof value === "string" && value != "" ? value : "<p></p>",
});
// store hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
const { getWorkspaceBySlug } = useWorkspace();
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(descriptionHTML, 1500);
// computed values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
useEffect(() => {
setDescriptionHTML(value);
if (value) setDescriptionHTML(value);
}, [value]);
useEffect(() => {
if (issueId && value)
setLocalIssueDescription({
id: issueId,
description_html: typeof value === "string" && value != "" ? value : "<p></p>",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issueId, value]);
useEffect(() => {
if (debouncedValue || debouncedValue === "") {
setIsSubmitting("submitted");
issueOperations
.update(workspaceSlug, projectId, issueId, { description_html: debouncedValue }, false)
.finally(() => {
@@ -79,12 +92,13 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
value={descriptionHTML}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
rerenderOnPropsChange={localIssueDescription}
// setShouldShowAlert={setShowAlert}
// setIsSubmitting={setIsSubmitting}
dragDropEnabled
customClassName="min-h-[150px] shadow-sm"
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
// setShowAlert(true);
setIsSubmitting("submitting");
setDescriptionHTML(description_html);
}}
@@ -63,6 +63,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@@ -73,6 +74,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@@ -67,6 +67,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@@ -77,6 +78,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
@@ -54,7 +54,7 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview is_archived={true} />
<IssuePeekOverview is_archived />
</Fragment>
)}
</div>
@@ -0,0 +1,153 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react";
// ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// hooks
import useToast from "hooks/use-toast";
// store hooks
import { useUser } from "hooks/store";
// components
import { IssueSubscription, IssueUpdateStatus } from "components/issues";
export type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
title: "Side Peek",
},
{
key: "modal",
icon: CenterPanelIcon,
title: "Modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
title: "Full Screen",
},
];
export type PeekOverviewHeaderProps = {
peekMode: TPeekModes;
setPeekMode: (value: TPeekModes) => void;
removeRoutePeekId: () => void;
workspaceSlug: string;
projectId: string;
issueId: string;
isArchived: boolean;
disabled: boolean;
toggleDeleteIssueModal: (value: boolean) => void;
isSubmitting: "submitting" | "submitted" | "saved";
};
export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((props) => {
const {
peekMode,
setPeekMode,
workspaceSlug,
projectId,
issueId,
isArchived,
disabled,
removeRoutePeekId,
toggleDeleteIssueModal,
isSubmitting,
} = props;
// router
const router = useRouter();
// store hooks
const { currentUser } = useUser();
// hooks
const { setToastAlert } = useToast();
// derived values
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(
`${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`,
});
removeRoutePeekId();
};
return (
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "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 flex-shrink-0 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>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key
? "text-custom-text-200"
: "text-custom-text-400 hover:text-custom-text-200"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{mode.title}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{currentUser && !isArchived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</button>
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
</div>
</div>
</div>
);
});
@@ -2,3 +2,4 @@ export * from "./issue-detail";
export * from "./properties";
export * from "./root";
export * from "./view";
export * from "./header";
@@ -1,4 +1,4 @@
import { FC, useCallback, useEffect, useState } from "react";
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
// store hooks
import { useIssueDetail, useProject, useUser } from "hooks/store";
@@ -9,7 +9,6 @@ import { TIssueOperations } from "components/issues";
import { IssueReaction } from "../issue-detail/reactions";
import { IssueTitleInput } from "../title-input";
import { IssueDescriptionInput } from "../description-input";
import { debounce } from "lodash";
interface IPeekOverviewIssueDetails {
workspaceSlug: string;
@@ -22,13 +21,15 @@ interface IPeekOverviewIssueDetails {
}
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, setIsSubmitting } = props;
const { workspaceSlug, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props;
// store hooks
const { getProjectById } = useProject();
const { currentUser } = useUser();
const {
issue: { getIssueById },
} = useIssueDetail();
// hooks
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
// derived values
const issue = getIssueById(issueId);
@@ -36,6 +37,17 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
const projectDetails = getProjectById(issue?.project_id);
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert, setIsSubmitting]);
return (
<>
<span className="text-base font-medium text-custom-text-400">
@@ -45,6 +57,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}
@@ -54,6 +67,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={disabled}
+26 -119
View File
@@ -1,28 +1,25 @@
import { FC, useRef, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useKeypress from "hooks/use-keypress";
// store hooks
import { useIssueDetail, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
import { useIssueDetail } from "hooks/store";
// components
import {
DeleteArchivedIssueModal,
DeleteIssueModal,
IssueSubscription,
IssueUpdateStatus,
IssuePeekOverviewHeader,
TPeekModes,
PeekOverviewIssueDetails,
PeekOverviewProperties,
TIssueOperations,
} from "components/issues";
import { IssueActivity } from "../issue-detail/issue-activity";
// ui
import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import { Spinner } from "@plane/ui";
interface IIssueView {
workspaceSlug: string;
@@ -34,72 +31,28 @@ interface IIssueView {
issueOperations: TIssueOperations;
}
type TPeekModes = "side-peek" | "modal" | "full-screen";
const PEEK_OPTIONS: { key: TPeekModes; icon: any; title: string }[] = [
{
key: "side-peek",
icon: SidePanelIcon,
title: "Side Peek",
},
{
key: "modal",
icon: CenterPanelIcon,
title: "Modal",
},
{
key: "full-screen",
icon: FullScreenPanelIcon,
title: "Full Screen",
},
];
export const IssueView: FC<IIssueView> = observer((props) => {
const { workspaceSlug, projectId, issueId, isLoading, is_archived, disabled = false, issueOperations } = props;
// router
const router = useRouter();
// states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
// store hooks
const { setPeekIssue, isAnyModalOpen, isDeleteIssueModalOpen, toggleDeleteIssueModal } = useIssueDetail();
const { currentUser } = useUser();
const {
setPeekIssue,
isAnyModalOpen,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
issue: { getIssueById },
} = useIssueDetail();
const { setToastAlert } = useToast();
// derived values
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const issue = getIssueById(issueId);
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
};
// hooks
useOutsideClickDetector(issuePeekOverviewRef, () => !isAnyModalOpen && removeRoutePeekId());
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/${is_archived ? "archived-issues" : "issues"}/${issueId}`,
});
removeRoutePeekId();
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(
`${workspaceSlug}/projects/${projectId}/${is_archived ? "archived-issues" : "issues"}/${issueId}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const handleKeyDown = () => !isAnyModalOpen && removeRoutePeekId();
useKeypress("Escape", handleKeyDown);
@@ -141,66 +94,20 @@ export const IssueView: FC<IIssueView> = observer((props) => {
}}
>
{/* header */}
<div
className={`relative flex items-center justify-between p-4 ${
currentMode?.key === "full-screen" ? "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 flex-shrink-0 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>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key
? "text-custom-text-200"
: "text-custom-text-400 hover:text-custom-text-200"
}`}
>
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{mode.title}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{currentUser && !is_archived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
</button>
{!disabled && (
<button onClick={() => toggleDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
</div>
</div>
</div>
<IssuePeekOverviewHeader
peekMode={peekMode}
setPeekMode={(value: TPeekModes) => {
setPeekMode(value);
}}
removeRoutePeekId={removeRoutePeekId}
toggleDeleteIssueModal={toggleDeleteIssueModal}
isArchived={is_archived}
issueId={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isSubmitting={isSubmitting}
disabled={disabled}
/>
{/* content */}
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
{isLoading && !issue ? (
+3 -4
View File
@@ -6,12 +6,12 @@ import { TextArea } from "@plane/ui";
import { TIssueOperations } from "./issue-detail";
// hooks
import useDebounce from "hooks/use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
export type IssueTitleInputProps = {
disabled?: boolean;
value: string | undefined | null;
workspaceSlug: string;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
issueOperations: TIssueOperations;
projectId: string;
@@ -23,7 +23,7 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
// states
const [title, setTitle] = useState("");
// hooks
const { setShowAlert } = useReloadConfirmations();
const debouncedValue = useDebounce(title, 1500);
useEffect(() => {
@@ -42,11 +42,10 @@ export const IssueTitleInput: FC<IssueTitleInputProps> = observer((props) => {
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setShowAlert(true);
setIsSubmitting("submitting");
setTitle(e.target.value);
},
[setIsSubmitting, setShowAlert]
[setIsSubmitting]
);
return (