Compare commits

...
Author SHA1 Message Date
dakshesh14 080fb5deea fix: made editor authenticate route 2023-09-07 14:12:01 +05:30
dakshesh14 41242cc671 Merge branch 'feat/mobile_editor' of https://github.com/makeplane/plane into feat/mobile_editor 2023-09-07 13:57:28 +05:30
Aaryan KhandelwalandGitHub 5989f2476a fix: update plane logo (#2118) 2023-09-07 13:41:29 +05:30
Aaryan KhandelwalandGitHub 8ea6dd4e84 fix: onboarding role select dropdown text color (#2117) 2023-09-07 13:40:50 +05:30
NikhilandGitHub 39bc975994 fix: remove triage issue status from public boards (#2110) 2023-09-07 13:21:58 +05:30
NikhilandGitHub 866eead35f fix: issue comment ordering for public boards (#2108) 2023-09-07 13:21:05 +05:30
NikhilandGitHub 9c3510851d dev: update python packages (#2095) 2023-09-07 13:20:32 +05:30
Aaryan KhandelwalandGitHub 81436902a3 chore: option to switch access of a comment (#2116) 2023-09-07 12:54:30 +05:30
Aaryan KhandelwalandGitHub d26aa1b2da chore: render proper icons for due dates (#2114) 2023-09-07 12:53:49 +05:30
M. PalanikannanandGitHub b47c7d363f fix: Improved Image Deletion Logic, Image ID Issue in Modals and Performance Optimization in Editor (#2092)
* added improved delete logic in modals

* added better ts support

* impoved complexity to O(1) from O(n) for large docs

* regression: removed ts nocheck
2023-09-07 12:22:02 +05:30
LAKHAN BAHETI 3e674751e0 feat: editor for create issue 2023-09-06 20:54:36 +05:30
Aaryan KhandelwalandGitHub 85f797058d fix: edit issue comment mutation (#2109) 2023-09-06 19:02:59 +05:30
Dakshesh JainandGitHub 1655d0cb1c feat: view, create, update and delete comment (#2106)
* feat: update, delete link

refactor: using old fetch-key

* feat: issue activity with ability to view & add comment

feat: click on view more to view more options in the issue detail

* fix: upload image not working on mobile
2023-09-06 17:08:19 +05:30
Anmol Singh BhatiaandGitHub 58562dc4b7 fix: ui improvement and bug fixes (#2105)
* chore: workspace level typo fix

* fix: setting opacity fix
2023-09-06 16:15:21 +05:30
NikhilandGitHub 2ad46d7bfa fix: public issue list endpoint n+1 (#2099) 2023-09-06 16:04:12 +05:30
NikhilandGitHub 4f0cac37db fix: issue object for filtering (#2102) 2023-09-06 16:03:41 +05:30
sriram veeraghantaandGitHub b46a7481ae Merge pull request #2101 from makeplane/fix/gantt_issues
fix: don't render invalid dated blocks on the gantt chart
2023-09-06 15:00:03 +05:30
sriram veeraghantaandGitHub f11ae00201 Merge pull request #2100 from makeplane/feat/comment_reactions
feat: plane space comment reactions
2023-09-06 14:52:55 +05:30
Aaryan Khandelwal c5612ee7a3 fix: don't render invalid dated cycles and modules 2023-09-06 12:26:51 +05:30
Aaryan Khandelwal 0dd336aec8 fix: don't render invalid dated issues 2023-09-06 12:25:34 +05:30
sriram veeraghantaandGitHub 4b364f72b5 Merge pull request #2096 from makeplane/fix/login_redirection
fix: redirection after signing in on space
2023-09-06 12:21:08 +05:30
Aaryan Khandelwal 6d13332818 style: add shadow to reaction selector 2023-09-06 12:17:47 +05:30
Aaryan Khandelwal ac4127c93d chore: add tooltip for user info 2023-09-06 12:04:18 +05:30
Aaryan Khandelwal 60c3d1a6e9 feat: comment reactions 2023-09-06 11:59:57 +05:30
sriram veeraghantaandGitHub 70ed3c1fdf Merge pull request #2097 from makeplane/feat/issue_detail_for_webview
feat: issue detail for web-view
2023-09-05 17:56:33 +05:30
dakshesh14 b40059ea21 feat: add links and permission to perform actions
refactor: divided file into components
2023-09-05 17:06:17 +05:30
Bavisetti NarayanandGitHub 90276073cd fix: validation in automation task (#2094) 2023-09-05 16:53:53 +05:30
Aaryan Khandelwal 8d5ff1a628 fix: redirection after signing in on space 2023-09-05 16:12:17 +05:30
dakshesh14 065a4a3cf7 feat: issue detail for web view 2023-09-05 14:42:34 +05:30
MengYXandGitHub 928ae775f4 fix: replace Completion with ChatCompletion to use gpt-3.5-turbo model (#2066)
According to https://platform.openai.com/docs/guides/gpt/chat-completions-vs-completions , GPT-3.5 Turbo & GPT-4 is not working on **Legacy** Completions API
2023-09-05 13:11:31 +05:30
Aaryan KhandelwalandGitHub 900a4fcb0e fix: profile time according to the user timezone (#2089) 2023-09-04 18:48:33 +05:30
19c65b26d6 dev: docker environment deploy fixes (#2087)
* chore: updated space and web dockerfiles

* chore: updated compose file params

* updated nextjs config for basepath

* chore: updated package.json with new packages

* chore: modified space and web dockerfiles

* dev: update deploy configuration for deploy images

* dev: update docker folder for web

* dev: add semi colon for module exports

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-09-04 18:46:10 +05:30
Anmol Singh BhatiaandGitHub 71394d3316 chore: add issue option removed from subscribed issue page (#2088)
* chore: condition for subscribed page add issue option

* chore: condition for subscribed page add issue option
2023-09-04 18:42:31 +05:30
sriram veeraghantaandGitHub 9423472838 Env Fixes (#2086)
* fixing env issues

* removing husky
2023-09-04 18:03:31 +05:30
sriram veeraghantaandGitHub 729eabdd3f next config fixes in space app (#2084) 2023-09-04 17:55:40 +05:30
Aaryan KhandelwalandGitHub 03f204a71c chore: invalid url content (#2082) 2023-09-04 17:27:29 +05:30
guru_sainathandGitHub faf5a274cb fix: mutation latency in sidebar projects when user leaves the project (#2083)
* fix: mutation latency in sidebar projects when user leaves the project

* chore: remove console
2023-09-04 17:24:52 +05:30
86 changed files with 2968 additions and 692 deletions
-23
View File
@@ -1,23 +0,0 @@
#!/bin/sh
. "$(dirname -- "$0")/_/husky.sh"
changed_files=$(git diff --name-only HEAD~1)
web_changed=$(echo "$changed_files" | grep -E '^web/' || true)
space_changed=$(echo "$changed_files" | grep -E '^space/' || true)
echo $web_changed
echo $space_changed
if [ -n "$web_changed" ] && [ -n "$space_changed" ]; then
echo "Changes detected in both web and space. Building..."
yarn run lint
yarn run build
elif [ -n "$web_changed" ]; then
echo "Changes detected in web app. Building..."
yarn run lint --filter=web
yarn run build --filter=web
elif [ -n "$space_changed" ]; then
echo "Changes detected in space app. Building..."
yarn run lint --filter=space
yarn run build --filter=space
fi
+3 -3
View File
@@ -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(
{
+8 -1
View File
@@ -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,
)
+3 -3
View File
@@ -116,7 +116,7 @@ class WorkSpaceViewSet(BaseViewSet):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -203,7 +203,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
issue_count = (
Issue.objects.filter(workspace=OuterRef("id"))
Issue.issue_objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -1075,7 +1075,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
priority_order = ["urgent", "high", "medium", "low", None]
priority_distribution = (
Issue.objects.filter(
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
@@ -32,7 +32,7 @@ def archive_old_issues():
archive_in = project.archive_in
# Get all the issues whose updated_at in less that the archive_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@@ -64,21 +64,22 @@ def archive_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
if issues_to_update:
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
for issue in updated_issues
]
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in updated_issues
]
return
except Exception as e:
if settings.DEBUG:
@@ -99,7 +100,7 @@ def close_old_issues():
close_in = project.close_in
# Get all the issues whose updated_at in less that the close_in month
issues = Issue.objects.filter(
issues = Issue.issue_objects.filter(
Q(
project=project_id,
archived_at__isnull=True,
@@ -136,19 +137,20 @@ def close_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in updated_issues
]
if issues_to_update:
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
subscriber=False,
)
for issue in updated_issues
]
return
except Exception as e:
if settings.DEBUG:
+2 -2
View File
@@ -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,
+13 -13
View File
@@ -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
+5 -5
View File
@@ -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
+15 -1
View File
@@ -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
+10
View File
@@ -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 -2
View File
@@ -19,8 +19,7 @@
"devDependencies": {
"eslint-config-custom": "*",
"prettier": "latest",
"turbo": "latest",
"husky": "^8.0.3"
"turbo": "latest"
},
"packageManager": "yarn@1.22.19"
}
+2 -2
View File
@@ -1,8 +1,8 @@
# Base url for the API requests
NEXT_PUBLIC_API_BASE_URL=""
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="https://plane-space-dev.vercel.app"
NEXT_PUBLIC_DEPLOY_URL=""
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=232920797020-235n93bn7hh7628vdd69hq873129ng4o.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_CLIENTID=""
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=1
+7 -13
View 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>
+5 -4
View File
@@ -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>
);
});
@@ -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>
+8 -5
View File
@@ -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>
+16 -2
View File
@@ -1,5 +1,9 @@
import { useEffect } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
@@ -11,6 +15,8 @@ import { IssuePeekOverview } from "components/issues/peek-overview";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
export const ProjectDetailsView = observer(() => {
const router = useRouter();
@@ -55,8 +61,16 @@ export const ProjectDetailsView = observer(() => {
) : (
<>
{issueStore?.error ? (
<div className="text-sm text-center py-10 bg-custom-background-200 text-custom-text-100">
Something went wrong.
<div className="h-full w-full grid place-items-center p-6">
<div className="text-center">
<div className="h-52 w-52 bg-custom-background-80 rounded-full grid place-items-center mx-auto">
<div className="h-32 w-32 grid place-items-center">
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
</div>
</div>
<h1 className="text-3xl font-semibold mt-12">Oops! Something went wrong.</h1>
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
</div>
</div>
) : (
projectStore?.activeBoard && (
-36
View File
@@ -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}`;
};
+23
View File
@@ -12,3 +12,26 @@ export const timeAgo = (time: any) => {
time = +new Date();
}
};
/**
* @description Returns date and month, if date is of the current year
* @description Returns date, month adn year, if date is of a different year than current
* @param {string} date
* @example renderFullDate("2023-01-01") // 1 Jan
* @example renderFullDate("2021-01-01") // 1 Jan, 2021
*/
export const renderFullDate = (date: string): string => {
if (!date) return "";
const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const currentDate: Date = new Date();
const [year, month, day]: number[] = date.split("-").map(Number);
const formattedMonth: string = months[month - 1];
const formattedDay: string = day < 10 ? `0${day}` : day.toString();
if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`;
else return `${formattedDay} ${formattedMonth}, ${year}`;
};
+1
View File
@@ -13,6 +13,7 @@ const nextConfig = {
if (parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) {
const nextConfigWithNginx = withImages({ basePath: "/spaces", ...nextConfig });
module.exports = nextConfigWithNginx;
} else {
module.exports = nextConfig;
}
+17 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import React, { useEffect } from "react";
import Image from "next/image";
// assets
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.svg";
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,15 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="276.000000pt" height="276.000000pt" viewBox="0 0 276.000000 276.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,276.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M930 2300 l0 -450 460 0 460 0 0 -460 0 -460 450 0 450 0 0 910 0
910 -910 0 -910 0 0 -450z"/>
<path d="M10 1380 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
<path d="M930 460 l0 -450 450 0 450 0 0 450 0 450 -450 0 -450 0 0 -450z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 690 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="104" height="104" viewBox="0 0 104 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4226 95.3614C46.3393 95.3614 40.6853 94.2699 35.4608 92.0871C30.2363 89.9043 25.6917 86.8805 21.827 83.0158C17.9623 79.1511 14.9385 74.6065 12.7557 69.3819C10.5728 64.1574 9.48141 58.5035 9.48141 52.4202C9.48141 47.5535 10.1971 42.9731 11.6285 38.679C13.0598 34.3849 15.0638 30.4486 17.6402 26.8702L5.93876 15.1687C5.29464 14.5246 4.99048 13.7552 5.02626 12.8606C5.06205 11.966 5.402 11.1967 6.04612 10.5525C6.69023 9.90842 7.4596 9.58636 8.3542 9.58636C9.24881 9.58636 10.0182 9.90842 10.6623 10.5525L92.6799 92.6775C93.3241 93.3217 93.6461 94.0731 93.6461 94.9319C93.6461 95.7908 93.3241 96.5422 92.6799 97.1864C92.0358 97.8305 91.2665 98.1525 90.3718 98.1525C89.4772 98.1525 88.7079 97.8305 88.0638 97.1864L77.9726 87.2025C74.3942 89.779 70.4579 91.7829 66.1638 93.2143C61.8696 94.6457 57.2893 95.3614 52.4226 95.3614ZM52.4226 88.9202C56.3589 88.9202 60.0804 88.3655 63.5873 87.2562C67.0942 86.1469 70.3505 84.5903 73.3564 82.5864L54.3549 63.5849L48.3432 69.5967C47.6991 70.2408 46.9476 70.5807 46.0888 70.6165C45.2299 70.6523 44.4785 70.3481 43.8344 69.704L30.7373 56.4996C30.0932 55.8555 29.789 55.0503 29.8248 54.0842C29.8606 53.118 30.2005 52.3128 30.8446 51.6687C31.4888 51.0246 32.2939 50.7025 33.2601 50.7025C34.2263 50.7025 35.0314 51.0246 35.6755 51.6687L46.0888 62.1893L49.5241 58.754L22.2564 31.4864C20.2525 34.4922 18.6959 37.7486 17.5866 41.2555C16.4772 44.7623 15.9226 48.4839 15.9226 52.4202C15.9226 62.7976 19.4116 71.4753 26.3895 78.4533C33.3674 85.4312 42.0451 88.9202 52.4226 88.9202ZM87.2049 77.9702L82.5888 73.354C84.5927 70.3481 86.1493 67.0918 87.2586 63.5849C88.3679 60.078 88.9226 56.3565 88.9226 52.4202C88.9226 42.0427 85.4336 33.365 78.4557 26.3871C71.4777 19.4092 62.8 15.9202 52.4226 15.9202C48.4863 15.9202 44.7647 16.4748 41.2579 17.5842C37.751 18.6935 34.4946 20.2501 31.4888 22.254L26.8726 17.6378C30.451 15.0614 34.3873 13.0574 38.6814 11.6261C42.9755 10.1947 47.5559 9.479 52.4226 9.479C58.4343 9.479 64.0525 10.5883 69.277 12.8069C74.5015 15.0256 79.0461 18.0672 82.9108 21.9319C86.7755 25.7967 89.8172 30.3413 92.0358 35.5658C94.2544 40.7903 95.3638 46.4084 95.3638 52.4202C95.3638 57.2868 94.6481 61.8672 93.2167 66.1614C91.7853 70.4555 89.7814 74.3917 87.2049 77.9702ZM63.6946 54.3525L58.7564 49.5217L69.1696 39.1084C69.8138 38.4643 70.601 38.1422 71.5314 38.1422C72.4618 38.1422 73.2848 38.4643 74.0005 39.1084C74.7162 39.8241 75.0741 40.6471 75.0741 41.5775C75.0741 42.5079 74.7162 43.331 74.0005 44.0466L63.6946 54.3525Z" fill="#B9B9B9"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+33 -10
View File
@@ -93,16 +93,6 @@ class IssueService extends APIService {
});
}
async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise<any> {
return this.get(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`,
@@ -140,6 +130,39 @@ class IssueService extends APIService {
throw error?.response;
});
}
async createCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
data: {
reaction: string;
}
): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
reactionHex: string
): Promise<any> {
return this.delete(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/${reactionHex}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
export default IssueService;
+136 -22
View File
@@ -32,6 +32,20 @@ export interface IIssueDetailStore {
data: any
) => Promise<any>;
deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void;
addCommentReaction: (
workspaceId: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => void;
removeCommentReaction: (
workspaceId: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => void;
// issue reactions
addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
@@ -61,8 +75,17 @@ class IssueDetailStore implements IssueDetailStore {
details: observable.ref,
// actions
setPeekId: action,
fetchIssueDetails: action,
setPeekMode: action,
fetchIssueDetails: action,
addIssueComment: action,
updateIssueComment: action,
deleteIssueComment: action,
addCommentReaction: action,
removeCommentReaction: action,
addIssueReaction: action,
removeIssueReaction: action,
addIssueVote: action,
removeIssueVote: action,
});
this.issueService = new IssueService();
this.rootStore = _rootStore;
@@ -131,29 +154,32 @@ class IssueDetailStore implements IssueDetailStore {
data: any
) => {
try {
const issueCommentUpdateResponse = await this.issueService.updateIssueComment(
workspaceSlug,
projectId,
issueId,
commentId,
data
);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: this.details[issueId].comments.map((c) => ({
...c,
...(c.id === commentId ? data : {}),
})),
},
};
});
if (issueCommentUpdateResponse) {
const remainingComments = this.details[issueId].comments.filter((com) => com.id != commentId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: [...remainingComments, issueCommentUpdateResponse],
},
};
});
}
return issueCommentUpdateResponse;
await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data);
} catch (error) {
console.log("Failed to add issue comment");
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: issueComments,
},
};
});
}
};
@@ -175,6 +201,94 @@ class IssueDetailStore implements IssueDetailStore {
}
};
addCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => {
const newReaction = {
id: uuidv4(),
comment: commentId,
reaction: reactionHex,
actor_detail: this.rootStore.user.currentActor,
};
const newComments = this.details[issueId].comments.map((comment) => ({
...comment,
comment_reactions:
comment.id === commentId ? [...comment.comment_reactions, newReaction] : comment.comment_reactions,
}));
try {
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: [...newComments],
},
};
});
await this.issueService.createCommentReaction(workspaceSlug, projectId, commentId, {
reaction: reactionHex,
});
} catch (error) {
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: issueComments,
},
};
});
}
};
removeCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => {
try {
const comment = this.details[issueId].comments.find((c) => c.id === commentId);
const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? [];
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: this.details[issueId].comments.map((c) => ({
...c,
comment_reactions: c.id === commentId ? newCommentReactions : c.comment_reactions,
})),
},
};
});
await this.issueService.deleteCommentReaction(workspaceSlug, projectId, commentId, reactionHex);
} catch (error) {
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: issueComments,
},
};
});
}
};
addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
try {
runInAction(() => {
+4 -8
View File
@@ -62,17 +62,13 @@ class UserStore implements IUserStore {
return;
}
const currentPath = window.location.pathname + window.location.search;
this.fetchCurrentUser()
.then(() => {
if (!this.currentUser) {
const currentPath = window.location.pathname;
window.location.href = `/?next_path=${currentPath}`;
} else callback();
if (!this.currentUser) window.location.href = `/?next_path=${currentPath}`;
else callback();
})
.catch(() => {
const currentPath = window.location.pathname;
window.location.href = `/?next_path=${currentPath}`;
});
.catch(() => (window.location.href = `/?next_path=${currentPath}`));
};
fetchCurrentUser = async () => {
+21 -16
View File
@@ -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 {
+9 -4
View File
@@ -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>
+1 -1
View File
@@ -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
+4
View File
@@ -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());
+6 -3
View File
@@ -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>
);
+52 -11
View File
@@ -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>
)}
+4 -3
View File
@@ -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={{
+12 -2
View File
@@ -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>
),
@@ -1,8 +1,6 @@
import React from "react";
// next imports
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
@@ -21,6 +19,7 @@ import { RootStore } from "store/root";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useProjects from "hooks/use-projects";
// types
import { IProject } from "types";
@@ -42,6 +41,7 @@ export const ConfirmProjectLeaveModal: React.FC = observer(() => {
const { project } = store;
const { user } = useUser();
const { mutateProjects } = useProjects();
const { setToastAlert } = useToast();
@@ -59,9 +59,6 @@ export const ConfirmProjectLeaveModal: React.FC = observer(() => {
reset({ ...defaultValues });
};
project?.projectLeaveDetails &&
console.log("project leave confirmation modal", project?.projectLeaveDetails);
const onSubmit = async (data: any) => {
if (data) {
if (data.projectName === project?.projectLeaveDetails?.name) {
@@ -73,13 +70,7 @@ export const ConfirmProjectLeaveModal: React.FC = observer(() => {
user
)
.then((res) => {
mutate<IProject[]>(
PROJECTS_LIST(project.projectLeaveDetails.workspaceSlug.toString(), {
is_favorite: "all",
}),
(prevData) => prevData?.filter((project: IProject) => project.id !== data.id),
false
);
mutateProjects();
handleClose();
router.push(`/${workspaceSlug}/projects`);
})
+111 -111
View File
@@ -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,
];
+35 -23
View File
@@ -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";
+129
View File
@@ -0,0 +1,129 @@
import React from "react";
// next
import { useRouter } from "next/router";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { TipTapEditor } from "components/tiptap";
// icons
import { Send } from "lucide-react";
// ui
import { Icon, SecondaryButton, Tooltip, PrimaryButton } from "components/ui";
// types
import type { IIssueComment } from "types";
const defaultValues: Partial<IIssueComment> = {
access: "INTERNAL",
comment_html: "",
};
type Props = {
disabled?: boolean;
onSubmit: (data: IIssueComment) => Promise<void>;
};
const commentAccess = [
{
icon: "lock",
key: "INTERNAL",
label: "Private",
},
{
icon: "public",
key: "EXTERNAL",
label: "Public",
},
];
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const { projectDetails } = useProjectDetails();
const showAccessSpecifier = projectDetails?.is_deployed;
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<IIssueComment>({ defaultValues });
const handleAddComment = async (formData: IIssueComment) => {
if (!formData.comment_html || isSubmitting) return;
await onSubmit(formData).then(() => {
reset(defaultValues);
editorRef.current?.clearEditor();
});
};
return (
<form className="w-full flex gap-x-2" onSubmit={handleSubmit(handleAddComment)}>
<div className="relative flex-grow">
{showAccessSpecifier && (
<div className="absolute bottom-2 left-3 z-[1]">
<Controller
control={control}
name="access"
render={({ field: { onChange, value } }) => (
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
{commentAccess.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => onChange(access.key)}
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
value === access.key ? "bg-custom-background-80" : ""
}`}
>
<Icon
iconName={access.icon}
className={`w-4 h-4 -mt-1 ${
value === access.key ? "!text-custom-text-100" : "!text-custom-text-400"
}`}
/>
</button>
</Tooltip>
))}
</div>
)}
/>
</div>
)}
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={!value || value === "" ? "<p></p>" : value}
customClassName="p-3 min-h-[100px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
/>
)}
/>
</div>
<div className="inline">
<PrimaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
<Send className="w-4 h-4" />
</PrimaryButton>
</div>
</form>
);
};
@@ -0,0 +1,186 @@
// react
import React, { useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// react hooks form
import { useForm } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// fetch keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
// hooks
import useToast from "hooks/use-toast";
// ui
import { PrimaryButton, Input } from "components/ui";
// types
import type { linkDetails, IIssueLink } from "types";
type Props = {
isOpen: boolean;
data?: linkDetails;
links?: linkDetails[];
onSuccess: () => void;
};
export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
const { isOpen, data, links, onSuccess } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
title: "",
url: "",
},
});
useEffect(() => {
if (!data) return;
reset({
title: data.title,
url: data.url,
});
}, [data, reset]);
useEffect(() => {
if (!isOpen)
reset({
title: "",
url: "",
});
}, [isOpen, reset]);
const onSubmit = async (formData: IIssueLink) => {
if (!workspaceSlug || !projectId || !issueId) return;
const payload = { metadata: {}, ...formData };
if (!data)
await issuesService
.createIssueLink(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
payload
)
.then(() => {
onSuccess();
mutate(ISSUE_DETAILS(issueId.toString()));
})
.catch((err) => {
if (err?.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "This URL already exists for this issue.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
else {
const updatedLinks = links?.map((l) =>
l.id === data.id
? {
...l,
title: formData.title,
url: formData.url,
}
: l
);
mutate(
ISSUE_DETAILS(issueId.toString()),
(prevData) => ({ ...prevData, issue_link: updatedLinks }),
false
);
await issuesService
.updateIssueLink(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString(),
data!.id,
payload
)
.then(() => {
onSuccess();
mutate(ISSUE_DETAILS(issueId.toString()));
});
}
};
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
<div>
<div className="space-y-5">
<div className="mt-2 space-y-3">
<div>
<Input
id="url"
label="URL"
name="url"
type="url"
placeholder="https://..."
autoComplete="off"
error={errors.url}
register={register}
validations={{
required: "URL is required",
}}
/>
</div>
<div>
<Input
id="title"
label="Title (optional)"
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<PrimaryButton
type="submit"
loading={isSubmitting}
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
>
{data
? isSubmitting
? "Updating Link..."
: "Update Link"
: isSubmitting
? "Adding Link..."
: "Add Link"}
</PrimaryButton>
</div>
</form>
);
};
+14
View File
@@ -0,0 +1,14 @@
export * from "./web-view-modal";
export * from "./select-state";
export * from "./select-priority";
export * from "./issue-web-view-form";
export * from "./label";
export * from "./sub-issues";
export * from "./issue-attachments";
export * from "./issue-properties-detail";
export * from "./issue-link-list";
export * from "./create-update-link-form";
export * from "./issue-activity";
export * from "./select-assignee";
export * from "./select-estimate";
export * from "./add-comment";
+233
View File
@@ -0,0 +1,233 @@
// react
import React from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// fetch key
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// components
import { Label, AddComment } from "components/web-view";
import { CommentCard } from "components/issues/comment";
import { ActivityIcon, ActivityMessage } from "components/core";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// ui
import { Icon } from "components/ui";
// types
import type { IIssue, IIssueComment } from "types";
type Props = {
allowed: boolean;
issueDetails: IIssue;
};
export const IssueActivity: React.FC<Props> = (props) => {
const { issueDetails, allowed } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { data: issueActivities, mutate: mutateIssueActivity } = useSWR(
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.getIssueActivities(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
const handleCommentUpdate = async (comment: any) => {
if (!workspaceSlug || !projectId || !issueId) return;
await issuesService
.patchIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
comment.id,
comment,
user
)
.then(() => mutateIssueActivity());
};
const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
await issuesService
.deleteIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
commentId,
user
)
.then(() => mutateIssueActivity());
};
const handleAddComment = async (formData: IIssueComment) => {
if (!workspaceSlug || !issueDetails) return;
await issuesService
.createIssueComment(
workspaceSlug.toString(),
issueDetails.project,
issueDetails.id,
formData,
user
)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
);
};
return (
<div>
<Label>Activity</Label>
<div className="mt-1 space-y-[6px] p-2 border rounded-[4px]">
<ul role="list" className="-mb-4">
{issueActivities?.map((activityItem, index) => {
// determines what type of action is performed
const message = activityItem.field ? (
<ActivityMessage activity={activityItem} />
) : (
"created the issue."
);
if ("field" in activityItem && activityItem.field !== "updated_by") {
return (
<li key={activityItem.id}>
<div className="relative pb-1">
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<Icon
iconName="history"
className="text-sm text-custom-text-200"
/>
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name.charAt(0)
: activityItem.actor_detail.display_name.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200 break-words">
{activityItem.field === "archived_at" &&
activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">
{activityItem.actor_detail.first_name} Bot
</span>
) : (
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name
: activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>
</div>
</div>
</div>
</div>
</li>
);
} else if ("comment_json" in activityItem)
return (
<div key={activityItem.id} className="my-4">
<CommentCard
workspaceSlug={workspaceSlug as string}
comment={activityItem as any}
onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete}
/>
</div>
);
})}
<li>
<div className="my-4">
<AddComment
onSubmit={handleAddComment}
disabled={
!allowed ||
!issueDetails ||
issueDetails.state === "closed" ||
issueDetails.state === "archived"
}
/>
</div>
</li>
</ul>
</div>
</div>
);
};
@@ -0,0 +1,181 @@
// react
import React, { useState, useCallback } from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// react dropzone
import { useDropzone } from "react-dropzone";
// fetch key
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// hooks
import useToast from "hooks/use-toast";
// icons
import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
// components
import { Label, WebViewModal } from "components/web-view";
import { DeleteAttachmentModal } from "components/issues";
// types
import type { IIssueAttachment } from "types";
type Props = {
allowed: boolean;
};
export const IssueAttachments: React.FC<Props> = (props) => {
const { allowed } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
const { setToastAlert } = useToast();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (!acceptedFiles[0] || !workspaceSlug) return;
const formData = new FormData();
formData.append("asset", acceptedFiles[0]);
formData.append(
"attributes",
JSON.stringify({
name: acceptedFiles[0].name,
size: acceptedFiles[0].size,
})
);
setIsLoading(true);
issuesService
.uploadIssueAttachment(
workspaceSlug as string,
projectId as string,
issueId as string,
formData
)
.then((res) => {
mutate<IIssueAttachment[]>(
ISSUE_ATTACHMENTS(issueId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
setToastAlert({
type: "success",
title: "Success!",
message: "File added successfully.",
});
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
setToastAlert({
type: "error",
title: "error!",
message: "Something went wrong. please check file type & size (max 5 MB)",
});
});
},
[issueId, projectId, setToastAlert, workspaceSlug]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: 5 * 1024 * 1024,
disabled: !allowed || isLoading,
});
const { data: attachments } = useSWR<IIssueAttachment[]>(
workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.getIssueAttachment(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
return (
<div>
<DeleteAttachmentModal
isOpen={allowed && attachmentDeleteModal}
setIsOpen={setAttachmentDeleteModal}
data={deleteAttachment}
/>
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file">
<div className="space-y-6">
<div
{...getRootProps()}
className={`border-b w-full py-2 text-custom-text-100 px-2 flex justify-between items-center ${
!allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer"
}`}
>
<input {...getInputProps()} />
{isLoading ? (
<p className="text-center">Uploading...</p>
) : (
<>
<h3 className="text-lg">Upload</h3>
<ChevronRightIcon className="w-5 h-5" />
</>
)}
</div>
</div>
</WebViewModal>
<Label>Attachments</Label>
<div className="mt-1 space-y-[6px]">
{attachments?.map((attachment) => (
<div
key={attachment.id}
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
>
<Link href={attachment.asset}>
<a target="_blank" className="text-custom-text-200 truncate">
{attachment.attributes.name}
</a>
</Link>
{allowed && (
<button
type="button"
onClick={() => {
setDeleteAttachment(attachment);
setAttachmentDeleteModal(true);
}}
>
<XMarkIcon className="w-5 h-5 text-custom-text-100" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => setIsOpen(true)}
className="bg-custom-primary-100/10 border border-dotted border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
>
Click to upload file here
</button>
</div>
</div>
);
};
+137
View File
@@ -0,0 +1,137 @@
// react
import React, { useState } from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// icons
import { LinkIcon, PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// components
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
// ui
import { SecondaryButton } from "components/ui";
// fetch keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
// types
import type { IIssue } from "types";
type Props = {
allowed: boolean;
issueDetails: IIssue;
};
export const IssueLinks: React.FC<Props> = (props) => {
const { issueDetails, allowed } = props;
const links = issueDetails?.issue_link;
const [isOpen, setIsOpen] = useState(false);
const [selectedLink, setSelectedLink] = useState<string | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
const updatedLinks = issueDetails.issue_link.filter((l) => l.id !== linkId);
mutate<IIssue>(
ISSUE_DETAILS(issueDetails.id),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issuesService
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetails.id, linkId)
.then((res) => {
mutate(ISSUE_DETAILS(issueDetails.id));
})
.catch((err) => {
console.log(err);
});
};
return (
<div>
<WebViewModal
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
setSelectedLink(null);
}}
modalTitle={selectedLink ? "Update Link" : "Add Link"}
>
<CreateUpdateLinkForm
isOpen={isOpen}
links={links}
onSuccess={() => {
setIsOpen(false);
setSelectedLink(null);
}}
data={links?.find((link) => link.id === selectedLink)}
/>
</WebViewModal>
<Label>Links</Label>
<div className="mt-1 space-y-[6px]">
{links?.map((link) => (
<div
key={link.id}
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
>
<Link href={link.url}>
<a target="_blank" className="text-custom-text-200 truncate">
<span>
<LinkIcon className="w-4 h-4 inline-block mr-1" />
</span>
<span>{link.title}</span>
</a>
</Link>
{allowed && (
<div className="flex gap-2 items-center">
<button
type="button"
onClick={() => {
setIsOpen(true);
setSelectedLink(link.id);
}}
>
<PencilIcon className="w-5 h-5 text-custom-text-100" />
</button>
<button
type="button"
onClick={() => {
handleDeleteLink(link.id);
}}
>
<TrashIcon className="w-5 h-5 text-red-500 hover:bg-red-500/20" />
</button>
</div>
)}
</div>
))}
<SecondaryButton
type="button"
disabled={!allowed}
onClick={() => setIsOpen(true)}
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
>
<PlusIcon className="w-4 h-4 inline-block mr-1" />
<span>Add</span>
</SecondaryButton>
</div>
</div>
);
};
@@ -0,0 +1,146 @@
// react
import React, { useState } from "react";
// react hook forms
import { Control, Controller } from "react-hook-form";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// ui
import { Icon, SecondaryButton } from "components/ui";
// components
import {
Label,
StateSelect,
PrioritySelect,
AssigneeSelect,
EstimateSelect,
} from "components/web-view";
// types
import type { IIssue } from "types";
type Props = {
control: Control<IIssue, any>;
submitChanges: (data: Partial<IIssue>) => Promise<void>;
};
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
const { control, submitChanges } = props;
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
const { isEstimateActive } = useEstimateOption();
return (
<div>
<Label>Details</Label>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="grid_view" />
<span className="text-sm text-custom-text-200">State</span>
</div>
<div>
<Controller
control={control}
name="state"
render={({ field: { value } }) => (
<StateSelect
value={value}
onChange={(val: string) => submitChanges({ state: val })}
/>
)}
/>
</div>
</div>
</div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="signal_cellular_alt" />
<span className="text-sm text-custom-text-200">Priority</span>
</div>
<div>
<Controller
control={control}
name="priority"
render={({ field: { value } }) => (
<PrioritySelect
value={value}
onChange={(val: string) => submitChanges({ priority: val })}
/>
)}
/>
</div>
</div>
</div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="person" />
<span className="text-sm text-custom-text-200">Assignee</span>
</div>
<div>
<Controller
control={control}
name="assignees_list"
render={({ field: { value } }) => (
<AssigneeSelect
value={value}
onChange={(val: string) => submitChanges({ assignees_list: [val] })}
/>
)}
/>
</div>
</div>
</div>
{isViewAllOpen && (
<>
{isEstimateActive && (
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<span className="text-sm text-custom-text-200">Estimate</span>
</div>
<div>
<Controller
control={control}
name="estimate_point"
render={({ field: { value } }) => (
<EstimateSelect
value={value}
onChange={(val) => submitChanges({ estimate_point: val })}
/>
)}
/>
</div>
</div>
</div>
)}
</>
)}
<div className="mb-[6px]">
<SecondaryButton
type="button"
onClick={() => setIsViewAllOpen((prev) => !prev)}
className="w-full flex justify-center items-center gap-1 !py-2"
>
<span className="text-base text-custom-primary-100">
{isViewAllOpen ? "View less" : "View all"}
</span>
<ChevronDownIcon
size={16}
className={`ml-1 text-custom-primary-100 ${isViewAllOpen ? "-rotate-180" : ""}`}
/>
</SecondaryButton>
</div>
</div>
);
};
@@ -0,0 +1,164 @@
// react
import React, { useCallback, useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// react hook forms
import { Controller } from "react-hook-form";
// hooks
import { useDebouncedCallback } from "use-debounce";
import useReloadConfirmations from "hooks/use-reload-confirmation";
// ui
import { TextArea } from "components/ui";
// components
import { TipTapEditor } from "components/tiptap";
import { Label } from "components/web-view";
// types
import type { IIssue } from "types";
type Props = {
isAllowed: boolean;
issueDetails: IIssue;
submitChanges: (data: Partial<IIssue>) => Promise<void>;
register: any;
control: any;
watch: any;
handleSubmit: any;
};
export const IssueWebViewForm: React.FC<Props> = (props) => {
const { isAllowed, issueDetails, submitChanges, register, control, watch, handleSubmit } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const [characterLimit, setCharacterLimit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const { setShowAlert } = useReloadConfirmations();
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
const debouncedTitleSave = useDebouncedCallback(async () => {
setTimeout(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 500);
}, 1000);
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => {
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
await submitChanges({
name: formData.name ?? "",
description_html: formData.description_html ?? "<p></p>",
});
},
[submitChanges]
);
return (
<>
<div className="flex flex-col">
<Label>Title</Label>
<div className="relative">
{isAllowed ? (
<TextArea
id="name"
name="name"
placeholder="Enter issue name"
register={register}
onFocus={() => setCharacterLimit(true)}
onChange={(e) => {
setCharacterLimit(false);
setIsSubmitting("submitting");
debouncedTitleSave();
}}
required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
role="textbox"
disabled={!isAllowed}
/>
) : (
<h4 className="break-words text-2xl font-semibold">{issueDetails?.name}</h4>
)}
{characterLimit && isAllowed && (
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
<span
className={`${
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
}`}
>
{watch("name").length}
</span>
/255
</div>
)}
</div>
</div>
<div>
<Label>Description</Label>
<div className="relative">
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => {
if (!value) return <></>;
return (
<TipTapEditor
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? "<p></p>"
: value
}
workspaceSlug={workspaceSlug!.toString()}
debouncedUpdatesEnabled={true}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
customClassName={
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
}
noBorder={!isAllowed}
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
setIsSubmitting("submitting");
onChange(description_html);
handleSubmit(handleDescriptionFormSubmit)().finally(() =>
setIsSubmitting("submitted")
);
}}
editable={isAllowed}
/>
);
}}
/>
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div>
</div>
</>
);
};
+7
View 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>
);
@@ -0,0 +1,95 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// services
import projectService from "services/project.service";
// fetch key
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// components
import { Avatar } from "components/ui/avatar";
import { WebViewModal } from "./web-view-modal";
type Props = {
value: string[];
onChange: (value: any) => void;
disabled?: boolean;
};
export const AssigneeSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const selectedAssignees = members?.filter((member) => value?.includes(member.member.id));
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select state"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={
members?.map((member) => ({
label: member.member.display_name,
value: member.member.id,
checked: value?.includes(member.member.id),
icon: <Avatar user={member.member} />,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(member.member.id);
},
})) || []
}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2">
<Avatar user={selectedAssignees?.[0].member} />
<span className="text-custom-text-100 text-xs">
{selectedAssignees?.length} Assignees
</span>
</div>
) : (
"No assignees"
)}
{/* {selectedAssignee?.member.display_name || "Select assignee"} */}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};
@@ -0,0 +1,83 @@
// react
import React, { useState } from "react";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// components
import { WebViewModal } from "./web-view-modal";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const EstimateSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
const { estimatePoints } = useEstimateOption();
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select estimate"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={[
{
label: "None",
value: null,
checked: value === null,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(null);
},
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
},
...estimatePoints?.map((point) => ({
label: point.value,
value: point.key,
checked: point.key === value,
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(point.key);
},
})),
]}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{value ? (
<div className="flex items-center gap-x-1.5">
<PlayIcon className="h-4 w-4 -rotate-90" />
<span>{estimatePoints?.find((e) => e.key === value)?.value}</span>
</div>
) : (
"No estimate"
)}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};
@@ -0,0 +1,83 @@
// react
import React, { useState } from "react";
// icons
import { ChevronDownIcon } from "lucide-react";
// constants
import { PRIORITIES } from "constants/project";
// components
import { getPriorityIcon } from "components/icons";
import { WebViewModal } from "./web-view-modal";
// helpers
import { capitalizeFirstLetter } from "helpers/string.helper";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const PrioritySelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select priority"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={
PRIORITIES?.map((priority) => ({
label: priority ? capitalizeFirstLetter(priority) : "None",
value: priority,
checked: priority === value,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(priority);
},
icon: (
<span
className={`text-left text-xs capitalize rounded ${
priority === "urgent"
? "border-red-500/20 text-red-500"
: priority === "high"
? "border-orange-500/20 text-orange-500"
: priority === "medium"
? "border-yellow-500/20 text-yellow-500"
: priority === "low"
? "border-green-500/20 text-green-500"
: "border-custom-border-200 text-custom-text-200"
}`}
>
{getPriorityIcon(priority, "text-sm")}
</span>
),
})) || []
}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{value ? capitalizeFirstLetter(value) : "None"}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};
+89
View File
@@ -0,0 +1,89 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// services
import stateService from "services/state.service";
// fetch key
import { STATES_LIST } from "constants/fetch-keys";
// components
import { getStateGroupIcon } from "components/icons";
import { WebViewModal } from "./web-view-modal";
// helpers
import { getStatesList } from "helpers/state.helper";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const StateSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
const selectedState = states?.find((s) => s.id === value);
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select state"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={
states?.map((state) => ({
label: state.name,
value: state.id,
checked: state.id === selectedState?.id,
icon: getStateGroupIcon(state.group, "16", "16", state.color),
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(state.id);
},
})) || []
}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{selectedState?.name || "Select a state"}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};
+108
View File
@@ -0,0 +1,108 @@
// react
import React from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
// services
import issuesService from "services/issues.service";
// fetch key
import { SUB_ISSUES } from "constants/fetch-keys";
// hooks
import useUser from "hooks/use-user";
// ui
import { Spinner } from "components/ui";
import { IIssue } from "types";
// components
import { Label } from "components/web-view";
type Props = {
issueDetails?: IIssue;
};
export const SubIssueList: React.FC<Props> = (props) => {
const { issueDetails } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
const { data: subIssuesResponse } = useSWR(
workspaceSlug && issueDetails ? SUB_ISSUES(issueDetails.id) : null,
workspaceSlug && issueDetails
? () =>
issuesService.subIssues(workspaceSlug as string, issueDetails.project, issueDetails.id)
: null
);
const handleSubIssueRemove = (issue: any) => {
if (!workspaceSlug || !issueDetails || !user) return;
mutate(
SUB_ISSUES(issueDetails.id),
(prevData) => {
if (!prevData) return prevData;
const stateDistribution = { ...prevData.state_distribution };
const issueGroup = issue.state_detail.group;
stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1;
return {
state_distribution: stateDistribution,
sub_issues: prevData.sub_issues.filter((i: any) => i.id !== issue.id),
};
},
false
);
issuesService
.patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
.finally(() => mutate(SUB_ISSUES(issueDetails.id)));
};
return (
<div>
<Label>Sub Issues</Label>
<div className="p-3 border border-custom-border-200 rounded-[4px]">
{!subIssuesResponse && (
<div className="flex justify-center items-center">
<Spinner />
Loading...
</div>
)}
{subIssuesResponse?.sub_issues.length === 0 && (
<div className="flex justify-center items-center">
<p className="text-sm text-custom-text-200">No sub issues</p>
</div>
)}
{subIssuesResponse?.sub_issues?.map((subIssue) => (
<div key={subIssue.id} className="flex items-center justify-between gap-2 py-2">
<div className="flex items-center">
<p className="mr-3 text-sm text-custom-text-300">
{subIssue.project_detail.identifier}-{subIssue.sequence_id}
</p>
<p className="text-sm font-normal">{subIssue.name}</p>
</div>
<button type="button" onClick={() => handleSubIssueRemove(subIssue)}>
<XMarkIcon className="w-5 h-5 text-custom-text-200" />
</button>
</div>
))}
</div>
</div>
);
};
+108
View 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-[4px] rounded-tl-[4px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:mt-8 w-full">
<div className="flex justify-between items-center w-full">
<Dialog.Title
as="h3"
className="text-2xl font-semibold leading-6 text-custom-text-100"
>
{modalTitle}
</Dialog.Title>
<button
type="button"
className="inline-flex justify-center items-center p-2 rounded-md text-custom-text-200 hover:text-custom-text-100 focus:outline-none"
onClick={handleClose}
>
<XMarkIcon className="w-6 h-6 text-custom-text-200" />
</button>
</div>
<div className="mt-6">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
type OptionsProps = {
options: Array<{
label: string;
value: string | null;
checked: boolean;
icon?: any;
onClick: () => void;
}>;
};
const Options: React.FC<OptionsProps> = ({ options }) => (
<div className="space-y-6">
{options.map((option) => (
<div key={option.value} className="flex items-center justify-between gap-2 py-2">
<div className="flex items-center gap-x-2">
<input
type="checkbox"
checked={option.checked}
onChange={option.onClick}
className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4"
/>
{option.icon}
<p className="text-sm font-normal">{option.label}</p>
</div>
</div>
))}
</div>
);
WebViewModal.Options = Options;
WebViewModal.Options.displayName = "WebViewModal.Options";
+3 -1
View File
@@ -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 = (
+55
View File
@@ -0,0 +1,55 @@
// swr
import useSWR from "swr";
// services
import userService from "services/user.service";
// fetch keys
import { CURRENT_USER } from "constants/fetch-keys";
// icons
import { AlertCircle } from "lucide-react";
// ui
import { Spinner } from "components/ui";
type Props = {
fullScreen?: boolean;
children: React.ReactNode;
};
const WebViewLayout: React.FC<Props> = ({ children, fullScreen = false }) => {
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
if (!currentUser && !error) {
return (
<div className="h-screen grid place-items-center p-4">
<div className="flex flex-col items-center gap-3 text-center">
<h3 className="text-xl">Loading your profile...</h3>
<Spinner />
</div>
</div>
);
}
return (
<div
className={`${
fullScreen
? "h-screen w-full overflow-hidden bg-custom-background-100"
: "flex-col blur-none shadow-none backdrop:backdrop-blur-none justify-center items-center"
}`}
>
{error ? (
<div className="flex flex-col items-center justify-center gap-y-3 h-full text-center text-custom-text-200">
<AlertCircle size={64} />
<h2 className="text-2xl font-semibold">You are not authorized to view this page.</h2>
</div>
) : (
children
)}
</div>
);
};
export default WebViewLayout;
-192
View File
@@ -1,192 +0,0 @@
import { TipTapEditor } from "components/tiptap";
import type { NextPage } from "next";
import { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import issuesService from "services/issues.service";
import { ICurrentUserResponse, IIssue } from "types";
import useReloadConfirmations from "hooks/use-reload-confirmation";
import { Spinner } from "components/ui";
import Image404 from "public/404.svg";
import DefaultLayout from "layouts/default-layout";
import Image from "next/image";
import userService from "services/user.service";
import { useRouter } from "next/router";
const Editor: NextPage = () => {
const [user, setUser] = useState<ICurrentUserResponse | undefined>();
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [isLoading, setIsLoading] = useState("false");
const { setShowAlert } = useReloadConfirmations();
const [cookies, setCookies] = useState<any>({});
const [issueDetail, setIssueDetail] = useState<IIssue | null>(null);
const router = useRouter();
const { editable } = router.query;
const {
handleSubmit,
watch,
setValue,
control,
formState: { errors },
} = useForm<IIssue>({
defaultValues: {
name: "",
description: "",
description_html: "",
},
});
const getCookies = () => {
const cookies = document.cookie.split(";");
const cookieObj: any = {};
cookies.forEach((cookie) => {
const cookieArr = cookie.split("=");
cookieObj[cookieArr[0].trim()] = cookieArr[1];
});
setCookies(cookieObj);
return cookieObj;
};
const getIssueDetail = async (cookiesData: any) => {
try {
setIsLoading("true");
const userData = await userService.currentUser();
setUser(userData);
const issueDetail = await issuesService.retrieve(
cookiesData.MOBILE_slug,
cookiesData.MOBILE_project_id,
cookiesData.MOBILE_issue_id
);
setIssueDetail(issueDetail);
setIsLoading("false");
setValue("description_html", issueDetail.description_html);
setValue("description", issueDetail.description);
} catch (e) {
setIsLoading("error");
console.log(e);
}
};
useEffect(() => {
const cookiesData = getCookies();
getIssueDetail(cookiesData);
}, []);
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
const submitChanges = async (
formData: Partial<IIssue>,
workspaceSlug: string,
projectId: string,
issueId: string
) => {
if (!workspaceSlug || !projectId || !issueId) return;
const payload: Partial<IIssue> = {
...formData,
};
delete payload.blocker_issues;
delete payload.blocked_issues;
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.catch((e) => {
console.log(e);
});
};
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => {
if (!formData) return;
await submitChanges(
{
name: issueDetail?.name ?? "",
description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
},
cookies.MOBILE_slug,
cookies.MOBILE_project_id,
cookies.MOBILE_issue_id
);
},
[submitChanges]
);
return isLoading === "error" ? (
<ErrorEncountered />
) : isLoading === "true" ? (
<div className="grid place-items-center h-screen w-full">
<Spinner />
</div>
) : (
<div className="flex blur-none shadow-none backdrop:backdrop-blur-none justify-center items-center">
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
borderOnFocus={false}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
editable={editable === "true"}
noBorder={true}
workspaceSlug={cookies.MOBILE_slug ?? ""}
debouncedUpdatesEnabled={true}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
customClassName="min-h-[150px] shadow-sm"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
setIsSubmitting("submitting");
onChange(description_html);
setValue("description", description);
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
setIsSubmitting("submitted");
});
}}
/>
)}
/>
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div>
);
};
const ErrorEncountered: NextPage = () => (
<DefaultLayout>
<div className="grid max-h-fit place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-40 w-40 lg:h-40 lg:w-40">
<Image src={Image404} layout="fill" alt="404- Page not found" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
</div>
</div>
</div>
</DefaultLayout>
);
export default Editor;
@@ -187,7 +187,7 @@ const GeneralSettings: NextPage = () => {
/>
<form onSubmit={handleSubmit(onSubmit)} className="p-8">
<SettingsHeader />
<div className="space-y-8 sm:space-y-12 opacity-60">
<div className={`space-y-8 sm:space-y-12 ${isAdmin ? "" : "opacity-60"}`}>
<div className="grid grid-cols-12 items-start gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold">Icon & Name</h4>
+5 -7
View File
@@ -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>
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
// next
import type { NextPage } from "next";
import { useRouter } from "next/router";
// cookies
import Cookies from "js-cookie";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// layouts
import WebViewLayout from "layouts/web-view-layout";
// components
import { TipTapEditor } from "components/tiptap";
import { PrimaryButton, Spinner } from "components/ui";
const Editor: NextPage = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { workspaceSlug, editable } = router.query;
const isEditable = editable === "true";
const {
watch,
setValue,
control,
formState: { errors },
} = useForm({
defaultValues: {
data: "",
data_html: "",
},
});
useEffect(() => {
setIsLoading(true);
if (!isEditable) return;
setIsLoading(false);
const data_html = Cookies.get("data_html");
setValue("data_html", data_html ?? "");
}, [isEditable, setValue]);
return (
<WebViewLayout fullScreen={isLoading}>
{isLoading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : (
<>
<Controller
name="data_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
borderOnFocus={false}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("data_html")
: value
}
editable={isEditable}
noBorder={true}
workspaceSlug={workspaceSlug?.toString() ?? ""}
debouncedUpdatesEnabled={true}
customClassName="min-h-[150px] shadow-sm"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("data_html", description_html);
setValue("data", JSON.stringify(description));
}}
/>
)}
/>
{isEditable && (
<PrimaryButton
className="mt-4 w-[calc(100%-30px)] h-[45px] mx-[15px] text-[17px]"
onClick={() => {
console.log(
"submitted",
JSON.stringify({
data_html: watch("data_html"),
})
);
}}
>
Submit
</PrimaryButton>
)}
</>
)}
</WebViewLayout>
);
};
export default Editor;
@@ -0,0 +1,175 @@
// react
import React, { useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// react hook forms
import { useForm } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// fetch key
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// hooks
import useUser from "hooks/use-user";
import useProjectMembers from "hooks/use-project-members";
// layouts
import DefaultLayout from "layouts/default-layout";
// ui
import { Spinner } from "components/ui";
// components
import {
IssueWebViewForm,
SubIssueList,
IssueAttachments,
IssuePropertiesDetail,
IssueLinks,
IssueActivity,
} from "components/web-view";
// types
import type { IIssue } from "types";
const MobileWebViewIssueDetail = () => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const memberRole = useProjectMembers(
workspaceSlug as string,
projectId as string,
!!workspaceSlug && !!projectId
);
const isAllowed = Boolean(memberRole.isMember || memberRole.isOwner);
const { user } = useUser();
const { register, control, reset, handleSubmit, watch } = useForm<IIssue>({
defaultValues: {
name: "",
description: "",
description_html: "",
state: "",
},
});
const {
data: issueDetails,
mutate: mutateIssueDetails,
error,
} = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
useEffect(() => {
if (!issueDetails) return;
reset({
...issueDetails,
name: issueDetails.name,
description: issueDetails.description,
description_html: issueDetails.description_html,
state: issueDetails.state,
assignees_list:
issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
labels: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset]);
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload: Partial<IIssue> = {
...formData,
};
delete payload.blocker_issues;
delete payload.blocked_issues;
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
);
if (!error && !issueDetails)
return (
<DefaultLayout>
<div className="px-4 py-2 h-full">
<div className="h-full flex justify-center items-center">
<Spinner />
Loading...
</div>
</div>
</DefaultLayout>
);
if (error)
return (
<DefaultLayout>
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
</DefaultLayout>
);
return (
<DefaultLayout>
<div className="px-6 py-2 h-full overflow-auto space-y-3">
<IssueWebViewForm
isAllowed={isAllowed}
issueDetails={issueDetails!}
submitChanges={submitChanges}
register={register}
control={control}
watch={watch}
handleSubmit={handleSubmit}
/>
<SubIssueList issueDetails={issueDetails!} />
<IssuePropertiesDetail control={control} submitChanges={submitChanges} />
<IssueAttachments allowed={isAllowed} />
<IssueLinks allowed={isAllowed} issueDetails={issueDetails!} />
<IssueActivity allowed={isAllowed} issueDetails={issueDetails!} />
</div>
</DefaultLayout>
);
};
export default MobileWebViewIssueDetail;
+1 -1
View File
@@ -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(
+1 -11
View File
@@ -1367,7 +1367,7 @@
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.7.tgz#95bed2487bf59632125a13b8eb8f4c21e460afec"
integrity sha512-sCWTUNElBPgB30iLvWe3PU7SIlTKZNf6/E/sko85iHVeHCM6WPkDw+y89CrZYjhFNmPqt2fIQM/pZu+rP2lFLA==
"@mui/icons-material@^5.14.1", "@mui/icons-material@^5.14.7":
"@mui/icons-material@^5.14.1":
version "5.14.7"
resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.14.7.tgz#d7f6bd188fe38adf35c89d9343b8a529c2306383"
integrity sha512-mWp4DwMa8c1Gx9yOEtPgxM4b+e6hAbtZyzfSubdBwrnEE6G5D2rbAJ5MB+If6kfI48JaYaJ5j8+zAdmZLuZc0A==
@@ -4892,11 +4892,6 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
husky@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184"
integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==
idb@^7.0.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
@@ -6046,11 +6041,6 @@ next-pwa@^5.6.0:
workbox-webpack-plugin "^6.5.4"
workbox-window "^6.5.4"
next-theme@^0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/next-theme/-/next-theme-0.1.5.tgz#aa6655c516892925e577349d7715a8ed54bad727"
integrity sha512-WR8UCLEFjWvRl+UO2lTM4pGo7R4jzGZqQ6YL3hiL1Ns587Qb91GhJZLPu/Aa4ExtGQ/5wlcDX8zDYZoCN9oDPw==
next-themes@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45"