mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into feat/gantt_year_view
This commit is contained in:
commit
6c72a193b4
@ -41,9 +41,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
final_text = task + "\n" + prompt
|
||||
|
||||
openai.api_key = settings.OPENAI_API_KEY
|
||||
response = openai.Completion.create(
|
||||
response = openai.ChatCompletion.create(
|
||||
model=settings.GPT_ENGINE,
|
||||
prompt=final_text,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
temperature=0.7,
|
||||
max_tokens=1024,
|
||||
)
|
||||
@ -51,7 +51,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
text = response.choices[0].text.strip()
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -482,7 +482,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
# Delete joined project invites
|
||||
project_invitations.delete()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@ -924,8 +924,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
|
||||
project_member.save()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "The requested resource does not exists"},
|
||||
|
@ -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")
|
||||
@ -532,7 +532,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
# Delete joined workspace invites
|
||||
workspace_invitations.delete()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@ -846,7 +846,7 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
||||
workspace_member.view_props = request.data.get("view_props", {})
|
||||
workspace_member.save()
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except WorkspaceMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User not a member of workspace"},
|
||||
@ -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
|
@ -38,7 +38,7 @@ services:
|
||||
container_name: planefrontend
|
||||
image: makeplane/plane-frontend:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh apps/app/server.js app
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@ -56,6 +56,20 @@ services:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
|
||||
plane-deploy:
|
||||
container_name: planedeploy
|
||||
image: makeplane/plane-deploy:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- plane-web
|
||||
|
||||
plane-api:
|
||||
container_name: planebackend
|
||||
image: makeplane/plane-backend:latest
|
||||
|
@ -39,6 +39,7 @@ services:
|
||||
context: .
|
||||
dockerfile: ./web/Dockerfile.web
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
|
||||
restart: always
|
||||
@ -67,6 +68,7 @@ services:
|
||||
dockerfile: ./space/Dockerfile.space
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
@ -84,8 +86,12 @@ services:
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: always
|
||||
command: ./bin/takeoff
|
||||
ports:
|
||||
- 8000:8000
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@ -99,6 +105,8 @@ services:
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: always
|
||||
command: ./bin/worker
|
||||
env_file:
|
||||
@ -115,6 +123,8 @@ services:
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
restart: always
|
||||
command: ./bin/beat
|
||||
env_file:
|
||||
|
@ -1,6 +1,5 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
@ -9,37 +8,34 @@ COPY . .
|
||||
|
||||
RUN turbo prune --scope=space --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 plane
|
||||
RUN adduser --system --uid 1001 captain
|
||||
USER captain
|
||||
@ -47,16 +43,14 @@ USER captain
|
||||
COPY --from=installer /app/space/next.config.js .
|
||||
COPY --from=installer /app/space/package.json .
|
||||
|
||||
# Automatically leverage output traces to reduce image sizß
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next
|
||||
COPY --from=installer --chown=captain:plane /app/space/public ./space/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
3
space/components/issues/peek-overview/comment/index.ts
Normal file
3
space/components/issues/peek-overview/comment/index.ts
Normal file
@ -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>
|
||||
|
@ -77,14 +77,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
{...bubbleMenuProps}
|
||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||
>
|
||||
<NodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
{!props.editor.isActive("table") && (
|
||||
<NodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LinkSelector
|
||||
editor={props.editor!!}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
|
@ -28,7 +28,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
name: "Text",
|
||||
icon: TextIcon,
|
||||
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
||||
isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"),
|
||||
isActive: () =>
|
||||
editor.isActive("paragraph") &&
|
||||
!editor.isActive("bulletList") &&
|
||||
!editor.isActive("orderedList"),
|
||||
},
|
||||
{
|
||||
name: "H1",
|
||||
@ -69,7 +72,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
||||
{
|
||||
name: "Quote",
|
||||
icon: TextQuote,
|
||||
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
||||
command: () =>
|
||||
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
||||
isActive: () => editor.isActive("blockquote"),
|
||||
},
|
||||
{
|
||||
|
@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { lowlight } from "lowlight/lib/core";
|
||||
import SlashCommand from "../slash-command";
|
||||
import { InputRule } from "@tiptap/core";
|
||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css";
|
||||
import UniqueID from "@tiptap-pro/extension-unique-id";
|
||||
import UpdatedImage from "./updated-image";
|
||||
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
|
||||
import { CustomTableCell } from "./table/table-cell";
|
||||
import { Table } from "./table/table";
|
||||
import { TableHeader } from "./table/table-header";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
|
||||
lowlight.registerLanguage("ts", ts);
|
||||
|
||||
@ -27,113 +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: {
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
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: "#DBEAFE",
|
||||
width: 2,
|
||||
},
|
||||
gapcursor: false,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
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",
|
||||
},
|
||||
}),
|
||||
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}`;
|
||||
}
|
||||
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,
|
||||
}),
|
||||
];
|
||||
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,
|
||||
];
|
||||
|
32
space/components/tiptap/extensions/table/table-cell.ts
Normal file
32
space/components/tiptap/extensions/table/table-cell.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
|
||||
export const CustomTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
isHeader: {
|
||||
default: false,
|
||||
parseHTML: (element) => {
|
||||
isHeader: element.tagName === "TD";
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
tag: attributes.isHeader ? "th" : "td";
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
if (HTMLAttributes.isHeader) {
|
||||
return [
|
||||
"th",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
class: `relative ${HTMLAttributes.class}`,
|
||||
},
|
||||
["span", { class: "absolute top-0 right-0" }],
|
||||
0,
|
||||
];
|
||||
}
|
||||
return ["td", HTMLAttributes, 0];
|
||||
},
|
||||
});
|
7
space/components/tiptap/extensions/table/table-header.ts
Normal file
7
space/components/tiptap/extensions/table/table-header.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
||||
|
||||
const TableHeader = BaseTableHeader.extend({
|
||||
content: "paragraph",
|
||||
});
|
||||
|
||||
export { TableHeader };
|
9
space/components/tiptap/extensions/table/table.ts
Normal file
9
space/components/tiptap/extensions/table/table.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Table as BaseTable } from "@tiptap/extension-table";
|
||||
|
||||
const Table = BaseTable.configure({
|
||||
resizable: true,
|
||||
cellMinWidth: 100,
|
||||
allowTableNodeSelection: true,
|
||||
});
|
||||
|
||||
export { Table };
|
@ -6,6 +6,7 @@ import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { TiptapExtensions } from "./extensions";
|
||||
import { TiptapEditorProps } from "./props";
|
||||
import { ImageResizer } from "./extensions/image-resize";
|
||||
import { TableMenu } from "./table-menu";
|
||||
|
||||
export interface ITipTapRichTextEditor {
|
||||
value: string;
|
||||
@ -37,6 +38,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
editable: editable ?? true,
|
||||
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
|
||||
@ -81,8 +83,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
|
||||
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
|
||||
${noBorder ? "" : "border border-custom-border-200"} ${
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||
} ${customClassName}`;
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||
} ${customClassName}`;
|
||||
|
||||
if (!editor) return null;
|
||||
editorRef.current = editor;
|
||||
@ -98,6 +100,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className={`${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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";
|
||||
@ -46,7 +45,11 @@ export default UploadImagesPlugin;
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state);
|
||||
const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id);
|
||||
const found = decos.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec: { id: number | undefined }) => spec.id == id
|
||||
);
|
||||
return found.length ? found[0].from : null;
|
||||
}
|
||||
|
||||
@ -59,8 +62,6 @@ export async function startImageUpload(
|
||||
) {
|
||||
if (!file.type.includes("image/")) {
|
||||
return;
|
||||
} else if (file.size / 1024 / 1024 > 20) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = {};
|
||||
@ -93,7 +94,9 @@ export async function startImageUpload(
|
||||
const imageSrc = typeof src === "object" ? reader.result : src;
|
||||
|
||||
const node = schema.nodes.image.create({ src: imageSrc });
|
||||
const transaction = view.state.tr.replaceWith(pos, pos, node).setMeta(uploadKey, { remove: { id } });
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
|
||||
@ -107,7 +110,9 @@ const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string>
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const imageUrl = await fileService.uploadFile(workspaceSlug, formData).then((response) => response.asset);
|
||||
const imageUrl = await fileService
|
||||
.uploadFile(workspaceSlug, formData)
|
||||
.then((response) => response.asset);
|
||||
|
||||
const image = new Image();
|
||||
image.src = imageUrl;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { startImageUpload } from "./plugins/upload-image";
|
||||
import { findTableAncestor } from "./table-menu";
|
||||
|
||||
export function TiptapEditorProps(
|
||||
workspaceSlug: string,
|
||||
@ -21,6 +22,15 @@ export function TiptapEditorProps(
|
||||
},
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
@ -31,6 +41,15 @@ export function TiptapEditorProps(
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
MinusSquare,
|
||||
CheckSquare,
|
||||
ImageIcon,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import { startImageUpload } from "../plugins/upload-image";
|
||||
import { cn } from "../utils";
|
||||
@ -46,6 +47,9 @@ const Command = Extension.create({
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
allow({ editor }) {
|
||||
return !editor.isActive("table");
|
||||
},
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
@ -53,7 +57,10 @@ const Command = Extension.create({
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
|
||||
(
|
||||
workspaceSlug: string,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) =>
|
||||
({ query }: { query: string }) =>
|
||||
[
|
||||
{
|
||||
@ -119,6 +126,20 @@ const getSuggestionItems =
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Create a Table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
@ -134,14 +155,21 @@ const getSuggestionItems =
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.toggleBlockquote()
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
@ -190,7 +218,15 @@ export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
}: {
|
||||
items: CommandItemProps[];
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
|
16
space/components/tiptap/table-menu/InsertBottomTableIcon.tsx
Normal file
16
space/components/tiptap/table-menu/InsertBottomTableIcon.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const InsertBottomTableIcon = (props: any) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 -960 960 960"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default InsertBottomTableIcon;
|
15
space/components/tiptap/table-menu/InsertLeftTableIcon.tsx
Normal file
15
space/components/tiptap/table-menu/InsertLeftTableIcon.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
const InsertLeftTableIcon = (props: any) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 -960 960 960"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default InsertLeftTableIcon;
|
16
space/components/tiptap/table-menu/InsertRightTableIcon.tsx
Normal file
16
space/components/tiptap/table-menu/InsertRightTableIcon.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const InsertRightTableIcon = (props: any) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 -960 960 960"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default InsertRightTableIcon;
|
15
space/components/tiptap/table-menu/InsertTopTableIcon.tsx
Normal file
15
space/components/tiptap/table-menu/InsertTopTableIcon.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
const InsertTopTableIcon = (props: any) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 -960 960 960"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
|
||||
fill="rgb(var(--color-text-300))"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default InsertTopTableIcon;
|
143
space/components/tiptap/table-menu/index.tsx
Normal file
143
space/components/tiptap/table-menu/index.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Rows, Columns, ToggleRight } from "lucide-react";
|
||||
import { cn } from "../utils";
|
||||
import { Tooltip } from "components/ui";
|
||||
import InsertLeftTableIcon from "./InsertLeftTableIcon";
|
||||
import InsertRightTableIcon from "./InsertRightTableIcon";
|
||||
import InsertTopTableIcon from "./InsertTopTableIcon";
|
||||
import InsertBottomTableIcon from "./InsertBottomTableIcon";
|
||||
|
||||
interface TableMenuItem {
|
||||
command: () => void;
|
||||
icon: any;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
||||
while (node !== null && node.nodeName !== "TABLE") {
|
||||
node = node.parentNode;
|
||||
}
|
||||
return node as HTMLTableElement;
|
||||
};
|
||||
|
||||
export const TableMenu = ({ editor }: { editor: any }) => {
|
||||
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
|
||||
const isOpen = editor?.isActive("table");
|
||||
|
||||
const items: TableMenuItem[] = [
|
||||
{
|
||||
command: () => editor.chain().focus().addColumnBefore().run(),
|
||||
icon: InsertLeftTableIcon,
|
||||
key: "insert-column-left",
|
||||
name: "Insert 1 column left",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().addColumnAfter().run(),
|
||||
icon: InsertRightTableIcon,
|
||||
key: "insert-column-right",
|
||||
name: "Insert 1 column right",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().addRowBefore().run(),
|
||||
icon: InsertTopTableIcon,
|
||||
key: "insert-row-above",
|
||||
name: "Insert 1 row above",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().addRowAfter().run(),
|
||||
icon: InsertBottomTableIcon,
|
||||
key: "insert-row-below",
|
||||
name: "Insert 1 row below",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().deleteColumn().run(),
|
||||
icon: Columns,
|
||||
key: "delete-column",
|
||||
name: "Delete column",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().deleteRow().run(),
|
||||
icon: Rows,
|
||||
key: "delete-row",
|
||||
name: "Delete row",
|
||||
},
|
||||
{
|
||||
command: () => editor.chain().focus().toggleHeaderRow().run(),
|
||||
icon: ToggleRight,
|
||||
key: "toggle-header-row",
|
||||
name: "Toggle header row",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!window) return;
|
||||
|
||||
const handleWindowClick = () => {
|
||||
const selection: any = window?.getSelection();
|
||||
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const tableNode = findTableAncestor(range.startContainer);
|
||||
|
||||
let parent = tableNode?.parentElement;
|
||||
|
||||
if (tableNode) {
|
||||
const tableRect = tableNode.getBoundingClientRect();
|
||||
const tableCenter = tableRect.left + tableRect.width / 2;
|
||||
const menuWidth = 45;
|
||||
const menuLeft = tableCenter - menuWidth / 2;
|
||||
const tableBottom = tableRect.bottom;
|
||||
|
||||
setTableLocation({ bottom: tableBottom, left: menuLeft });
|
||||
|
||||
while (parent) {
|
||||
if (!parent.classList.contains("disable-scroll"))
|
||||
parent.classList.add("disable-scroll");
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} else {
|
||||
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
|
||||
|
||||
scrollDisabledContainers.forEach((container) => {
|
||||
container.classList.remove("disable-scroll");
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleWindowClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", handleWindowClick);
|
||||
};
|
||||
}, [tableLocation, editor]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
style={{
|
||||
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
|
||||
left: `${tableLocation.left}px`,
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} tooltipContent={item.name}>
|
||||
<button
|
||||
onClick={item.command}
|
||||
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
|
||||
title={item.name}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4 text-lg", {
|
||||
"text-red-600": item.key.includes("delete"),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
@ -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}`;
|
||||
};
|
||||
|
@ -9,8 +9,15 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mui/icons-material": "^5.14.7",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@mui/icons-material": "^5.14.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tiptap-pro/extension-unique-id": "^2.1.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
||||
"@tiptap/extension-color": "^2.0.4",
|
||||
@ -33,17 +40,25 @@
|
||||
"@tiptap/starter-kit": "^2.0.4",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"axios": "^1.3.4",
|
||||
"clsx": "^2.0.0",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"next": "12.3.2",
|
||||
"next-images": "^1.8.5",
|
||||
"next-theme": "^0.1.5",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-moveable": "^0.54.1",
|
||||
"swr": "^2.2.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"typescript": "4.9.5",
|
||||
"use-debounce": "^9.0.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -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";
|
||||
|
BIN
space/public/plane-logos/blue-without-text.png
Normal file
BIN
space/public/plane-logos/blue-without-text.png
Normal file
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;
|
||||
|
@ -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(() => {
|
||||
|
@ -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 () => {
|
||||
|
@ -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 {
|
||||
|
@ -15,6 +15,7 @@ FROM node:18-alpine AS installer
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
@ -29,11 +30,12 @@ COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
RUN yarn turbo run build --filter=web
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL}
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
@ -54,8 +56,11 @@ COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./
|
||||
COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
|
@ -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
|
||||
|
@ -53,6 +53,7 @@ type Props = {
|
||||
handleOnDragEnd: (result: DropResult) => Promise<void>;
|
||||
openIssuesListModal: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
disableAddIssueOption?: boolean;
|
||||
trashBox: boolean;
|
||||
setTrashBox: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
viewProps: IIssueViewProps;
|
||||
@ -68,6 +69,7 @@ export const AllViews: React.FC<Props> = ({
|
||||
handleOnDragEnd,
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
disableAddIssueOption = false,
|
||||
trashBox,
|
||||
setTrashBox,
|
||||
viewProps,
|
||||
@ -127,6 +129,7 @@ export const AllViews: React.FC<Props> = ({
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
removeIssue={removeIssue}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
user={user}
|
||||
userAuth={memberRole}
|
||||
viewProps={viewProps}
|
||||
@ -135,6 +138,7 @@ export const AllViews: React.FC<Props> = ({
|
||||
<AllBoards
|
||||
addIssueToGroup={addIssueToGroup}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
dragDisabled={dragDisabled}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleTrashBox={handleTrashBox}
|
||||
|
@ -10,6 +10,7 @@ import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from
|
||||
type Props = {
|
||||
addIssueToGroup: (groupTitle: string) => void;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
dragDisabled: boolean;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
@ -24,6 +25,7 @@ type Props = {
|
||||
export const AllBoards: React.FC<Props> = ({
|
||||
addIssueToGroup,
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
dragDisabled,
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
@ -52,6 +54,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
addIssueToGroup={() => addIssueToGroup(singleGroup)}
|
||||
currentState={currentState}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
dragDisabled={dragDisabled}
|
||||
groupTitle={singleGroup}
|
||||
handleIssueAction={handleIssueAction}
|
||||
|
@ -20,6 +20,7 @@ type Props = {
|
||||
addIssueToGroup: () => void;
|
||||
currentState?: IState | null;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
dragDisabled: boolean;
|
||||
groupTitle: string;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
@ -36,6 +37,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
currentState,
|
||||
groupTitle,
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
dragDisabled,
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
@ -53,8 +55,6 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
|
||||
const isSubscribedIssues = router.pathname.includes("subscribed");
|
||||
|
||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||
|
||||
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
|
||||
@ -72,7 +72,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssue={isSubscribedIssues}
|
||||
disableAddIssue={disableAddIssueOption}
|
||||
viewProps={viewProps}
|
||||
/>
|
||||
{isCollapsed && (
|
||||
@ -154,7 +154,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
{selectedGroup !== "created_by" && (
|
||||
<div>
|
||||
{type === "issue"
|
||||
? !isSubscribedIssues && (
|
||||
? !disableAddIssueOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
|
||||
|
@ -11,6 +11,7 @@ type Props = {
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
viewProps: IIssueViewProps;
|
||||
@ -20,6 +21,7 @@ export const AllLists: React.FC<Props> = ({
|
||||
addIssueToGroup,
|
||||
handleIssueAction,
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
states,
|
||||
@ -49,6 +51,7 @@ export const AllLists: React.FC<Props> = ({
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
removeIssue={removeIssue}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
viewProps={viewProps}
|
||||
|
@ -39,6 +39,7 @@ type Props = {
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
viewProps: IIssueViewProps;
|
||||
@ -52,6 +53,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
@ -60,7 +62,6 @@ export const SingleList: React.FC<Props> = ({
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
const isSubscribedIssues = router.pathname.includes("subscribed");
|
||||
|
||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||
|
||||
@ -181,7 +182,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
{isArchivedIssues ? (
|
||||
""
|
||||
) : type === "issue" ? (
|
||||
!isSubscribedIssues && (
|
||||
!disableAddIssueOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
||||
|
@ -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());
|
||||
|
@ -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}
|
||||
|
@ -209,6 +209,15 @@ export const MyIssuesView: React.FC<Props> = ({
|
||||
Object.keys(filtersToDisplay).length > 0 &&
|
||||
nullFilters.length !== Object.keys(filtersToDisplay).length;
|
||||
|
||||
const isSubscribedIssuesRoute = router.pathname.includes("subscribed");
|
||||
const isMySubscribedIssues =
|
||||
(filters.subscriber &&
|
||||
filters.subscriber.length > 0 &&
|
||||
router.pathname.includes("my-issues")) ??
|
||||
false;
|
||||
|
||||
const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
@ -291,6 +300,7 @@ export const MyIssuesView: React.FC<Props> = ({
|
||||
handleIssueAction={handleIssueAction}
|
||||
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
|
||||
removeIssue={null}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
trashBox={trashBox}
|
||||
setTrashBox={setTrashBox}
|
||||
viewProps={{
|
||||
|
@ -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,
|
||||
|
@ -223,6 +223,15 @@ export const ProfileIssuesView = () => {
|
||||
Object.keys(filtersToDisplay).length > 0 &&
|
||||
nullFilters.length !== Object.keys(filtersToDisplay).length;
|
||||
|
||||
const isSubscribedIssuesRoute = router.pathname.includes("subscribed");
|
||||
const isMySubscribedIssues =
|
||||
(filters.subscriber &&
|
||||
filters.subscriber.length > 0 &&
|
||||
router.pathname.includes("my-issues")) ??
|
||||
false;
|
||||
|
||||
const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
@ -292,6 +301,7 @@ export const ProfileIssuesView = () => {
|
||||
handleIssueAction={handleIssueAction}
|
||||
openIssuesListModal={null}
|
||||
removeIssue={null}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
trashBox={trashBox}
|
||||
setTrashBox={setTrashBox}
|
||||
viewProps={{
|
||||
|
@ -14,7 +14,7 @@ import { Icon, Loader, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { EditOutlined } from "@mui/icons-material";
|
||||
// helpers
|
||||
import { render12HourFormatTime, renderLongDetailDateFormat } from "helpers/date-time.helper";
|
||||
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// fetch-keys
|
||||
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
|
||||
@ -35,6 +35,16 @@ export const ProfileSidebar = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
// Create a date object for the current time in the specified timezone
|
||||
const currentTime = new Date();
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: userProjectsData?.user_data.user_timezone,
|
||||
hour12: false, // Use 24-hour format
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const timeString = formatter.format(currentTime);
|
||||
|
||||
const userDetails = [
|
||||
{
|
||||
label: "Joined on",
|
||||
@ -44,7 +54,7 @@ export const ProfileSidebar = () => {
|
||||
label: "Timezone",
|
||||
value: (
|
||||
<span>
|
||||
{render12HourFormatTime(new Date())}{" "}
|
||||
{timeString}{" "}
|
||||
<span className="text-custom-text-200">{userProjectsData?.user_data.user_timezone}</span>
|
||||
</span>
|
||||
),
|
||||
|
@ -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,9 +1,10 @@
|
||||
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
|
||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
// components
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { TiptapExtensions } from "./extensions";
|
||||
import { TiptapEditorProps } from "./props";
|
||||
import { useImperativeHandle, useRef, forwardRef } from "react";
|
||||
import { ImageResizer } from "./extensions/image-resize";
|
||||
import { TableMenu } from "./table-menu";
|
||||
|
||||
@ -55,6 +56,12 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
@ -76,8 +83,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
|
||||
|
||||
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
|
||||
${noBorder ? "" : "border border-custom-border-200"} ${
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||
} ${customClassName}`;
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
|
||||
} ${customClassName}`;
|
||||
|
||||
if (!editor) return null;
|
||||
editorRef.current = editor;
|
||||
|
@ -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";
|
||||
|
133
web/components/web-view/add-comment.tsx
Normal file
133
web/components/web-view/add-comment.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
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 w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
186
web/components/web-view/create-update-link-form.tsx
Normal file
186
web/components/web-view/create-update-link-form.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
17
web/components/web-view/index.ts
Normal file
17
web/components/web-view/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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";
|
||||
export * from "./select-parent";
|
||||
export * from "./select-blocker";
|
||||
export * from "./select-blocked";
|
235
web/components/web-view/issue-activity.tsx
Normal file
235
web/components/web-view/issue-activity.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
// 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 { 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(() =>
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
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>
|
||||
);
|
||||
};
|
190
web/components/web-view/issue-attachments.tsx
Normal file
190
web/components/web-view/issue-attachments.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
// 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";
|
||||
|
||||
// icons
|
||||
import { FileText, ChevronRight, X, Image as ImageIcon } from "lucide-react";
|
||||
|
||||
// components
|
||||
import { Label, WebViewModal } from "components/web-view";
|
||||
import { DeleteAttachmentModal } from "components/issues";
|
||||
|
||||
// types
|
||||
import type { IIssueAttachment } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
const isImage = (fileName: string) => /\.(gif|jpe?g|tiff?|png|webp|bmp)$/i.test(fileName);
|
||||
|
||||
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 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));
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "File added successfully.",
|
||||
})
|
||||
);
|
||||
setIsOpen(false);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: "Something went wrong. please check file type & size (max 5 MB)",
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
[issueId, projectId, 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>
|
||||
<ChevronRight 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 flex items-center">
|
||||
{isImage(attachment.attributes.name) ? (
|
||||
<ImageIcon className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
|
||||
)}
|
||||
<span className="truncate">{attachment.attributes.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{allowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteAttachment(attachment);
|
||||
setAttachmentDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<X className="w-[18px] h-[18px] text-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-custom-primary-100/10 border border-dotted rounded-[4px] border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
|
||||
>
|
||||
Click to upload file here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
138
web/components/web-view/issue-link-list.tsx
Normal file
138
web/components/web-view/issue-link-list.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
// 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";
|
||||
import { Link as LinkIcon, Plus, Pencil, X } from "lucide-react";
|
||||
|
||||
// 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);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-[18px] h-[18px] text-custom-text-400" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleDeleteLink(link.id);
|
||||
}}
|
||||
>
|
||||
<X className="w-[18px] h-[18px] text-custom-text-400" />
|
||||
</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"
|
||||
>
|
||||
<Plus className="w-[18px] h-[18px] inline-block mr-1" />
|
||||
<span>Add</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
355
web/components/web-view/issue-properties-detail.tsx
Normal file
355
web/components/web-view/issue-properties-detail.tsx
Normal file
@ -0,0 +1,355 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react hook forms
|
||||
import { Control, Controller, useWatch } from "react-hook-form";
|
||||
|
||||
// icons
|
||||
import { BlockedIcon, BlockerIcon } from "components/icons";
|
||||
import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react";
|
||||
|
||||
// hooks
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
|
||||
// ui
|
||||
import { SecondaryButton, CustomDatePicker } from "components/ui";
|
||||
|
||||
// components
|
||||
import {
|
||||
Label,
|
||||
StateSelect,
|
||||
PrioritySelect,
|
||||
AssigneeSelect,
|
||||
EstimateSelect,
|
||||
ParentSelect,
|
||||
BlockerSelect,
|
||||
} 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 blockerIssue = useWatch({
|
||||
control,
|
||||
name: "blocker_issues",
|
||||
});
|
||||
|
||||
const blockedIssue = useWatch({
|
||||
control,
|
||||
name: "blocked_issues",
|
||||
});
|
||||
|
||||
const startDate = useWatch({
|
||||
control,
|
||||
name: "start_date",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
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">
|
||||
<LayoutGrid className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">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">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5862 14.5239C13.3459 14.5239 13.1416 14.4398 12.9733 14.2715C12.805 14.1032 12.7209 13.8989 12.7209 13.6585V3.76429C12.7209 3.52391 12.805 3.31958 12.9733 3.15132C13.1416 2.98306 13.3459 2.89893 13.5862 2.89893C13.8266 2.89893 14.031 2.98306 14.1992 3.15132C14.3675 3.31958 14.4516 3.52391 14.4516 3.76429V13.6585C14.4516 13.8989 14.3675 14.1032 14.1992 14.2715C14.031 14.4398 13.8266 14.5239 13.5862 14.5239ZM5.1629 14.5239C5.04676 14.5239 4.93557 14.5018 4.82932 14.4576C4.72308 14.4133 4.63006 14.3513 4.55025 14.2715C4.47045 14.1917 4.40843 14.0986 4.36419 13.9922C4.31996 13.8858 4.29785 13.7746 4.29785 13.6585V11.2643C4.29785 11.0239 4.38198 10.8196 4.55025 10.6513C4.71851 10.4831 4.92283 10.3989 5.16322 10.3989C5.40359 10.3989 5.60791 10.4831 5.77618 10.6513C5.94445 10.8196 6.02859 11.0239 6.02859 11.2643V13.6585C6.02859 13.7746 6.00647 13.8858 5.96223 13.9922C5.91801 14.0986 5.85599 14.1917 5.77618 14.2715C5.69638 14.3513 5.60325 14.4133 5.49678 14.4576C5.39033 14.5018 5.27904 14.5239 5.1629 14.5239ZM9.37473 14.5239C9.13436 14.5239 8.93003 14.4398 8.76176 14.2715C8.59349 14.1032 8.50936 13.8989 8.50936 13.6585V7.5143C8.50936 7.27391 8.59349 7.06958 8.76176 6.90132C8.93003 6.73306 9.13436 6.64893 9.37473 6.64893C9.61511 6.64893 9.81943 6.73306 9.98771 6.90132C10.156 7.06958 10.2401 7.27391 10.2401 7.5143V13.6585C10.2401 13.8989 10.156 14.1032 9.98771 14.2715C9.81943 14.4398 9.61511 14.5239 9.37473 14.5239Z"
|
||||
fill="#A3A3A3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="text-sm text-custom-text-400">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">
|
||||
<Users className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">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 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">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]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Parent</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value } }) => (
|
||||
<ParentSelect
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ parent: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<BlockerIcon height={16} width={16} />
|
||||
<span className="text-sm text-custom-text-400">Blocking</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="blocker_issues"
|
||||
render={({ field: { value } }) => (
|
||||
<BlockerSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
blocker_issues: val,
|
||||
blockers_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockerIssue &&
|
||||
blockerIssue.map((issue) => (
|
||||
<div
|
||||
key={issue.blocker_issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
const updatedBlockers = blockerIssue.filter(
|
||||
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocker_issues: updatedBlockers,
|
||||
blockers_list: updatedBlockers.map(
|
||||
(i) => i.blocker_issue_detail?.id ?? ""
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<BlockedIcon height={16} width={16} />
|
||||
<span className="text-sm text-custom-text-400">Blocked by</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="blocked_issues"
|
||||
render={({ field: { value } }) => (
|
||||
<BlockerSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
blocked_issues: val,
|
||||
blocks_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockedIssue &&
|
||||
blockedIssue.map((issue) => (
|
||||
<div
|
||||
key={issue.blocked_issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${issue?.blocked_issue_detail?.project_detail?.identifier}-${issue?.blocked_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
const updatedBlocked = blockedIssue.filter(
|
||||
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocked_issues: updatedBlocked,
|
||||
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-[6px]">
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarDays className="w-4 h-4 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Due date</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
value={value}
|
||||
wrapperClassName="w-full"
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="border-transparent !shadow-none !w-[6.75rem]"
|
||||
minDate={startDate ? new Date(startDate) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`ml-1 text-custom-primary-100 ${isViewAllOpen ? "-rotate-180" : ""}`}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
164
web/components/web-view/issue-web-view-form.tsx
Normal file
164
web/components/web-view/issue-web-view-form.tsx
Normal file
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
7
web/components/web-view/label.tsx
Normal file
7
web/components/web-view/label.tsx
Normal file
@ -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>
|
||||
);
|
94
web/components/web-view/select-assignee.tsx
Normal file
94
web/components/web-view/select-assignee.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// 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"
|
||||
)}
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
87
web/components/web-view/select-blocked.tsx
Normal file
87
web/components/web-view/select-blocked.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
|
||||
// types
|
||||
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const BlockedSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
|
||||
blocker_issue_detail: {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
sequence_id: i.sequence_id,
|
||||
project_detail: {
|
||||
id: i.project_id,
|
||||
identifier: i.project__identifier,
|
||||
name: i.project__name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
onChange([...(value || []), ...selectedIssues]);
|
||||
|
||||
setIsBlockedModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockedModalOpen}
|
||||
handleClose={() => setIsBlockedModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBlockedModalOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
87
web/components/web-view/select-blocker.tsx
Normal file
87
web/components/web-view/select-blocker.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
|
||||
// types
|
||||
import { BlockeIssueDetail, ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const BlockerSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
|
||||
blocker_issue_detail: {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
sequence_id: i.sequence_id,
|
||||
project_detail: {
|
||||
id: i.project_id,
|
||||
identifier: i.project__identifier,
|
||||
name: i.project__name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
onChange([...(value || []), ...selectedIssues]);
|
||||
|
||||
setIsBlockerModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockerModalOpen}
|
||||
handleClose={() => setIsBlockerModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBlockerModalOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
83
web/components/web-view/select-estimate.tsx
Normal file
83
web/components/web-view/select-estimate.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDown, 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"
|
||||
)}
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
76
web/components/web-view/select-parent.tsx
Normal file
76
web/components/web-view/select-parent.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// fetch key
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
// components
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
|
||||
// types
|
||||
import { ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
value: string | null;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ParentSelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParentIssuesListModal
|
||||
isOpen={isParentModalOpen}
|
||||
handleClose={() => setIsParentModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
issueId={issueId as string}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsParentModalOpen(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"
|
||||
}
|
||||
>
|
||||
{selectedParentIssue && issueDetails?.parent ? (
|
||||
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
|
||||
) : !selectedParentIssue && issueDetails?.parent ? (
|
||||
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
|
||||
) : (
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
83
web/components/web-view/select-priority.tsx
Normal file
83
web/components/web-view/select-priority.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } 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"}
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
89
web/components/web-view/select-state.tsx
Normal file
89
web/components/web-view/select-state.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// 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"}
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
108
web/components/web-view/sub-issues.tsx
Normal file
108
web/components/web-view/sub-issues.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
// react
|
||||
import React from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
|
||||
// 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)}>
|
||||
<X className="w-[18px] h-[18px] text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
108
web/components/web-view/web-view-modal.tsx
Normal file
108
web/components/web-view/web-view-modal.tsx
Normal file
@ -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-[20px] rounded-tl-[20px] 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="divide-y">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center justify-between gap-2 py-[14px]">
|
||||
<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 = (
|
||||
|
60
web/layouts/web-view-layout/index.tsx
Normal file
60
web/layouts/web-view-layout/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
// 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 = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const getIfInWebview = (userAgent: NavigatorID["userAgent"]) => {
|
||||
if (/iphone|ipod|ipad/.test(userAgent) || userAgent.includes("wv")) return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
const useMobileDetect = () => {
|
||||
const userAgent = typeof navigator === "undefined" ? "SSR" : navigator.userAgent;
|
||||
return getIfInWebview(userAgent);
|
||||
};
|
||||
|
||||
const WebViewLayout: React.FC<Props> = ({ children }) => {
|
||||
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
|
||||
|
||||
const isWebview = useMobileDetect();
|
||||
|
||||
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="h-screen w-full bg-custom-background-100">
|
||||
{error || !isWebview ? (
|
||||
<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;
|
@ -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>
|
||||
|
@ -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 WebViewLayout from "layouts/web-view-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 (
|
||||
<WebViewLayout>
|
||||
<div className="px-4 py-2 h-full">
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</WebViewLayout>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<WebViewLayout>
|
||||
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
|
||||
</WebViewLayout>
|
||||
);
|
||||
|
||||
return (
|
||||
<WebViewLayout>
|
||||
<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>
|
||||
</WebViewLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileWebViewIssueDetail;
|
@ -8,11 +8,19 @@ const nonValidatedRoutes = [
|
||||
"/reset-password",
|
||||
"/workspace-member-invitation",
|
||||
"/sign-up",
|
||||
"/m/",
|
||||
];
|
||||
|
||||
const validateRouteCheck = (route: string): boolean => {
|
||||
let validationToggle = false;
|
||||
const routeCheck = nonValidatedRoutes.find((_route: string) => _route === route);
|
||||
|
||||
let routeCheck = false;
|
||||
nonValidatedRoutes.forEach((_route: string) => {
|
||||
if (route.includes(_route)) {
|
||||
routeCheck = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (routeCheck) validationToggle = true;
|
||||
return validationToggle;
|
||||
};
|
||||
|
@ -204,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(
|
||||
|
Loading…
Reference in New Issue
Block a user