Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
080fb5deea | ||
|
|
41242cc671 | ||
|
|
5989f2476a | ||
|
|
8ea6dd4e84 | ||
|
|
39bc975994 | ||
|
|
866eead35f | ||
|
|
9c3510851d | ||
|
|
81436902a3 | ||
|
|
d26aa1b2da | ||
|
|
b47c7d363f | ||
|
|
3e674751e0 | ||
|
|
85f797058d | ||
|
|
1655d0cb1c | ||
|
|
58562dc4b7 | ||
|
|
2ad46d7bfa | ||
|
|
4f0cac37db | ||
|
|
b46a7481ae | ||
|
|
f11ae00201 | ||
|
|
c5612ee7a3 | ||
|
|
0dd336aec8 | ||
|
|
4b364f72b5 | ||
|
|
6d13332818 | ||
|
|
ac4127c93d | ||
|
|
60c3d1a6e9 | ||
|
|
70ed3c1fdf | ||
|
|
b40059ea21 | ||
|
|
90276073cd | ||
|
|
8d5ff1a628 | ||
|
|
065a4a3cf7 |
@@ -1575,7 +1575,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
).order_by("created_at")
|
||||
else:
|
||||
return IssueComment.objects.none()
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
@@ -2100,6 +2100,12 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"votes",
|
||||
queryset=IssueVote.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
@@ -2189,6 +2195,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
|
||||
states = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
@@ -116,7 +116,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.objects.filter(workspace=OuterRef("id"))
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -203,7 +203,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.objects.filter(workspace=OuterRef("id"))
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -1075,7 +1075,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
|
||||
priority_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[user_id],
|
||||
project__project_projectmember__member=request.user,
|
||||
|
||||
@@ -32,7 +32,7 @@ def archive_old_issues():
|
||||
archive_in = project.archive_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the archive_in month
|
||||
issues = Issue.objects.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
@@ -64,21 +64,22 @@ def archive_old_issues():
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
updated_issues = Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
if issues_to_update:
|
||||
updated_issues = Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
for issue in updated_issues
|
||||
]
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in updated_issues
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
@@ -99,7 +100,7 @@ def close_old_issues():
|
||||
close_in = project.close_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the close_in month
|
||||
issues = Issue.objects.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
@@ -136,19 +137,20 @@ def close_old_issues():
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in updated_issues
|
||||
]
|
||||
if issues_to_update:
|
||||
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in updated_issues
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
|
||||
@@ -96,7 +96,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
completed_issues_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
@@ -118,7 +118,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
completed_issues_distribution = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_module__module_id=module_id,
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
# base requirements
|
||||
|
||||
Django==4.2.3
|
||||
Django==4.2.5
|
||||
django-braces==1.15.0
|
||||
django-taggit==4.0.0
|
||||
psycopg==3.1.9
|
||||
psycopg==3.1.10
|
||||
django-oauth-toolkit==2.3.0
|
||||
mistune==3.0.1
|
||||
djangorestframework==3.14.0
|
||||
redis==4.6.0
|
||||
django-nested-admin==4.0.2
|
||||
django-cors-headers==4.1.0
|
||||
django-cors-headers==4.2.0
|
||||
whitenoise==6.5.0
|
||||
django-allauth==0.54.0
|
||||
django-allauth==0.55.2
|
||||
faker==18.11.2
|
||||
django-filter==23.2
|
||||
jsonmodels==2.6.0
|
||||
djangorestframework-simplejwt==5.2.2
|
||||
sentry-sdk==1.27.0
|
||||
djangorestframework-simplejwt==5.3.0
|
||||
sentry-sdk==1.30.0
|
||||
django-s3-storage==0.14.0
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.21.0
|
||||
google-api-python-client==2.92.0
|
||||
google-auth==2.22.0
|
||||
google-api-python-client==2.97.0
|
||||
django-redis==5.3.0
|
||||
uvicorn==0.22.0
|
||||
uvicorn==0.23.2
|
||||
channels==4.0.0
|
||||
openai==0.27.8
|
||||
openai==0.28.0
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
||||
celery==5.3.4
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
psycopg-binary==3.1.10
|
||||
psycopg-c==3.1.10
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
@@ -1,11 +1,11 @@
|
||||
-r base.txt
|
||||
|
||||
dj-database-url==2.0.0
|
||||
gunicorn==20.1.0
|
||||
dj-database-url==2.1.0
|
||||
gunicorn==21.2.0
|
||||
whitenoise==6.5.0
|
||||
django-storages==1.13.2
|
||||
boto3==1.27.0
|
||||
django-anymail==10.0
|
||||
django-storages==1.14
|
||||
boto3==1.28.40
|
||||
django-anymail==10.1
|
||||
django-debug-toolbar==4.1.0
|
||||
gevent==23.7.0
|
||||
psycogreen==1.0.2
|
||||
@@ -8,6 +8,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"start": "turbo run start",
|
||||
|
||||
@@ -131,7 +131,7 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none px-3 py-2 text-sm`}
|
||||
>
|
||||
<span className="text-custom-text-400">{value || "Select your role..."}</span>
|
||||
<span className={value ? "" : "text-custom-text-400"}>{value || "Select your role..."}</span>
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</Listbox.Button>
|
||||
|
||||
|
||||
@@ -13,13 +13,12 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
// images
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg";
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
|
||||
export const SignInView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -34,13 +33,15 @@ export const SignInView = observer(() => {
|
||||
const onSignInSuccess = (response: any) => {
|
||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/";
|
||||
|
||||
userStore.setCurrentUser(response?.user);
|
||||
|
||||
if (!isOnboarded) {
|
||||
router.push(`/onboarding?next_path=${next_path}`);
|
||||
router.push(`/onboarding?next_path=${nextPath}`);
|
||||
return;
|
||||
}
|
||||
router.push((next_path ?? "/").toString());
|
||||
router.push((nextPath ?? "/").toString());
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
// helpers
|
||||
import { renderFullDate } from "constants/helpers";
|
||||
import { renderFullDate } from "helpers/date-time.helper";
|
||||
|
||||
export const findHowManyDaysLeft = (date: string | Date) => {
|
||||
const today = new Date();
|
||||
const eventDate = new Date(date);
|
||||
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
|
||||
|
||||
return Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
};
|
||||
|
||||
const dueDateIcon = (
|
||||
export const dueDateIconDetails = (
|
||||
date: string,
|
||||
stateGroup: string
|
||||
): {
|
||||
@@ -26,17 +18,24 @@ const dueDateIcon = (
|
||||
className = "";
|
||||
} else {
|
||||
const today = new Date();
|
||||
const dueDate = new Date(date);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const targetDate = new Date(date);
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dueDate < today) {
|
||||
const timeDifference = targetDate.getTime() - today.getTime();
|
||||
|
||||
if (timeDifference < 0) {
|
||||
iconName = "event_busy";
|
||||
className = "text-red-500";
|
||||
} else if (dueDate > today) {
|
||||
iconName = "calendar_today";
|
||||
className = "";
|
||||
} else {
|
||||
} else if (timeDifference === 0) {
|
||||
iconName = "today";
|
||||
className = "text-red-500";
|
||||
} else if (timeDifference === 24 * 60 * 60 * 1000) {
|
||||
iconName = "event";
|
||||
className = "text-yellow-500";
|
||||
} else {
|
||||
iconName = "calendar_today";
|
||||
className = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +46,7 @@ const dueDateIcon = (
|
||||
};
|
||||
|
||||
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
|
||||
const iconDetails = dueDateIcon(due_date, group);
|
||||
const iconDetails = dueDateIconDetails(due_date, group);
|
||||
|
||||
return (
|
||||
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs">
|
||||
|
||||
+11
-5
@@ -1,17 +1,22 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import React, { useState } from "react";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// react-hook-form
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { CommentReactions } from "components/issues/peek-overview";
|
||||
// icons
|
||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { Comment } from "types/issue";
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -76,7 +81,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||
<>Commented {timeAgo(comment.created_at)}</>
|
||||
<>commented {timeAgo(comment.created_at)}</>
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
@@ -125,6 +130,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
editable={false}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
<CommentReactions commentId={comment.id} projectId={comment.project} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { ReactionSelector, Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
|
||||
|
||||
type Props = {
|
||||
commentId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const CommentReactions: React.FC<Props> = observer((props) => {
|
||||
const { commentId, projectId } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
const { issueDetails: issueDetailsStore, user: userStore } = useMobxStore();
|
||||
|
||||
const peekId = issueDetailsStore.peekId;
|
||||
const user = userStore.currentUser;
|
||||
|
||||
const commentReactions = peekId
|
||||
? issueDetailsStore.details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions
|
||||
: [];
|
||||
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
|
||||
|
||||
const userReactions = commentReactions?.filter((r) => r.actor_detail.id === user?.id);
|
||||
|
||||
const handleAddReaction = (reactionHex: string) => {
|
||||
if (!workspace_slug || !projectId || !peekId) return;
|
||||
|
||||
issueDetailsStore.addCommentReaction(
|
||||
workspace_slug.toString(),
|
||||
projectId.toString(),
|
||||
peekId,
|
||||
commentId,
|
||||
reactionHex
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveReaction = (reactionHex: string) => {
|
||||
if (!workspace_slug || !projectId || !peekId) return;
|
||||
|
||||
issueDetailsStore.removeCommentReaction(
|
||||
workspace_slug.toString(),
|
||||
projectId.toString(),
|
||||
peekId,
|
||||
commentId,
|
||||
reactionHex
|
||||
);
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
|
||||
if (userReaction) handleRemoveReaction(reactionHex);
|
||||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5 items-center mt-2">
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(value);
|
||||
});
|
||||
}}
|
||||
position="top"
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
||||
const reactions = groupedReactions?.[reaction] ?? [];
|
||||
const REACTIONS_LIMIT = 1000;
|
||||
|
||||
if (reactions.length > 0)
|
||||
return (
|
||||
<Tooltip
|
||||
key={reaction}
|
||||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.splice(0, REACTIONS_LIMIT)
|
||||
.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionClick(reaction);
|
||||
});
|
||||
}}
|
||||
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
|
||||
commentReactions?.some(
|
||||
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
|
||||
)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
commentReactions?.some(
|
||||
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
|
||||
)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./add-comment";
|
||||
export * from "./comment-detail-card";
|
||||
export * from "./comment-reactions";
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
@@ -41,7 +43,7 @@ const peekModes: {
|
||||
},
|
||||
];
|
||||
|
||||
export const PeekOverviewHeader: React.FC<Props> = (props) => {
|
||||
export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, issueDetails } = props;
|
||||
|
||||
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
|
||||
@@ -137,4 +139,4 @@ export const PeekOverviewHeader: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./comment";
|
||||
export * from "./full-screen-peek-view";
|
||||
export * from "./header";
|
||||
export * from "./issue-activity";
|
||||
@@ -8,5 +9,3 @@ export * from "./side-peek-view";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./issue-vote-reactions";
|
||||
export * from "./issue-emoji-reactions";
|
||||
export * from "./comment-detail-card";
|
||||
export * from "./add-comment";
|
||||
|
||||
@@ -20,18 +20,27 @@ export const IssueEmojiReactions: React.FC = observer(() => {
|
||||
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
||||
const handleReactionSelectClick = (reactionHex: string) => {
|
||||
const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id);
|
||||
|
||||
const handleAddReaction = (reactionHex: string) => {
|
||||
if (!workspace_slug || !project_slug || !issueId) return;
|
||||
const userReaction = reactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
if (userReaction) return;
|
||||
|
||||
issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const handleRemoveReaction = (reactionHex: string) => {
|
||||
if (!workspace_slug || !project_slug || !issueId) return;
|
||||
|
||||
issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
|
||||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
|
||||
if (userReaction) handleRemoveReaction(reactionHex);
|
||||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
userStore.fetchCurrentUser();
|
||||
@@ -42,9 +51,10 @@ export const IssueEmojiReactions: React.FC = observer(() => {
|
||||
<ReactionSelector
|
||||
onSelect={(value) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleReactionSelectClick(value);
|
||||
handleReactionClick(value);
|
||||
});
|
||||
}}
|
||||
selected={userReactions?.map((r) => r.reaction)}
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { Icon } from "components/ui";
|
||||
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// helpers
|
||||
import { renderDateFormat } from "constants/helpers";
|
||||
import { copyTextToClipboard, addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { renderFullDate } from "helpers/date-time.helper";
|
||||
import { dueDateIconDetails } from "../board-views/block-due-date";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
import { IPeekMode } from "store/issue_details";
|
||||
@@ -16,35 +17,16 @@ type Props = {
|
||||
mode?: IPeekMode;
|
||||
};
|
||||
|
||||
const validDate = (date: any, state: string): string => {
|
||||
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
|
||||
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
|
||||
else {
|
||||
const today = new Date();
|
||||
const dueDate = new Date(date);
|
||||
|
||||
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
|
||||
else return `bg-green-500/10 text-green-500 border-green-500/50`;
|
||||
}
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mode }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const startDate = issueDetails.start_date;
|
||||
const targetDate = issueDetails.target_date;
|
||||
|
||||
const minDate = startDate ? new Date(startDate) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const state = issueDetails.state_detail;
|
||||
const stateGroup = issueGroupFilter(state.group);
|
||||
|
||||
const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null;
|
||||
|
||||
const dueDateIcon = dueDateIconDetails(issueDetails.target_date, state.group);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const urlToCopy = window.location.href;
|
||||
|
||||
@@ -125,11 +107,11 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
|
||||
</div>
|
||||
<div>
|
||||
{issueDetails.target_date ? (
|
||||
<div
|
||||
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
|
||||
${validDate(issueDetails.target_date, state)}`}
|
||||
>
|
||||
{renderDateFormat(issueDetails.target_date)}
|
||||
<div className="h-6 rounded flex items-center gap-1 px-2.5 py-1 border border-custom-border-100 text-custom-text-100 text-xs bg-custom-background-80">
|
||||
<span className={`material-symbols-rounded text-sm -my-0.5 ${dueDateIcon.className}`}>
|
||||
{dueDateIcon.iconName}
|
||||
</span>
|
||||
{renderFullDate(issueDetails.target_date)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-custom-text-200">Empty</span>
|
||||
|
||||
@@ -12,13 +12,14 @@ import { Icon } from "components/ui";
|
||||
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
|
||||
|
||||
interface Props {
|
||||
size?: "sm" | "md" | "lg";
|
||||
position?: "top" | "bottom";
|
||||
onSelect: (emoji: string) => void;
|
||||
position?: "top" | "bottom";
|
||||
selected?: string[];
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const ReactionSelector: React.FC<Props> = (props) => {
|
||||
const { onSelect, position, size } = props;
|
||||
const { onSelect, position, selected = [], size } = props;
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
@@ -51,7 +52,7 @@ export const ReactionSelector: React.FC<Props> = (props) => {
|
||||
position === "top" ? "-top-12" : "-bottom-12"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1">
|
||||
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 shadow-custom-shadow-sm rounded-md p-1">
|
||||
<div className="flex gap-x-1">
|
||||
{reactionEmojis.map((emoji) => (
|
||||
<button
|
||||
@@ -61,7 +62,9 @@ export const ReactionSelector: React.FC<Props> = (props) => {
|
||||
onSelect(emoji);
|
||||
closePopover();
|
||||
}}
|
||||
className="flex select-none items-center justify-between rounded-md text-sm p-1 hover:bg-custom-sidebar-background-90"
|
||||
className={`grid place-items-center select-none rounded-md text-sm p-1 ${
|
||||
selected.includes(emoji) ? "bg-custom-primary-100/10" : "hover:bg-custom-sidebar-background-80"
|
||||
}`}
|
||||
>
|
||||
{renderEmoji(emoji)}
|
||||
</button>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export const renderDateFormat = (date: string | Date | null) => {
|
||||
if (!date) return "N/A";
|
||||
|
||||
var d = new Date(date),
|
||||
month = "" + (d.getMonth() + 1),
|
||||
day = "" + d.getDate(),
|
||||
year = d.getFullYear();
|
||||
|
||||
if (month.length < 2) month = "0" + month;
|
||||
if (day.length < 2) day = "0" + day;
|
||||
|
||||
return [year, month, day].join("-");
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns date and month, if date is of the current year
|
||||
* @description Returns date, month adn year, if date is of a different year than current
|
||||
* @param {string} date
|
||||
* @example renderFullDate("2023-01-01") // 1 Jan
|
||||
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
|
||||
*/
|
||||
|
||||
export const renderFullDate = (date: string): string => {
|
||||
if (!date) return "";
|
||||
|
||||
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const currentDate: Date = new Date();
|
||||
const [year, month, day]: number[] = date.split("-").map(Number);
|
||||
|
||||
const formattedMonth: string = months[month - 1];
|
||||
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
|
||||
|
||||
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
|
||||
else return `${formattedDay} ${formattedMonth}, ${year}`;
|
||||
};
|
||||
@@ -12,3 +12,26 @@ export const timeAgo = (time: any) => {
|
||||
time = +new Date();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns date and month, if date is of the current year
|
||||
* @description Returns date, month adn year, if date is of a different year than current
|
||||
* @param {string} date
|
||||
* @example renderFullDate("2023-01-01") // 1 Jan
|
||||
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
|
||||
*/
|
||||
|
||||
export const renderFullDate = (date: string): string => {
|
||||
if (!date) return "";
|
||||
|
||||
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const currentDate: Date = new Date();
|
||||
const [year, month, day]: number[] = date.split("-").map(Number);
|
||||
|
||||
const formattedMonth: string = months[month - 1];
|
||||
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
|
||||
|
||||
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
|
||||
else return `${formattedDay} ${formattedMonth}, ${year}`;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
// assets
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg";
|
||||
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="276.000000pt" height="276.000000pt" viewBox="0 0 276.000000 276.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,276.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M930 2300 l0 -450 460 0 460 0 0 -460 0 -460 450 0 450 0 0 910 0
|
||||
910 -910 0 -910 0 0 -450z"/>
|
||||
<path d="M10 1380 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
|
||||
<path d="M930 460 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 690 B |
@@ -93,16 +93,6 @@ class IssueService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise<any> {
|
||||
return this.get(
|
||||
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
|
||||
return this.post(
|
||||
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`,
|
||||
@@ -140,6 +130,39 @@ class IssueService extends APIService {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async createCommentReaction(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
data: {
|
||||
reaction: string;
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.post(
|
||||
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`,
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCommentReaction(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
commentId: string,
|
||||
reactionHex: string
|
||||
): Promise<any> {
|
||||
return this.delete(
|
||||
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/${reactionHex}/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default IssueService;
|
||||
|
||||
+136
-22
@@ -32,6 +32,20 @@ export interface IIssueDetailStore {
|
||||
data: any
|
||||
) => Promise<any>;
|
||||
deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void;
|
||||
addCommentReaction: (
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
reactionHex: string
|
||||
) => void;
|
||||
removeCommentReaction: (
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
reactionHex: string
|
||||
) => void;
|
||||
// issue reactions
|
||||
addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
|
||||
removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
|
||||
@@ -61,8 +75,17 @@ class IssueDetailStore implements IssueDetailStore {
|
||||
details: observable.ref,
|
||||
// actions
|
||||
setPeekId: action,
|
||||
fetchIssueDetails: action,
|
||||
setPeekMode: action,
|
||||
fetchIssueDetails: action,
|
||||
addIssueComment: action,
|
||||
updateIssueComment: action,
|
||||
deleteIssueComment: action,
|
||||
addCommentReaction: action,
|
||||
removeCommentReaction: action,
|
||||
addIssueReaction: action,
|
||||
removeIssueReaction: action,
|
||||
addIssueVote: action,
|
||||
removeIssueVote: action,
|
||||
});
|
||||
this.issueService = new IssueService();
|
||||
this.rootStore = _rootStore;
|
||||
@@ -131,29 +154,32 @@ class IssueDetailStore implements IssueDetailStore {
|
||||
data: any
|
||||
) => {
|
||||
try {
|
||||
const issueCommentUpdateResponse = await this.issueService.updateIssueComment(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
commentId,
|
||||
data
|
||||
);
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: this.details[issueId].comments.map((c) => ({
|
||||
...c,
|
||||
...(c.id === commentId ? data : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (issueCommentUpdateResponse) {
|
||||
const remainingComments = this.details[issueId].comments.filter((com) => com.id != commentId);
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: [...remainingComments, issueCommentUpdateResponse],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return issueCommentUpdateResponse;
|
||||
await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data);
|
||||
} catch (error) {
|
||||
console.log("Failed to add issue comment");
|
||||
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: issueComments,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,6 +201,94 @@ class IssueDetailStore implements IssueDetailStore {
|
||||
}
|
||||
};
|
||||
|
||||
addCommentReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
reactionHex: string
|
||||
) => {
|
||||
const newReaction = {
|
||||
id: uuidv4(),
|
||||
comment: commentId,
|
||||
reaction: reactionHex,
|
||||
actor_detail: this.rootStore.user.currentActor,
|
||||
};
|
||||
const newComments = this.details[issueId].comments.map((comment) => ({
|
||||
...comment,
|
||||
comment_reactions:
|
||||
comment.id === commentId ? [...comment.comment_reactions, newReaction] : comment.comment_reactions,
|
||||
}));
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: [...newComments],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.issueService.createCommentReaction(workspaceSlug, projectId, commentId, {
|
||||
reaction: reactionHex,
|
||||
});
|
||||
} catch (error) {
|
||||
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: issueComments,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
removeCommentReaction = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
reactionHex: string
|
||||
) => {
|
||||
try {
|
||||
const comment = this.details[issueId].comments.find((c) => c.id === commentId);
|
||||
const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? [];
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: this.details[issueId].comments.map((c) => ({
|
||||
...c,
|
||||
comment_reactions: c.id === commentId ? newCommentReactions : c.comment_reactions,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.issueService.deleteCommentReaction(workspaceSlug, projectId, commentId, reactionHex);
|
||||
} catch (error) {
|
||||
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueId]: {
|
||||
...this.details[issueId],
|
||||
comments: issueComments,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
|
||||
+4
-8
@@ -62,17 +62,13 @@ class UserStore implements IUserStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
this.fetchCurrentUser()
|
||||
.then(() => {
|
||||
if (!this.currentUser) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `/?next_path=${currentPath}`;
|
||||
} else callback();
|
||||
if (!this.currentUser) window.location.href = `/?next_path=${currentPath}`;
|
||||
else callback();
|
||||
})
|
||||
.catch(() => {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `/?next_path=${currentPath}`;
|
||||
});
|
||||
.catch(() => (window.location.href = `/?next_path=${currentPath}`));
|
||||
};
|
||||
|
||||
fetchCurrentUser = async () => {
|
||||
|
||||
+21
-16
@@ -68,25 +68,30 @@ export interface IVote {
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
actor_detail: ActorDetail;
|
||||
issue_detail: IssueDetail;
|
||||
project_detail: ProjectDetail;
|
||||
workspace_detail: WorkspaceDetail;
|
||||
comment_reactions: any[];
|
||||
is_member: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
comment_stripped: string;
|
||||
comment_html: string;
|
||||
attachments: any[];
|
||||
access: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
issue: string;
|
||||
actor: string;
|
||||
attachments: any[];
|
||||
comment_html: string;
|
||||
comment_reactions: {
|
||||
actor_detail: ActorDetail;
|
||||
comment: string;
|
||||
id: string;
|
||||
reaction: string;
|
||||
}[];
|
||||
comment_stripped: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
id: string;
|
||||
is_member: boolean;
|
||||
issue: string;
|
||||
issue_detail: IssueDetail;
|
||||
project: string;
|
||||
project_detail: ProjectDetail;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
workspace_detail: WorkspaceDetail;
|
||||
}
|
||||
|
||||
export interface IIssueReaction {
|
||||
|
||||
@@ -212,7 +212,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
workspace level
|
||||
Workspace Level
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ReactionSelector: React.FC<Props> = (props) => {
|
||||
position === "top" ? "-top-12" : "-bottom-12"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1">
|
||||
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 shadow-custom-shadow-sm rounded-md p-1">
|
||||
<div className="flex gap-x-1">
|
||||
{reactionEmojis.map((emoji) => (
|
||||
<button
|
||||
|
||||
@@ -63,7 +63,9 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
|
||||
const blockFormat = (blocks: ICycle[]) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.end_date)
|
||||
.filter(
|
||||
(b) => b.start_date && b.end_date && new Date(b.start_date) <= new Date(b.end_date)
|
||||
)
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { IGanttBlock } from "components/gantt-chart";
|
||||
|
||||
export const renderIssueBlocksStructure = (blocks: IIssue[]): IGanttBlock[] =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.target_date ?? ""),
|
||||
}))
|
||||
? blocks
|
||||
.filter((b) => new Date(b?.start_date ?? "") <= new Date(b?.target_date ?? ""))
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.target_date ?? ""),
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -38,7 +38,7 @@ export const InboxIssueActivity: React.FC<Props> = ({ issueDetails }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxIssueId) return;
|
||||
|
||||
await issuesService
|
||||
@@ -46,8 +46,8 @@ export const InboxIssueActivity: React.FC<Props> = ({ issueDetails }) => {
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
inboxIssueId as string,
|
||||
comment.id,
|
||||
comment,
|
||||
commentId,
|
||||
data,
|
||||
user
|
||||
)
|
||||
.then(() => mutateIssueActivity());
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
|
||||
import getConfig from "next/config";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
@@ -33,13 +31,11 @@ export const SelectRepository: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
|
||||
const getKey = (pageIndex: number) => {
|
||||
if (!workspaceSlug || !integration) return;
|
||||
|
||||
return `${
|
||||
NEXT_PUBLIC_API_BASE_URL
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
}/api/workspaces/${workspaceSlug}/workspace-integrations/${
|
||||
integration.id
|
||||
}/github-repositories/?page=${++pageIndex}`;
|
||||
|
||||
@@ -15,14 +15,16 @@ import { IIssueActivity, IIssueComment } from "types";
|
||||
|
||||
type Props = {
|
||||
activity: IIssueActivity[] | undefined;
|
||||
handleCommentUpdate: (comment: IIssueComment) => Promise<void>;
|
||||
handleCommentUpdate: (commentId: string, data: Partial<IIssueComment>) => Promise<void>;
|
||||
handleCommentDelete: (commentId: string) => Promise<void>;
|
||||
showAccessSpecifier?: boolean;
|
||||
};
|
||||
|
||||
export const IssueActivitySection: React.FC<Props> = ({
|
||||
activity,
|
||||
handleCommentUpdate,
|
||||
handleCommentDelete,
|
||||
showAccessSpecifier = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@@ -131,10 +133,11 @@ export const IssueActivitySection: React.FC<Props> = ({
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-4">
|
||||
<CommentCard
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
comment={activityItem as IIssueComment}
|
||||
onSubmit={handleCommentUpdate}
|
||||
handleCommentDeletion={handleCommentDelete}
|
||||
onSubmit={handleCommentUpdate}
|
||||
showAccessSpecifier={showAccessSpecifier}
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/rea
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { CustomMenu, Icon } from "components/ui";
|
||||
import { CommentReaction } from "components/issues";
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
// helpers
|
||||
@@ -16,17 +16,19 @@ import { timeAgo } from "helpers/date-time.helper";
|
||||
import type { IIssueComment } from "types";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
comment: IIssueComment;
|
||||
onSubmit: (comment: IIssueComment) => void;
|
||||
handleCommentDeletion: (comment: string) => void;
|
||||
onSubmit: (commentId: string, data: Partial<IIssueComment>) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const CommentCard: React.FC<Props> = ({
|
||||
comment,
|
||||
workspaceSlug,
|
||||
onSubmit,
|
||||
handleCommentDeletion,
|
||||
onSubmit,
|
||||
showAccessSpecifier = false,
|
||||
workspaceSlug,
|
||||
}) => {
|
||||
const { user } = useUser();
|
||||
|
||||
@@ -45,11 +47,11 @@ export const CommentCard: React.FC<Props> = ({
|
||||
defaultValues: comment,
|
||||
});
|
||||
|
||||
const onEnter = (formData: IIssueComment) => {
|
||||
const onEnter = (formData: Partial<IIssueComment>) => {
|
||||
if (isSubmitting) return;
|
||||
setIsEditing(false);
|
||||
|
||||
onSubmit(formData);
|
||||
onSubmit(comment.id, formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData.comment_html);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||
@@ -99,7 +101,7 @@ export const CommentCard: React.FC<Props> = ({
|
||||
: comment.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||
Commented {timeAgo(comment.created_at)}
|
||||
commented {timeAgo(comment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
@@ -137,7 +139,15 @@ export const CommentCard: React.FC<Props> = ({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<div className={`relative ${isEditing ? "hidden" : ""}`}>
|
||||
{showAccessSpecifier && (
|
||||
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
|
||||
<Icon
|
||||
iconName={comment.access === "INTERNAL" ? "lock" : "public"}
|
||||
className="!text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={showEditorRef}
|
||||
@@ -151,13 +161,44 @@ export const CommentCard: React.FC<Props> = ({
|
||||
</div>
|
||||
{user?.id === comment.actor && (
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setIsEditing(true)}>Edit</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Icon iconName="edit" />
|
||||
Edit comment
|
||||
</CustomMenu.MenuItem>
|
||||
{showAccessSpecifier && (
|
||||
<>
|
||||
{comment.access === "INTERNAL" ? (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Icon iconName="public" />
|
||||
Switch to public comment
|
||||
</CustomMenu.MenuItem>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Icon iconName="lock" />
|
||||
Switch to private comment
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleCommentDeletion(comment.id);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Delete
|
||||
<Icon iconName="delete" />
|
||||
Delete comment
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
await issuesService
|
||||
@@ -85,8 +85,8 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
comment.id,
|
||||
comment,
|
||||
commentId,
|
||||
data,
|
||||
user
|
||||
)
|
||||
.then(() => mutateIssueActivity());
|
||||
@@ -222,6 +222,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
activity={issueActivity}
|
||||
handleCommentUpdate={handleCommentUpdate}
|
||||
handleCommentDelete={handleCommentDelete}
|
||||
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
||||
/>
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
workspace level
|
||||
Workspace Level
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,6 +5,7 @@ import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { AddComment, IssueActivitySection } from "components/issues";
|
||||
// types
|
||||
@@ -22,6 +23,7 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { user } = useUser();
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
|
||||
workspaceSlug && issue ? PROJECT_ISSUES_ACTIVITY(issue.id) : null,
|
||||
@@ -30,18 +32,11 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: IIssueComment) => {
|
||||
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueComment>) => {
|
||||
if (!workspaceSlug || !issue) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssueComment(
|
||||
workspaceSlug as string,
|
||||
issue.project,
|
||||
issue.id,
|
||||
comment.id,
|
||||
comment,
|
||||
user
|
||||
)
|
||||
.patchIssueComment(workspaceSlug as string, issue.project, issue.id, commentId, data, user)
|
||||
.then(() => mutateIssueActivity());
|
||||
};
|
||||
|
||||
@@ -80,9 +75,13 @@ export const PeekOverviewIssueActivity: React.FC<Props> = ({ workspaceSlug, issu
|
||||
activity={issueActivity}
|
||||
handleCommentUpdate={handleCommentUpdate}
|
||||
handleCommentDelete={handleCommentDelete}
|
||||
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<AddComment onSubmit={handleAddComment} />
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,10 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
|
||||
const blockFormat = (blocks: IModule[]) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.target_date)
|
||||
.filter(
|
||||
(b) =>
|
||||
b.start_date && b.target_date && new Date(b.start_date) <= new Date(b.target_date)
|
||||
)
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui components
|
||||
import getConfig from "next/config";
|
||||
import {
|
||||
ToggleSwitch,
|
||||
PrimaryButton,
|
||||
@@ -64,11 +63,9 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
|
||||
const [isUnpublishing, setIsUnpublishing] = useState(false);
|
||||
const [isUpdateRequired, setIsUpdateRequired] = useState(false);
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_DEPLOY_URL } } = getConfig();
|
||||
const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000";
|
||||
|
||||
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000";
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
@@ -32,122 +32,122 @@ export const TiptapExtensions = (
|
||||
workspaceSlug: string,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
},
|
||||
},
|
||||
},
|
||||
code: {
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
},
|
||||
gapcursor: false,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
|
||||
const attributes = {};
|
||||
const { tr } = state;
|
||||
const start = range.from;
|
||||
const end = range.to;
|
||||
// @ts-ignore
|
||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mb-6 border-t border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
Gapcursor,
|
||||
TiptapLink.configure({
|
||||
protocols: ["http", "https"],
|
||||
validate: (url) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
},
|
||||
gapcursor: false,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
}),
|
||||
UpdatedImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const attributes = {};
|
||||
const { tr } = state;
|
||||
const start = range.from;
|
||||
const end = range.to;
|
||||
// @ts-ignore
|
||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mb-6 border-t border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
Gapcursor,
|
||||
TiptapLink.configure({
|
||||
protocols: ["http", "https"],
|
||||
validate: (url) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
UpdatedImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
UniqueID.configure({
|
||||
types: ["image"],
|
||||
}),
|
||||
SlashCommand(workspaceSlug, setIsSubmitting),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
];
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
UniqueID.configure({
|
||||
types: ["image"],
|
||||
}),
|
||||
SlashCommand(workspaceSlug, setIsSubmitting),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
];
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import fileService from "services/file.service";
|
||||
|
||||
const deleteKey = new PluginKey("delete-image");
|
||||
const IMAGE_NODE_TYPE = "image";
|
||||
|
||||
const TrackImageDeletionPlugin = () =>
|
||||
interface ImageNode extends ProseMirrorNode {
|
||||
attrs: {
|
||||
src: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const TrackImageDeletionPlugin = (): Plugin =>
|
||||
new Plugin({
|
||||
key: deleteKey,
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const newImageSources = new Set();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
||||
newImageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
if (!transaction.docChanged) return;
|
||||
|
||||
const removedImages: ProseMirrorNode[] = [];
|
||||
const removedImages: ImageNode[] = [];
|
||||
|
||||
oldState.doc.descendants((oldNode, oldPos) => {
|
||||
if (oldNode.type.name !== "image") return;
|
||||
|
||||
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
|
||||
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
|
||||
if (!newState.doc.resolve(oldPos).parent) return;
|
||||
|
||||
const newNode = newState.doc.nodeAt(oldPos);
|
||||
|
||||
// Check if the node has been deleted or replaced
|
||||
if (!newNode || newNode.type.name !== "image") {
|
||||
// Check if the node still exists elsewhere in the document
|
||||
let nodeExists = false;
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.attrs.id === oldNode.attrs.id) {
|
||||
nodeExists = true;
|
||||
}
|
||||
});
|
||||
if (!nodeExists) {
|
||||
removedImages.push(oldNode as ProseMirrorNode);
|
||||
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
|
||||
if (!newImageSources.has(oldNode.attrs.src)) {
|
||||
removedImages.push(oldNode as ImageNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
removedImages.forEach((node) => {
|
||||
removedImages.forEach(async (node) => {
|
||||
const src = node.attrs.src;
|
||||
onNodeDeleted(src);
|
||||
await onNodeDeleted(src);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,10 +55,14 @@ const TrackImageDeletionPlugin = () =>
|
||||
|
||||
export default TrackImageDeletionPlugin;
|
||||
|
||||
async function onNodeDeleted(src: string) {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
|
||||
if (resStatus === 204) {
|
||||
console.log("Image deleted successfully");
|
||||
async function onNodeDeleted(src: string): Promise<void> {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
|
||||
if (resStatus === 204) {
|
||||
console.log("Image deleted successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting image: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck
|
||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
||||
import fileService from "services/file.service";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
|
||||
// icons
|
||||
import { Send } from "lucide-react";
|
||||
|
||||
// ui
|
||||
import { Icon, SecondaryButton, Tooltip, PrimaryButton } from "components/ui";
|
||||
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
comment_html: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
onSubmit: (data: IIssueComment) => Promise<void>;
|
||||
};
|
||||
|
||||
const commentAccess = [
|
||||
{
|
||||
icon: "lock",
|
||||
key: "INTERNAL",
|
||||
label: "Private",
|
||||
},
|
||||
{
|
||||
icon: "public",
|
||||
key: "EXTERNAL",
|
||||
label: "Public",
|
||||
},
|
||||
];
|
||||
|
||||
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const showAccessSpecifier = projectDetails?.is_deployed;
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
|
||||
const handleAddComment = async (formData: IIssueComment) => {
|
||||
if (!formData.comment_html || isSubmitting) return;
|
||||
|
||||
await onSubmit(formData).then(() => {
|
||||
reset(defaultValues);
|
||||
editorRef.current?.clearEditor();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full flex gap-x-2" onSubmit={handleSubmit(handleAddComment)}>
|
||||
<div className="relative flex-grow">
|
||||
{showAccessSpecifier && (
|
||||
<div className="absolute bottom-2 left-3 z-[1]">
|
||||
<Controller
|
||||
control={control}
|
||||
name="access"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
||||
{commentAccess.map((access) => (
|
||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(access.key)}
|
||||
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
|
||||
value === access.key ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
iconName={access.icon}
|
||||
className={`w-4 h-4 -mt-1 ${
|
||||
value === access.key ? "!text-custom-text-100" : "!text-custom-text-400"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
value={!value || value === "" ? "<p></p>" : value}
|
||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline">
|
||||
<PrimaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
|
||||
<Send className="w-4 h-4" />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
// react
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react hooks form
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// ui
|
||||
import { PrimaryButton, Input } from "components/ui";
|
||||
|
||||
// types
|
||||
import type { linkDetails, IIssueLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data?: linkDetails;
|
||||
links?: linkDetails[];
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
|
||||
const { isOpen, data, links, onSuccess } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
reset({
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen)
|
||||
reset({
|
||||
title: "",
|
||||
url: "",
|
||||
});
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const onSubmit = async (formData: IIssueLink) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
if (!data)
|
||||
await issuesService
|
||||
.createIssueLink(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
payload
|
||||
)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(ISSUE_DETAILS(issueId.toString()));
|
||||
})
|
||||
.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.",
|
||||
});
|
||||
});
|
||||
else {
|
||||
const updatedLinks = links?.map((l) =>
|
||||
l.id === data.id
|
||||
? {
|
||||
...l,
|
||||
title: formData.title,
|
||||
url: formData.url,
|
||||
}
|
||||
: l
|
||||
);
|
||||
|
||||
mutate(
|
||||
ISSUE_DETAILS(issueId.toString()),
|
||||
(prevData) => ({ ...prevData, issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.updateIssueLink(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
data!.id,
|
||||
payload
|
||||
)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(ISSUE_DETAILS(issueId.toString()));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<Input
|
||||
id="url"
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
autoComplete="off"
|
||||
error={errors.url}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="title"
|
||||
label="Title (optional)"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
autoComplete="off"
|
||||
error={errors.title}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
|
||||
>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./web-view-modal";
|
||||
export * from "./select-state";
|
||||
export * from "./select-priority";
|
||||
export * from "./issue-web-view-form";
|
||||
export * from "./label";
|
||||
export * from "./sub-issues";
|
||||
export * from "./issue-attachments";
|
||||
export * from "./issue-properties-detail";
|
||||
export * from "./issue-link-list";
|
||||
export * from "./create-update-link-form";
|
||||
export * from "./issue-activity";
|
||||
export * from "./select-assignee";
|
||||
export * from "./select-estimate";
|
||||
export * from "./add-comment";
|
||||
@@ -0,0 +1,233 @@
|
||||
// react
|
||||
import React from "react";
|
||||
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// fetch key
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// components
|
||||
import { Label, AddComment } from "components/web-view";
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
|
||||
// ui
|
||||
import { Icon } from "components/ui";
|
||||
|
||||
// types
|
||||
import type { IIssue, IIssueComment } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const IssueActivity: React.FC<Props> = (props) => {
|
||||
const { issueDetails, allowed } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: issueActivities, mutate: mutateIssueActivity } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.getIssueActivities(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: any) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
comment.id,
|
||||
comment,
|
||||
user
|
||||
)
|
||||
.then(() => mutateIssueActivity());
|
||||
};
|
||||
|
||||
const handleCommentDelete = async (commentId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||
|
||||
await issuesService
|
||||
.deleteIssueComment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
commentId,
|
||||
user
|
||||
)
|
||||
.then(() => mutateIssueActivity());
|
||||
};
|
||||
|
||||
const handleAddComment = async (formData: IIssueComment) => {
|
||||
if (!workspaceSlug || !issueDetails) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueComment(
|
||||
workspaceSlug.toString(),
|
||||
issueDetails.project,
|
||||
issueDetails.id,
|
||||
formData,
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Comment could not be posted. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>Activity</Label>
|
||||
<div className="mt-1 space-y-[6px] p-2 border rounded-[4px]">
|
||||
<ul role="list" className="-mb-4">
|
||||
{issueActivities?.map((activityItem, index) => {
|
||||
// determines what type of action is performed
|
||||
const message = activityItem.field ? (
|
||||
<ActivityMessage activity={activityItem} />
|
||||
) : (
|
||||
"created the issue."
|
||||
);
|
||||
|
||||
if ("field" in activityItem && activityItem.field !== "updated_by") {
|
||||
return (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-1">
|
||||
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex items-start space-x-2">
|
||||
<div>
|
||||
<div className="relative px-1.5">
|
||||
<div className="mt-1.5">
|
||||
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<Icon
|
||||
iconName="history"
|
||||
className="text-sm text-custom-text-200"
|
||||
/>
|
||||
) : (
|
||||
<ActivityIcon activity={activityItem} />
|
||||
)
|
||||
) : activityItem.actor_detail.avatar &&
|
||||
activityItem.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={activityItem.actor_detail.avatar}
|
||||
alt={activityItem.actor_detail.display_name}
|
||||
height={24}
|
||||
width={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
|
||||
>
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name.charAt(0)
|
||||
: activityItem.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-custom-text-200 break-words">
|
||||
{activityItem.field === "archived_at" &&
|
||||
activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : activityItem.actor_detail.is_bot ? (
|
||||
<span className="text-gray font-medium">
|
||||
{activityItem.actor_detail.first_name} Bot
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
|
||||
>
|
||||
<a className="text-gray font-medium">
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name
|
||||
: activityItem.actor_detail.display_name}
|
||||
</a>
|
||||
</Link>
|
||||
)}{" "}
|
||||
{message}{" "}
|
||||
<span className="whitespace-nowrap">
|
||||
{timeAgo(activityItem.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
} else if ("comment_json" in activityItem)
|
||||
return (
|
||||
<div key={activityItem.id} className="my-4">
|
||||
<CommentCard
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
comment={activityItem as any}
|
||||
onSubmit={handleCommentUpdate}
|
||||
handleCommentDeletion={handleCommentDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<li>
|
||||
<div className="my-4">
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
disabled={
|
||||
!allowed ||
|
||||
!issueDetails ||
|
||||
issueDetails.state === "closed" ||
|
||||
issueDetails.state === "archived"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
// react
|
||||
import React, { useState, useCallback } from "react";
|
||||
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// react dropzone
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
// fetch key
|
||||
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// icons
|
||||
import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// components
|
||||
import { Label, WebViewModal } from "components/web-view";
|
||||
import { DeleteAttachmentModal } from "components/issues";
|
||||
|
||||
// types
|
||||
import type { IIssueAttachment } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachments: React.FC<Props> = (props) => {
|
||||
const { allowed } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
|
||||
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (!acceptedFiles[0] || !workspaceSlug) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", acceptedFiles[0]);
|
||||
formData.append(
|
||||
"attributes",
|
||||
JSON.stringify({
|
||||
name: acceptedFiles[0].name,
|
||||
size: acceptedFiles[0].size,
|
||||
})
|
||||
);
|
||||
setIsLoading(true);
|
||||
|
||||
issuesService
|
||||
.uploadIssueAttachment(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
formData
|
||||
)
|
||||
.then((res) => {
|
||||
mutate<IIssueAttachment[]>(
|
||||
ISSUE_ATTACHMENTS(issueId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "File added successfully.",
|
||||
});
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: "Something went wrong. please check file type & size (max 5 MB)",
|
||||
});
|
||||
});
|
||||
},
|
||||
[issueId, projectId, setToastAlert, workspaceSlug]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
disabled: !allowed || isLoading,
|
||||
});
|
||||
|
||||
const { data: attachments } = useSWR<IIssueAttachment[]>(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.getIssueAttachment(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeleteAttachmentModal
|
||||
isOpen={allowed && attachmentDeleteModal}
|
||||
setIsOpen={setAttachmentDeleteModal}
|
||||
data={deleteAttachment}
|
||||
/>
|
||||
|
||||
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file">
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-b w-full py-2 text-custom-text-100 px-2 flex justify-between items-center ${
|
||||
!allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isLoading ? (
|
||||
<p className="text-center">Uploading...</p>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg">Upload</h3>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WebViewModal>
|
||||
|
||||
<Label>Attachments</Label>
|
||||
<div className="mt-1 space-y-[6px]">
|
||||
{attachments?.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
|
||||
>
|
||||
<Link href={attachment.asset}>
|
||||
<a target="_blank" className="text-custom-text-200 truncate">
|
||||
{attachment.attributes.name}
|
||||
</a>
|
||||
</Link>
|
||||
{allowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteAttachment(attachment);
|
||||
setAttachmentDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-custom-primary-100/10 border border-dotted border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
|
||||
>
|
||||
Click to upload file here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// icons
|
||||
import { LinkIcon, PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// components
|
||||
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
|
||||
|
||||
// ui
|
||||
import { SecondaryButton } from "components/ui";
|
||||
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const IssueLinks: React.FC<Props> = (props) => {
|
||||
const { issueDetails, allowed } = props;
|
||||
|
||||
const links = issueDetails?.issue_link;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedLink, setSelectedLink] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails) return;
|
||||
|
||||
const updatedLinks = issueDetails.issue_link.filter((l) => l.id !== linkId);
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueDetails.id),
|
||||
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetails.id, linkId)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetails.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
setSelectedLink(null);
|
||||
}}
|
||||
modalTitle={selectedLink ? "Update Link" : "Add Link"}
|
||||
>
|
||||
<CreateUpdateLinkForm
|
||||
isOpen={isOpen}
|
||||
links={links}
|
||||
onSuccess={() => {
|
||||
setIsOpen(false);
|
||||
setSelectedLink(null);
|
||||
}}
|
||||
data={links?.find((link) => link.id === selectedLink)}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<Label>Links</Label>
|
||||
<div className="mt-1 space-y-[6px]">
|
||||
{links?.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
|
||||
>
|
||||
<Link href={link.url}>
|
||||
<a target="_blank" className="text-custom-text-200 truncate">
|
||||
<span>
|
||||
<LinkIcon className="w-4 h-4 inline-block mr-1" />
|
||||
</span>
|
||||
<span>{link.title}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{allowed && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedLink(link.id);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5 text-custom-text-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleDeleteLink(link.id);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5 text-red-500 hover:bg-red-500/20" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 inline-block mr-1" />
|
||||
<span>Add</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// react hook forms
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
// icons
|
||||
import { ChevronDownIcon, PlayIcon } from "lucide-react";
|
||||
|
||||
// hooks
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
|
||||
// ui
|
||||
import { Icon, SecondaryButton } from "components/ui";
|
||||
|
||||
// components
|
||||
import {
|
||||
Label,
|
||||
StateSelect,
|
||||
PrioritySelect,
|
||||
AssigneeSelect,
|
||||
EstimateSelect,
|
||||
} from "components/web-view";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
const { control, submitChanges } = props;
|
||||
|
||||
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
|
||||
|
||||
const { isEstimateActive } = useEstimateOption();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>Details</Label>
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon iconName="grid_view" />
|
||||
<span className="text-sm text-custom-text-200">State</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<StateSelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon iconName="signal_cellular_alt" />
|
||||
<span className="text-sm text-custom-text-200">Priority</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<PrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon iconName="person" />
|
||||
<span className="text-sm text-custom-text-200">Assignee</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<AssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ assignees_list: [val] })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isViewAllOpen && (
|
||||
<>
|
||||
{isEstimateActive && (
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
|
||||
<span className="text-sm text-custom-text-200">Estimate</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<EstimateSelect
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ estimate_point: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="mb-[6px]">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsViewAllOpen((prev) => !prev)}
|
||||
className="w-full flex justify-center items-center gap-1 !py-2"
|
||||
>
|
||||
<span className="text-base text-custom-primary-100">
|
||||
{isViewAllOpen ? "View less" : "View all"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={`ml-1 text-custom-primary-100 ${isViewAllOpen ? "-rotate-180" : ""}`}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
// react
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react hook forms
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// hooks
|
||||
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
|
||||
// ui
|
||||
import { TextArea } from "components/ui";
|
||||
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { Label } from "components/web-view";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
isAllowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
register: any;
|
||||
control: any;
|
||||
watch: any;
|
||||
handleSubmit: any;
|
||||
};
|
||||
|
||||
export const IssueWebViewForm: React.FC<Props> = (props) => {
|
||||
const { isAllowed, issueDetails, submitChanges, register, control, watch, handleSubmit } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert]);
|
||||
|
||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||
setTimeout(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||
|
||||
await submitChanges({
|
||||
name: formData.name ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[submitChanges]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Label>Title</Label>
|
||||
<div className="relative">
|
||||
{isAllowed ? (
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Enter issue name"
|
||||
register={register}
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onChange={(e) => {
|
||||
setCharacterLimit(false);
|
||||
setIsSubmitting("submitting");
|
||||
debouncedTitleSave();
|
||||
}}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
) : (
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails?.name}</h4>
|
||||
)}
|
||||
{characterLimit && isAllowed && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value) return <></>;
|
||||
|
||||
return (
|
||||
<TipTapEditor
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? "<p></p>"
|
||||
: value
|
||||
}
|
||||
workspaceSlug={workspaceSlug!.toString()}
|
||||
debouncedUpdatesEnabled={true}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName={
|
||||
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
|
||||
}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() =>
|
||||
setIsSubmitting("submitted")
|
||||
);
|
||||
}}
|
||||
editable={isAllowed}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const Label: React.FC<
|
||||
React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
|
||||
> = (props) => (
|
||||
<label className="block text-base font-medium mb-[5px]" {...props}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
|
||||
// fetch key
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
// components
|
||||
import { Avatar } from "components/ui/avatar";
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
|
||||
type Props = {
|
||||
value: string[];
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AssigneeSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const selectedAssignees = members?.filter((member) => value?.includes(member.member.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select state"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={
|
||||
members?.map((member) => ({
|
||||
label: member.member.display_name,
|
||||
value: member.member.id,
|
||||
checked: value?.includes(member.member.id),
|
||||
icon: <Avatar user={member.member} />,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(member.member.id);
|
||||
},
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={
|
||||
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
|
||||
}
|
||||
>
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<div className="-my-0.5 flex items-center gap-2">
|
||||
<Avatar user={selectedAssignees?.[0].member} />
|
||||
<span className="text-custom-text-100 text-xs">
|
||||
{selectedAssignees?.length} Assignees
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"No assignees"
|
||||
)}
|
||||
{/* {selectedAssignee?.member.display_name || "Select assignee"} */}
|
||||
<ChevronDownIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDownIcon, PlayIcon } from "lucide-react";
|
||||
|
||||
// hooks
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
|
||||
// components
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EstimateSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { estimatePoints } = useEstimateOption();
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select estimate"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={[
|
||||
{
|
||||
label: "None",
|
||||
value: null,
|
||||
checked: value === null,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(null);
|
||||
},
|
||||
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
|
||||
},
|
||||
...estimatePoints?.map((point) => ({
|
||||
label: point.value,
|
||||
value: point.key,
|
||||
checked: point.key === value,
|
||||
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(point.key);
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={
|
||||
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
|
||||
}
|
||||
>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<PlayIcon className="h-4 w-4 -rotate-90" />
|
||||
<span>{estimatePoints?.find((e) => e.key === value)?.value}</span>
|
||||
</div>
|
||||
) : (
|
||||
"No estimate"
|
||||
)}
|
||||
<ChevronDownIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
|
||||
// components
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
|
||||
// helpers
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PrioritySelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select priority"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={
|
||||
PRIORITIES?.map((priority) => ({
|
||||
label: priority ? capitalizeFirstLetter(priority) : "None",
|
||||
value: priority,
|
||||
checked: priority === value,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(priority);
|
||||
},
|
||||
icon: (
|
||||
<span
|
||||
className={`text-left text-xs capitalize rounded ${
|
||||
priority === "urgent"
|
||||
? "border-red-500/20 text-red-500"
|
||||
: priority === "high"
|
||||
? "border-orange-500/20 text-orange-500"
|
||||
: priority === "medium"
|
||||
? "border-yellow-500/20 text-yellow-500"
|
||||
: priority === "low"
|
||||
? "border-green-500/20 text-green-500"
|
||||
: "border-custom-border-200 text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(priority, "text-sm")}
|
||||
</span>
|
||||
),
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={
|
||||
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
|
||||
}
|
||||
>
|
||||
{value ? capitalizeFirstLetter(value) : "None"}
|
||||
<ChevronDownIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
|
||||
// fetch key
|
||||
import { STATES_LIST } from "constants/fetch-keys";
|
||||
|
||||
// components
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const StateSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const states = getStatesList(stateGroups);
|
||||
|
||||
const selectedState = states?.find((s) => s.id === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select state"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={
|
||||
states?.map((state) => ({
|
||||
label: state.name,
|
||||
value: state.id,
|
||||
checked: state.id === selectedState?.id,
|
||||
icon: getStateGroupIcon(state.group, "16", "16", state.color),
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(state.id);
|
||||
},
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={
|
||||
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
|
||||
}
|
||||
>
|
||||
{selectedState?.name || "Select a state"}
|
||||
<ChevronDownIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
// react
|
||||
import React from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// fetch key
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
import { IIssue } from "types";
|
||||
|
||||
// components
|
||||
import { Label } from "components/web-view";
|
||||
|
||||
type Props = {
|
||||
issueDetails?: IIssue;
|
||||
};
|
||||
|
||||
export const SubIssueList: React.FC<Props> = (props) => {
|
||||
const { issueDetails } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: subIssuesResponse } = useSWR(
|
||||
workspaceSlug && issueDetails ? SUB_ISSUES(issueDetails.id) : null,
|
||||
workspaceSlug && issueDetails
|
||||
? () =>
|
||||
issuesService.subIssues(workspaceSlug as string, issueDetails.project, issueDetails.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleSubIssueRemove = (issue: any) => {
|
||||
if (!workspaceSlug || !issueDetails || !user) return;
|
||||
|
||||
mutate(
|
||||
SUB_ISSUES(issueDetails.id),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const stateDistribution = { ...prevData.state_distribution };
|
||||
|
||||
const issueGroup = issue.state_detail.group;
|
||||
stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1;
|
||||
|
||||
return {
|
||||
state_distribution: stateDistribution,
|
||||
sub_issues: prevData.sub_issues.filter((i: any) => i.id !== issue.id),
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
|
||||
.finally(() => mutate(SUB_ISSUES(issueDetails.id)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>Sub Issues</Label>
|
||||
<div className="p-3 border border-custom-border-200 rounded-[4px]">
|
||||
{!subIssuesResponse && (
|
||||
<div className="flex justify-center items-center">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subIssuesResponse?.sub_issues.length === 0 && (
|
||||
<div className="flex justify-center items-center">
|
||||
<p className="text-sm text-custom-text-200">No sub issues</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subIssuesResponse?.sub_issues?.map((subIssue) => (
|
||||
<div key={subIssue.id} className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center">
|
||||
<p className="mr-3 text-sm text-custom-text-300">
|
||||
{subIssue.project_detail.identifier}-{subIssue.sequence_id}
|
||||
</p>
|
||||
<p className="text-sm font-normal">{subIssue.name}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => handleSubIssueRemove(subIssue)}>
|
||||
<XMarkIcon className="w-5 h-5 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
// react
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
// headless ui
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modalTitle: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const WebViewModal = (props: Props) => {
|
||||
const { isOpen, onClose, modalTitle, children } = props;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed bottom-0 left-0 w-full z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center text-center sm:items-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-none rounded-tr-[4px] rounded-tl-[4px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:mt-8 w-full">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-2xl font-semibold leading-6 text-custom-text-100"
|
||||
>
|
||||
{modalTitle}
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center items-center p-2 rounded-md text-custom-text-200 hover:text-custom-text-100 focus:outline-none"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type OptionsProps = {
|
||||
options: Array<{
|
||||
label: string;
|
||||
value: string | null;
|
||||
checked: boolean;
|
||||
icon?: any;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const Options: React.FC<OptionsProps> = ({ options }) => (
|
||||
<div className="space-y-6">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={option.checked}
|
||||
onChange={option.onClick}
|
||||
className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4"
|
||||
/>
|
||||
|
||||
{option.icon}
|
||||
|
||||
<p className="text-sm font-normal">{option.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
WebViewModal.Options = Options;
|
||||
WebViewModal.Options.displayName = "WebViewModal.Options";
|
||||
@@ -285,7 +285,9 @@ export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
|
||||
params.segment
|
||||
}_${params.project?.toString()}`;
|
||||
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
|
||||
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${params?.cycle}_${params?.module}`;
|
||||
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${
|
||||
params?.cycle
|
||||
}_${params?.module}`;
|
||||
|
||||
// notifications
|
||||
export const USER_WORKSPACE_NOTIFICATIONS = (
|
||||
|
||||
@@ -8,8 +8,6 @@ import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
|
||||
import getConfig from "next/config";
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: JSX.Element;
|
||||
left?: JSX.Element;
|
||||
@@ -18,7 +16,7 @@ type Props = {
|
||||
noHeader: boolean;
|
||||
};
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_DEPLOY_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
|
||||
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL ? NEXT_PUBLIC_DEPLOY_URL : "http://localhost:3001";
|
||||
|
||||
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import userService from "services/user.service";
|
||||
|
||||
// fetch keys
|
||||
import { CURRENT_USER } from "constants/fetch-keys";
|
||||
|
||||
// icons
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
fullScreen?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const WebViewLayout: React.FC<Props> = ({ children, fullScreen = false }) => {
|
||||
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
|
||||
|
||||
if (!currentUser && !error) {
|
||||
return (
|
||||
<div className="h-screen grid place-items-center p-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<h3 className="text-xl">Loading your profile...</h3>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
fullScreen
|
||||
? "h-screen w-full overflow-hidden bg-custom-background-100"
|
||||
: "flex-col blur-none shadow-none backdrop:backdrop-blur-none justify-center items-center"
|
||||
}`}
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-3 h-full text-center text-custom-text-200">
|
||||
<AlertCircle size={64} />
|
||||
<h2 className="text-2xl font-semibold">You are not authorized to view this page.</h2>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebViewLayout;
|
||||
+4
-12
@@ -1,5 +1,4 @@
|
||||
// cookies
|
||||
import getConfig from "next/config";
|
||||
import { convertCookieStringToObject } from "./cookie";
|
||||
// types
|
||||
import type { IProjectMember, IUser, IWorkspace, IWorkspaceMember } from "types";
|
||||
@@ -10,9 +9,7 @@ export const requiredAuth = async (cookie?: string) => {
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
|
||||
const baseUrl = NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let user: IUser | null = null;
|
||||
|
||||
@@ -44,9 +41,7 @@ export const requiredAdmin = async (workspaceSlug: string, projectId: string, co
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
|
||||
const baseUrl = NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let memberDetail: IProjectMember | null = null;
|
||||
|
||||
@@ -80,9 +75,7 @@ export const requiredWorkspaceAdmin = async (workspaceSlug: string, cookie?: str
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
|
||||
const baseUrl = NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let memberDetail: IWorkspaceMember | null = null;
|
||||
|
||||
@@ -126,8 +119,7 @@ export const homePageRedirect = async (cookie?: string) => {
|
||||
|
||||
let workspaces: IWorkspace[] = [];
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const baseUrl = NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
@@ -9,10 +9,6 @@ const extraImageDomains = (process.env.NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS ?? "")
|
||||
const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
publicRuntimeConfig: {
|
||||
NEXT_PUBLIC_DEPLOY_URL: process.env.NEXT_PUBLIC_DEPLOY_URL,
|
||||
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
},
|
||||
images: {
|
||||
domains: [
|
||||
"vinci-web.s3.amazonaws.com",
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import type { NextPage } from "next";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import issuesService from "services/issues.service";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
import { Spinner } from "components/ui";
|
||||
import Image404 from "public/404.svg";
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
import Image from "next/image";
|
||||
import userService from "services/user.service";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const Editor: NextPage = () => {
|
||||
const [user, setUser] = useState<ICurrentUserResponse | undefined>();
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
const [isLoading, setIsLoading] = useState("false");
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
const [cookies, setCookies] = useState<any>({});
|
||||
const [issueDetail, setIssueDetail] = useState<IIssue | null>(null);
|
||||
const router = useRouter();
|
||||
const { editable } = router.query;
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<IIssue>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
description_html: "",
|
||||
},
|
||||
});
|
||||
|
||||
const getCookies = () => {
|
||||
const cookies = document.cookie.split(";");
|
||||
const cookieObj: any = {};
|
||||
cookies.forEach((cookie) => {
|
||||
const cookieArr = cookie.split("=");
|
||||
cookieObj[cookieArr[0].trim()] = cookieArr[1];
|
||||
});
|
||||
|
||||
setCookies(cookieObj);
|
||||
return cookieObj;
|
||||
};
|
||||
|
||||
const getIssueDetail = async (cookiesData: any) => {
|
||||
try {
|
||||
setIsLoading("true");
|
||||
const userData = await userService.currentUser();
|
||||
setUser(userData);
|
||||
const issueDetail = await issuesService.retrieve(
|
||||
cookiesData.MOBILE_slug,
|
||||
cookiesData.MOBILE_project_id,
|
||||
cookiesData.MOBILE_issue_id
|
||||
);
|
||||
setIssueDetail(issueDetail);
|
||||
setIsLoading("false");
|
||||
setValue("description_html", issueDetail.description_html);
|
||||
setValue("description", issueDetail.description);
|
||||
} catch (e) {
|
||||
setIsLoading("error");
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const cookiesData = getCookies();
|
||||
|
||||
getIssueDetail(cookiesData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert]);
|
||||
|
||||
const submitChanges = async (
|
||||
formData: Partial<IIssue>,
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
delete payload.blocker_issues;
|
||||
delete payload.blocked_issues;
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!formData) return;
|
||||
|
||||
await submitChanges(
|
||||
{
|
||||
name: issueDetail?.name ?? "",
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
},
|
||||
cookies.MOBILE_slug,
|
||||
cookies.MOBILE_project_id,
|
||||
cookies.MOBILE_issue_id
|
||||
);
|
||||
},
|
||||
[submitChanges]
|
||||
);
|
||||
|
||||
return isLoading === "error" ? (
|
||||
<ErrorEncountered />
|
||||
) : isLoading === "true" ? (
|
||||
<div className="grid place-items-center h-screen w-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex blur-none shadow-none backdrop:backdrop-blur-none justify-center items-center">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TipTapEditor
|
||||
borderOnFocus={false}
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
editable={editable === "true"}
|
||||
noBorder={true}
|
||||
workspaceSlug={cookies.MOBILE_slug ?? ""}
|
||||
debouncedUpdatesEnabled={true}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName="min-h-[150px] shadow-sm"
|
||||
editorContentCustomClassNames="pb-9"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
|
||||
setIsSubmitting("submitted");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorEncountered: NextPage = () => (
|
||||
<DefaultLayout>
|
||||
<div className="grid max-h-fit place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="relative mx-auto h-40 w-40 lg:h-40 lg:w-40">
|
||||
<Image src={Image404} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
||||
export default Editor;
|
||||
@@ -187,7 +187,7 @@ const GeneralSettings: NextPage = () => {
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
|
||||
<SettingsHeader />
|
||||
<div className="space-y-8 sm:space-y-12 opacity-60">
|
||||
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold">Icon & Name</h4>
|
||||
|
||||
@@ -90,9 +90,8 @@ const WorkspaceSettings: NextPage = () => {
|
||||
await workspaceService
|
||||
.updateWorkspace(activeWorkspace.slug, payload, user)
|
||||
.then((res) => {
|
||||
mutate<IWorkspace[]>(
|
||||
USER_WORKSPACES,
|
||||
(prevData) => prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
|
||||
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
|
||||
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
|
||||
);
|
||||
mutate<IWorkspace>(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
@@ -125,9 +124,8 @@ const WorkspaceSettings: NextPage = () => {
|
||||
title: "Success!",
|
||||
message: "Workspace picture removed successfully.",
|
||||
});
|
||||
mutate<IWorkspace[]>(
|
||||
USER_WORKSPACES,
|
||||
(prevData) => prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
|
||||
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
|
||||
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
|
||||
);
|
||||
mutate<IWorkspace>(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
@@ -183,7 +181,7 @@ const WorkspaceSettings: NextPage = () => {
|
||||
<div className="p-8">
|
||||
<SettingsHeader />
|
||||
{activeWorkspace ? (
|
||||
<div className="space-y-8 sm:space-y-12 opacity-60">
|
||||
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold">Logo</h4>
|
||||
|
||||
+2
-7
@@ -2,7 +2,6 @@
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import Router from "next/router";
|
||||
import App, { AppContext, AppProps } from 'next/app'
|
||||
|
||||
// themes
|
||||
import { ThemeProvider } from "next-themes";
|
||||
@@ -21,6 +20,8 @@ import NProgress from "nprogress";
|
||||
import { UserProvider } from "contexts/user.context";
|
||||
import { ToastContextProvider } from "contexts/toast.context";
|
||||
import { ThemeContextProvider } from "contexts/theme.context";
|
||||
// types
|
||||
import type { AppProps } from "next/app";
|
||||
// constants
|
||||
import { THEMES } from "constants/themes";
|
||||
// constants
|
||||
@@ -44,12 +45,6 @@ Router.events.on("routeChangeStart", NProgress.start);
|
||||
Router.events.on("routeChangeError", NProgress.done);
|
||||
Router.events.on("routeChangeComplete", NProgress.done);
|
||||
|
||||
MyApp.getInitialProps = async (appContext: AppContext) => {
|
||||
const appProps = await App.getInitialProps(appContext)
|
||||
|
||||
return { ...appProps }
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
// <UserProvider>
|
||||
|
||||
+1
-6
@@ -29,7 +29,6 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// next themes
|
||||
import { useTheme } from "next-themes";
|
||||
import { IUser } from "types";
|
||||
import getConfig from "next/config";
|
||||
|
||||
// types
|
||||
type EmailPasswordFormValues = {
|
||||
@@ -42,10 +41,6 @@ const HomePage: NextPage = observer(() => {
|
||||
const store: any = useMobxStore();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const {
|
||||
publicRuntimeConfig: { PROJECT_API },
|
||||
} = getConfig()
|
||||
|
||||
const { isLoading, mutateUser } = useUserAuth("sign-in");
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -181,7 +176,7 @@ const HomePage: NextPage = observer(() => {
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
{ `Sign in to ${PROJECT_API} Plane` }
|
||||
Sign in to Plane
|
||||
</h1>
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// cookies
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
// layouts
|
||||
import WebViewLayout from "layouts/web-view-layout";
|
||||
|
||||
// components
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { PrimaryButton, Spinner } from "components/ui";
|
||||
|
||||
const Editor: NextPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, editable } = router.query;
|
||||
|
||||
const isEditable = editable === "true";
|
||||
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
data: "",
|
||||
data_html: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (!isEditable) return;
|
||||
setIsLoading(false);
|
||||
const data_html = Cookies.get("data_html");
|
||||
setValue("data_html", data_html ?? "");
|
||||
}, [isEditable, setValue]);
|
||||
|
||||
return (
|
||||
<WebViewLayout fullScreen={isLoading}>
|
||||
{isLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
name="data_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TipTapEditor
|
||||
borderOnFocus={false}
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("data_html")
|
||||
: value
|
||||
}
|
||||
editable={isEditable}
|
||||
noBorder={true}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
debouncedUpdatesEnabled={true}
|
||||
customClassName="min-h-[150px] shadow-sm"
|
||||
editorContentCustomClassNames="pb-9"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setValue("data_html", description_html);
|
||||
setValue("data", JSON.stringify(description));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isEditable && (
|
||||
<PrimaryButton
|
||||
className="mt-4 w-[calc(100%-30px)] h-[45px] mx-[15px] text-[17px]"
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"submitted",
|
||||
JSON.stringify({
|
||||
data_html: watch("data_html"),
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WebViewLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,175 @@
|
||||
// react
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook forms
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// fetch key
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
|
||||
// components
|
||||
import {
|
||||
IssueWebViewForm,
|
||||
SubIssueList,
|
||||
IssueAttachments,
|
||||
IssuePropertiesDetail,
|
||||
IssueLinks,
|
||||
IssueActivity,
|
||||
} from "components/web-view";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
const MobileWebViewIssueDetail = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const memberRole = useProjectMembers(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
!!workspaceSlug && !!projectId
|
||||
);
|
||||
|
||||
const isAllowed = Boolean(memberRole.isMember || memberRole.isOwner);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { register, control, reset, handleSubmit, watch } = useForm<IIssue>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
description_html: "",
|
||||
state: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: issueDetails,
|
||||
mutate: mutateIssueDetails,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueDetails) return;
|
||||
reset({
|
||||
...issueDetails,
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
description_html: issueDetails.description_html,
|
||||
state: issueDetails.state,
|
||||
assignees_list:
|
||||
issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id),
|
||||
labels_list: issueDetails.labels_list ?? issueDetails.labels,
|
||||
labels: issueDetails.labels_list ?? issueDetails.labels,
|
||||
});
|
||||
}, [issueDetails, reset]);
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId.toString()),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
delete payload.blocker_issues;
|
||||
delete payload.blocked_issues;
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
|
||||
);
|
||||
|
||||
if (!error && !issueDetails)
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="px-4 py-2 h-full">
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="px-6 py-2 h-full overflow-auto space-y-3">
|
||||
<IssueWebViewForm
|
||||
isAllowed={isAllowed}
|
||||
issueDetails={issueDetails!}
|
||||
submitChanges={submitChanges}
|
||||
register={register}
|
||||
control={control}
|
||||
watch={watch}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<SubIssueList issueDetails={issueDetails!} />
|
||||
|
||||
<IssuePropertiesDetail control={control} submitChanges={submitChanges} />
|
||||
|
||||
<IssueAttachments allowed={isAllowed} />
|
||||
|
||||
<IssueLinks allowed={isAllowed} issueDetails={issueDetails!} />
|
||||
|
||||
<IssueActivity allowed={isAllowed} issueDetails={issueDetails!} />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileWebViewIssueDetail;
|
||||
@@ -1,12 +1,11 @@
|
||||
// services
|
||||
import getConfig from "next/config";
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
// types
|
||||
import { ICurrentUserResponse, IGptResponse } from "types";
|
||||
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
ISaveAnalyticsFormData,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
class AnalyticsServices extends APIService {
|
||||
constructor() {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import axios from "axios";
|
||||
import APIService from "services/api.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
class AppInstallationsService extends APIService {
|
||||
constructor() {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import APIService from "services/api.service";
|
||||
import { ICurrentUserResponse } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
class AuthService extends APIService {
|
||||
constructor() {
|
||||
|
||||
@@ -5,8 +5,7 @@ import trackEventServices from "services/track-event.service";
|
||||
// types
|
||||
import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IIssue } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -4,8 +4,7 @@ import APIService from "services/api.service";
|
||||
import type { ICurrentUserResponse, IEstimate, IEstimateFormData } from "types";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
interface UnSplashImage {
|
||||
id: string;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -3,8 +3,7 @@ import trackEventServices from "services/track-event.service";
|
||||
|
||||
import { ICurrentUserResponse } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -3,8 +3,7 @@ import trackEventServices from "services/track-event.service";
|
||||
|
||||
import { ICurrentUserResponse, IGithubRepoInfo, IGithubServiceImportFormData } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
IExportServiceResponse,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -4,8 +4,7 @@ import trackEventServices from "services/track-event.service";
|
||||
// types
|
||||
import { IJiraMetadata, IJiraResponse, IJiraImporterForm, ICurrentUserResponse } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -12,8 +12,7 @@ import type {
|
||||
ISubIssueResponse,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
@@ -205,7 +204,7 @@ class ProjectIssuesServices extends APIService {
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
data: IIssueComment,
|
||||
data: Partial<IIssueComment>,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
|
||||
@@ -5,8 +5,7 @@ import trackEventServices from "./track-event.service";
|
||||
// types
|
||||
import type { IIssueViewOptions, IModule, IIssue, ICurrentUserResponse } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
// types
|
||||
import type {
|
||||
|
||||
@@ -5,8 +5,7 @@ import trackEventServices from "services/track-event.service";
|
||||
// types
|
||||
import { IPage, IPageBlock, RecentPagesResponse, IIssue, ICurrentUserResponse } from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -5,8 +5,7 @@ import trackEventServices from "services/track-event.service";
|
||||
import { ICurrentUserResponse } from "types";
|
||||
import { IProjectPublishSettings } from "store/project-publish";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -16,8 +16,7 @@ import type {
|
||||
TProjectIssuesSearchParams,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -11,8 +11,7 @@ import type {
|
||||
IssueCommentReactionForm,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -12,8 +12,7 @@ import type {
|
||||
IUserWorkspaceDashboard,
|
||||
} from "types";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ICurrentUserResponse } from "types";
|
||||
// types
|
||||
import { IView } from "types/views";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
import getConfig from "next/config";
|
||||
const { publicRuntimeConfig: { NEXT_PUBLIC_API_BASE_URL } } = getConfig();
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
// types
|
||||
import {
|
||||
|
||||
@@ -1367,7 +1367,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.7.tgz#95bed2487bf59632125a13b8eb8f4c21e460afec"
|
||||
integrity sha512-sCWTUNElBPgB30iLvWe3PU7SIlTKZNf6/E/sko85iHVeHCM6WPkDw+y89CrZYjhFNmPqt2fIQM/pZu+rP2lFLA==
|
||||
|
||||
"@mui/icons-material@^5.14.1", "@mui/icons-material@^5.14.7":
|
||||
"@mui/icons-material@^5.14.1":
|
||||
version "5.14.7"
|
||||
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.7.tgz#d7f6bd188fe38adf35c89d9343b8a529c2306383"
|
||||
integrity sha512-mWp4DwMa8c1Gx9yOEtPgxM4b+e6hAbtZyzfSubdBwrnEE6G5D2rbAJ5MB+If6kfI48JaYaJ5j8+zAdmZLuZc0A==
|
||||
@@ -4892,11 +4892,6 @@ https-proxy-agent@^5.0.0:
|
||||
agent-base "6"
|
||||
debug "4"
|
||||
|
||||
husky@^8.0.3:
|
||||
version "8.0.3"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184"
|
||||
integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==
|
||||
|
||||
idb@^7.0.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
|
||||
@@ -6046,11 +6041,6 @@ next-pwa@^5.6.0:
|
||||
workbox-webpack-plugin "^6.5.4"
|
||||
workbox-window "^6.5.4"
|
||||
|
||||
next-theme@^0.1.5:
|
||||
version "0.1.5"
|
||||
resolved "https://registry.yarnpkg.com/next-theme/-/next-theme-0.1.5.tgz#aa6655c516892925e577349d7715a8ed54bad727"
|
||||
integrity sha512-WR8UCLEFjWvRl+UO2lTM4pGo7R4jzGZqQ6YL3hiL1Ns587Qb91GhJZLPu/Aa4ExtGQ/5wlcDX8zDYZoCN9oDPw==
|
||||
|
||||
next-themes@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45"
|
||||
|
||||
Reference in New Issue
Block a user