Merge branch 'develop' of https://github.com/makeplane/plane into feat/csv_exporter

This commit is contained in:
srinivaspendem 2023-08-21 11:45:15 +05:30
commit f60db24503
152 changed files with 5039 additions and 5546 deletions

View File

@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID="" NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so" # For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use

2
.gitignore vendored
View File

@ -71,3 +71,5 @@ package-lock.json
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
.npmrc

View File

@ -61,6 +61,16 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM > If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up - Run Docker compose up
```bash ```bash

View File

@ -88,6 +88,7 @@ class ProjectLiteSerializer(BaseSerializer):
"cover_image", "cover_image",
"icon_prop", "icon_prop",
"emoji", "emoji",
"description",
] ]
read_only_fields = fields read_only_fields = fields
@ -103,6 +104,7 @@ class ProjectDetailSerializer(BaseSerializer):
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True) member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = Project model = Project

View File

@ -48,6 +48,7 @@ class ExportIssuesEndpoint(BaseAPIView):
project_ids=project_ids, project_ids=project_ids,
token_id=exporter.token, token_id=exporter.token,
multiple=multiple, multiple=multiple,
slug=slug,
) )
return Response( return Response(
{ {

View File

@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id): def create(self, request, slug, project_id, workspace_integration_id):
try: try:
serializer = SlackProjectSyncSerializer(data=request.data) serializer = SlackProjectSyncSerializer(data=request.data)
@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError: except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist: except WorkspaceIntegration.DoesNotExist:
return Response( return Response(
{"error": "Workspace Integration does not exist"}, {"error": "Workspace Integration does not exist"},

View File

@ -370,7 +370,7 @@ class UserWorkSpaceIssues(BaseAPIView):
) )
) )
.filter(**filters) .filter(**filters)
) ).distinct()
# Priority Ordering # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority": if order_by_param == "priority" or order_by_param == "-priority":

View File

@ -122,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
) )
) )
.annotate( .annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id")) total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -145,6 +147,14 @@ class ProjectViewSet(BaseViewSet):
member_id=self.request.user.id, member_id=self.request.user.id,
).values("role") ).values("role")
) )
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct() .distinct()
) )
@ -216,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
project_id=serializer.data["id"], member=request.user, role=20 project_id=serializer.data["id"], member=request.user, role=20
) )
if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str(request.user.id): if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create( ProjectMember.objects.create(
project_id=serializer.data["id"], project_id=serializer.data["id"],
member_id=serializer.data["project_lead"], member_id=serializer.data["project_lead"],
@ -383,7 +395,9 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email) validate_email(email)
# Check if user is already a member of workspace # Check if user is already a member of workspace
if ProjectMember.objects.filter( if ProjectMember.objects.filter(
project_id=project_id, member__email=email project_id=project_id,
member__email=email,
member__is_bot=False,
).exists(): ).exists():
return Response( return Response(
{"error": "User is already member of workspace"}, {"error": "User is already member of workspace"},
@ -1087,7 +1101,9 @@ class ProjectMemberEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
try: try:
project_members = ProjectMember.objects.filter( project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member") ).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, many=True) serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -47,7 +47,7 @@ from plane.api.serializers import (
WorkspaceThemeSerializer, WorkspaceThemeSerializer,
IssueActivitySerializer, IssueActivitySerializer,
IssueLiteSerializer, IssueLiteSerializer,
WorkspaceMemberAdminSerializer WorkspaceMemberAdminSerializer,
) )
from plane.api.views.base import BaseAPIView from plane.api.views.base import BaseAPIView
from . import BaseViewSet from . import BaseViewSet
@ -107,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
member_count = ( member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id")) WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -192,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request): def get(self, request):
try: try:
member_count = ( member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id")) WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -625,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if ( if (
workspace_member.role == 20 workspace_member.role == 20
and WorkspaceMember.objects.filter( and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20 workspace__slug=slug,
role=20,
member__is_bot=False,
).count() ).count()
== 1 == 1
): ):
@ -988,11 +994,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
upcoming_issues = Issue.issue_objects.filter( upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]), ~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(), start_date__gte=timezone.now(),
workspace__slug=slug, workspace__slug=slug,
assignees__in=[request.user], assignees__in=[request.user],
completed_at__isnull=True, completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date") ).values("id", "name", "workspace__slug", "project_id", "start_date")
return Response( return Response(
{ {
@ -1077,6 +1083,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.filter(**filters) .filter(**filters)
.values("priority") .values("priority")
.annotate(priority_count=Count("priority")) .annotate(priority_count=Count("priority"))
.filter(priority_count__gte=1)
.annotate( .annotate(
priority_order=Case( priority_order=Case(
*[ *[
@ -1455,7 +1462,8 @@ class WorkspaceMembersEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
try: try:
workspace_members = WorkspaceMember.objects.filter( workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member") ).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True) serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK) return Response(serialzier.data, status=status.HTTP_200_OK)

View File

@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment segmented = segment
assignee_details = {} assignee_details = {}
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]: if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = ( assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id") .order_by("assignees__id")
.distinct("assignees__id") .distinct("assignees__id")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name") .values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
) )
if segment: if segment:
@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else: else:
generated_row.append("0") generated_row.append("0")
# x-axis replacement for names # x-axis replacement for names
if x_axis in ["assignees__display_name"]: if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)] assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee): if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row)) rows.append(tuple(generated_row))
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names # If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__display_name"]: if segmented in ["assignees__id"]:
for index, segm in enumerate(row_zero[2:]): for index, segm in enumerate(row_zero[2:]):
# find the name of the user # find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(segm)] assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
if len(assignee): if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "), else distribution.get(item)[0].get("estimate "),
] ]
# x-axis replacement to names # x-axis replacement to names
if x_axis in ["assignees__display_name"]: if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)] assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee): if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))

View File

@ -4,7 +4,6 @@ import io
import json import json
import boto3 import boto3
import zipfile import zipfile
from datetime import datetime, date, timedelta
# Django imports # Django imports
from django.conf import settings from django.conf import settings
@ -15,19 +14,18 @@ from celery import shared_task
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from botocore.client import Config from botocore.client import Config
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import NamedStyle
from openpyxl.utils.datetime import to_excel
# Module imports # Module imports
from plane.db.models import Issue, ExporterHistory, Project from plane.db.models import Issue, ExporterHistory
class DateTimeEncoder(json.JSONEncoder): def dateTimeConverter(time):
def default(self, obj): if time:
if isinstance(obj, (datetime, date)): return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
return obj.isoformat()
return super().default(obj)
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data): def create_csv_file(data):
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
@ -41,25 +39,16 @@ def create_csv_file(data):
def create_json_file(data): def create_json_file(data):
return json.dumps(data, cls=DateTimeEncoder) return json.dumps(data)
def create_xlsx_file(data): def create_xlsx_file(data):
workbook = Workbook() workbook = Workbook()
sheet = workbook.active sheet = workbook.active
no_timezone_style = NamedStyle(name="no_timezone_style")
no_timezone_style.number_format = "yyyy-mm-dd hh:mm:ss"
for row in data: for row in data:
sheet.append(row) sheet.append(row)
for column_cells in sheet.columns:
for cell in column_cells:
if isinstance(cell.value, datetime):
cell.style = no_timezone_style
cell.value = to_excel(cell.value.replace(tzinfo=None))
xlsx_buffer = io.BytesIO() xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer) workbook.save(xlsx_buffer)
xlsx_buffer.seek(0) xlsx_buffer.seek(0)
@ -76,7 +65,7 @@ def create_zip_file(files):
return zip_buffer return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id): def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client( s3 = boto3.client(
"s3", "s3",
region_name="ap-south-1", region_name="ap-south-1",
@ -84,7 +73,7 @@ def upload_to_s3(zip_file, workspace_id, token_id):
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"), config=Config(signature_version="s3v4"),
) )
file_name = f"{workspace_id}/issues-{datetime.now().date()}.zip" file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj( s3.upload_fileobj(
zip_file, zip_file,
@ -128,15 +117,15 @@ def generate_table_row(issue):
else "", else "",
issue["labels__name"], issue["labels__name"],
issue["issue_cycle__cycle__name"], issue["issue_cycle__cycle__name"],
issue["issue_cycle__cycle__start_date"], dateConverter(issue["issue_cycle__cycle__start_date"]),
issue["issue_cycle__cycle__end_date"], dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"], issue["issue_module__module__name"],
issue["issue_module__module__start_date"], dateConverter(issue["issue_module__module__start_date"]),
issue["issue_module__module__target_date"], dateConverter(issue["issue_module__module__target_date"]),
issue["created_at"], dateTimeConverter(issue["created_at"]),
issue["updated_at"], dateTimeConverter(issue["updated_at"]),
issue["completed_at"], dateTimeConverter(issue["completed_at"]),
issue["archived_at"], dateTimeConverter(issue["archived_at"]),
] ]
@ -156,15 +145,15 @@ def generate_json_row(issue):
else "", else "",
"Labels": issue["labels__name"], "Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"], "Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": issue["issue_cycle__cycle__start_date"], "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": issue["issue_cycle__cycle__end_date"], "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"], "Module Name": issue["issue_module__module__name"],
"Module Start Date": issue["issue_module__module__start_date"], "Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": issue["issue_module__module__target_date"], "Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": issue["created_at"], "Created At": dateTimeConverter(issue["created_at"]),
"Updated At": issue["updated_at"], "Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": issue["completed_at"], "Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": issue["archived_at"], "Archived At": dateTimeConverter(issue["archived_at"]),
} }
@ -244,7 +233,7 @@ def generate_xlsx(header, project_id, issues, files):
@shared_task @shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple): def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try: try:
exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing" exporter_instance.status = "processing"
@ -342,7 +331,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple):
) )
zip_buffer = create_zip_file(files) zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id) upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e: except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance = ExporterHistory.objects.get(token=token_id)

View File

@ -184,19 +184,24 @@ def track_description(
if current_instance.get("description_html") != requested_data.get( if current_instance.get("description_html") != requested_data.get(
"description_html" "description_html"
): ):
issue_activities.append( last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
IssueActivity( if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
issue_id=issue_id, last_activity.created_at = timezone.now()
actor=actor, last_activity.save(update_fields=["created_at"])
verb="updated", else:
old_value=current_instance.get("description_html"), issue_activities.append(
new_value=requested_data.get("description_html"), IssueActivity(
field="description", issue_id=issue_id,
project=project, actor=actor,
workspace=project.workspace, verb="updated",
comment=f"updated the description to {requested_data.get('description_html')}", old_value=current_instance.get("description_html"),
) new_value=requested_data.get("description_html"),
) field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date # Track changes in issue target date

View File

@ -1,965 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-04 11:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.project
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='analyticview',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='analyticview',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='apitoken',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='apitoken',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='cycle',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='cycle',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cycle',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='cycle',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='cycleissue',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='cycleissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cycleissue',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='cycleissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='estimate',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='estimate',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='estimate',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='estimate',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='estimatepoint',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='estimatepoint',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='estimatepoint',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='estimatepoint',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='fileasset',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='fileasset',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubissuesync',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='githubissuesync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubissuesync',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='githubissuesync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubrepository',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='githubrepository',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubrepository',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='githubrepository',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='importer',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='importer',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='importer',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='importer',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='inbox',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='inbox',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='inbox',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='inbox',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='inboxissue',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='inboxissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='inboxissue',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='inboxissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='integration',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='integration',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issue',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issue',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueactivity',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueactivity',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueactivity',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueactivity',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueassignee',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueassignee',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueassignee',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueassignee',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueattachment',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueattachment',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueattachment',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueattachment',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueblocker',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueblocker',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueblocker',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueblocker',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuecomment',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issuecomment',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuecomment',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issuecomment',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuelabel',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issuelabel',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuelabel',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issuelabel',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuelink',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issuelink',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='issuelink',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issuelink',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueproperty',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueproperty',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueproperty',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueproperty',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuesequence',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issuesequence',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuesequence',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issuesequence',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueview',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueview',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueview',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueview',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='label',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='label',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='label',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='label',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='module',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='module',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='module',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='module',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulefavorite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='modulefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulefavorite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='modulefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='moduleissue',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='moduleissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='moduleissue',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='moduleissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulelink',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='modulelink',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulelink',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='modulelink',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulemember',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='modulemember',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulemember',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='modulemember',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='page',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='page',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='page',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='page',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pageblock',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='pageblock',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pageblock',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='pageblock',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pagefavorite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='pagefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pagefavorite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='pagefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pagelabel',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='pagelabel',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pagelabel',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='pagelabel',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='project',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='project',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='projectfavorite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='projectfavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectfavorite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='projectfavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='projectidentifier',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='projectidentifier',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='projectmember',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='projectmember',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectmember',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='projectmember',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='socialloginconnection',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='socialloginconnection',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='state',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='state',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='state',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='state',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='team',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='team',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='teammember',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='teammember',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='workspace',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='workspace',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='workspaceintegration',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='workspaceintegration',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='workspacemember',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='workspacemember',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='workspacememberinvite',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='workspacememberinvite',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.AlterField(
model_name='workspacetheme',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
),
migrations.AlterField(
model_name='workspacetheme',
name='updated_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
),
migrations.CreateModel(
name='ProjectDeployBoard',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
]

View File

@ -0,0 +1,243 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.AlterField(
model_name='modulelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]

View File

@ -1,101 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-04 09:12
import string
import random
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
class Migration(migrations.Migration):
dependencies = [
("db", "0040_projectmember_preferences_user_cover_image_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="display_name",
field=models.CharField(default="", max_length=255),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-09 12:15
import random
from django.db import migrations
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0041_user_display_name_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
]

View File

@ -1,38 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-09 11:15
from django.db import migrations
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
("db", "0042_alter_analyticview_created_by_and_more"),
]
operations = [
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]

View File

@ -98,7 +98,7 @@ class ModuleIssue(ProjectBaseModel):
class ModuleLink(ProjectBaseModel): class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True) title = models.CharField(max_length=255, blank=True, null=True)
url = models.URLField() url = models.URLField()
module = models.ForeignKey( module = models.ForeignKey(
Module, on_delete=models.CASCADE, related_name="link_module" Module, on_delete=models.CASCADE, related_name="link_module"

View File

@ -1,3 +1,8 @@
// ui
import { ProfileEmptyState } from "components/ui";
// image
import emptyUsers from "public/empty-state/empty_users.svg";
type Props = { type Props = {
users: { users: {
avatar: string | null; avatar: string | null;
@ -8,10 +13,16 @@ type Props = {
id: string; id: string;
}[]; }[];
title: string; title: string;
emptyStateMessage: string;
workspaceSlug: string; workspaceSlug: string;
}; };
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title, workspaceSlug }) => ( export const AnalyticsLeaderboard: React.FC<Props> = ({
users,
title,
emptyStateMessage,
workspaceSlug,
}) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]"> <div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6> <h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? ( {users.length > 0 ? (
@ -47,7 +58,9 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title, workspaceS
))} ))}
</div> </div>
) : ( ) : (
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div> <div className="px-7 py-4">
<ProfileEmptyState title="No Data yet" description={emptyStateMessage} image={emptyUsers} />
</div>
)} )}
</div> </div>
); );

View File

@ -63,6 +63,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.created_by__id, id: user?.created_by__id,
}))} }))}
title="Most issues created" title="Most issues created"
emptyStateMessage="Co-workers and the number issues created by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}
/> />
<AnalyticsLeaderboard <AnalyticsLeaderboard
@ -75,6 +76,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.assignees__id, id: user?.assignees__id,
}))} }))}
title="Most issues closed" title="Most issues closed"
emptyStateMessage="Co-workers and the number issues closed by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}
/> />
<div className={fullScreen ? "md:col-span-2" : ""}> <div className={fullScreen ? "md:col-span-2" : ""}>

View File

@ -1,5 +1,7 @@
// ui // ui
import { BarGraph } from "components/ui"; import { BarGraph, ProfileEmptyState } from "components/ui";
// image
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
// types // types
import { IDefaultAnalyticsResponse } from "types"; import { IDefaultAnalyticsResponse } from "types";
@ -70,8 +72,12 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
}} }}
/> />
) : ( ) : (
<div className="text-custom-text-200 text-center text-sm py-8"> <div className="px-7 py-4">
No matching data found. <ProfileEmptyState
title="No Data yet"
description="Analysis of pending issues by co-workers appears here."
image={emptyBarGraph}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -1,5 +1,7 @@
// ui // ui
import { LineGraph } from "components/ui"; import { LineGraph, ProfileEmptyState } from "components/ui";
// image
import emptyGraph from "public/empty-state/empty_graph.svg";
// types // types
import { IDefaultAnalyticsResponse } from "types"; import { IDefaultAnalyticsResponse } from "types";
// constants // constants
@ -48,7 +50,13 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
enableArea enableArea
/> />
) : ( ) : (
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div> <div className="px-7 py-4">
<ProfileEmptyState
title="No Data yet"
description="Close issues to view analysis of the same in the form of a graph."
image={emptyGraph}
/>
</div>
)} )}
</div> </div>
); );

View File

@ -9,12 +9,18 @@ import userService from "services/user.service";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// helper // helper
import { unsetCustomCssVariables } from "helpers/theme.helper"; import { unsetCustomCssVariables } from "helpers/theme.helper";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = { type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>; setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
}; };
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => { export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpen }) => {
const store: any = useMobxStore();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@ -23,29 +29,11 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const updateUserTheme = (newTheme: string) => { const updateUserTheme = (newTheme: string) => {
if (!user) return; if (!user) return;
unsetCustomCssVariables();
setTheme(newTheme); setTheme(newTheme);
return store.user
mutateUser((prevData) => { .updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
if (!prevData) return prevData; .then((response: any) => response)
.catch((error: any) => error);
return {
...prevData,
theme: {
...prevData.theme,
theme: newTheme,
},
};
}, false);
userService.updateUser({
theme: {
...user.theme,
theme: newTheme,
},
});
}; };
// useEffect only runs on the client, so now we can safely show the UI // useEffect only runs on the client, so now we can safely show the UI
@ -74,4 +62,4 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
))} ))}
</> </>
); );
}; });

View File

@ -1,11 +1,8 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
@ -26,8 +23,10 @@ import inboxService from "services/inbox.service";
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { observable } from "mobx";
import { observer } from "mobx-react-lite";
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = observer(() => {
const store: any = useMobxStore(); const store: any = useMobxStore();
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -47,13 +46,12 @@ export const CommandPalette: React.FC = () => {
const { user } = useUser(); const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR( const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null : null
); );
@ -78,55 +76,52 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
// if on input, textarea or editor, don't do anything // if on input, textarea or editor, don't do anything
if ( if (
e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement || e.target instanceof HTMLInputElement ||
(e.target as Element).classList?.contains("remirror-editor") (e.target as Element).classList?.contains("ProseMirror")
) )
return; return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = e; if (cmdClicked) {
if (keyPressed === "k") {
if (!key) return; e.preventDefault();
setIsPaletteOpen(true);
const keyPressed = key.toLowerCase(); } else if (keyPressed === "c" && altKey) {
e.preventDefault();
const cmdClicked = ctrlKey || metaKey; copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
if (cmdClicked) { e.preventDefault();
if (keyPressed === "k") { store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
e.preventDefault(); }
setIsPaletteOpen(true); } else {
} else if (keyPressed === "c" && altKey) { if (keyPressed === "c") {
e.preventDefault(); setIsIssueModalOpen(true);
copyIssueUrlToClipboard(); } else if (keyPressed === "p") {
} else if (keyPressed === "b") { setIsProjectModalOpen(true);
e.preventDefault(); } else if (keyPressed === "v") {
toggleCollapsed(); setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
} }
} else {
if (keyPressed === "c") {
setIsIssueModalOpen(true);
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
}
}, },
[copyIssueUrlToClipboard, toggleCollapsed] [copyIssueUrlToClipboard]
); );
useEffect(() => { useEffect(() => {
@ -201,4 +196,4 @@ export const CommandPalette: React.FC = () => {
/> />
</> </>
); );
}; })

View File

@ -1,7 +1,6 @@
import { useEffect, useState, forwardRef, useRef } from "react"; import React, { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -15,6 +14,7 @@ import useUserAuth from "hooks/use-user-auth";
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types"; import { IIssue, IPageBlock } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
@ -32,17 +32,11 @@ type FormData = {
task: string; task: string;
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
); );
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; TiptapEditor.displayName = "TiptapEditor";
export const GptAssistantModal: React.FC<Props> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
@ -146,15 +140,15 @@ export const GptAssistantModal: React.FC<Props> = ({
return ( return (
<div <div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${ className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden"
isOpen ? "block" : "hidden" }`}
}`}
> >
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm"> <div className="text-sm">
Content: Content:
<WrappedRemirrorRichTextEditor <TiptapEditor
value={htmlContent ?? <p>{content}</p>} workspaceSlug={workspaceSlug as string}
value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3" customClassName="-m-3"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
@ -166,7 +160,8 @@ export const GptAssistantModal: React.FC<Props> = ({
{response !== "" && ( {response !== "" && (
<div className="page-block-section text-sm"> <div className="page-block-section text-sm">
Response: Response:
<RemirrorRichTextEditor <Tiptap
workspaceSlug={workspaceSlug as string}
value={`<p>${response}</p>`} value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3" customClassName="-mx-3 -my-3"
noBorder noBorder
@ -185,11 +180,10 @@ export const GptAssistantModal: React.FC<Props> = ({
type="text" type="text"
name="task" name="task"
register={register} register={register}
placeholder={`${ placeholder={`${content && content !== ""
content && content !== ""
? "Tell AI what action to perform on this content..." ? "Tell AI what action to perform on this content..."
: "Ask AI anything..." : "Ask AI anything..."
}`} }`}
autoComplete="off" autoComplete="off"
/> />
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}> <div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
@ -225,8 +219,8 @@ export const GptAssistantModal: React.FC<Props> = ({
{isSubmitting {isSubmitting
? "Generating response..." ? "Generating response..."
: response === "" : response === ""
? "Generate response" ? "Generate response"
: "Generate again"} : "Generate again"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -7,12 +7,15 @@ import { Dialog, Transition } from "@headlessui/react";
// ui // ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types // types
import type { IIssueLink, ModuleLink } from "types"; import type { IIssueLink, linkDetails, ModuleLink } from "types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>; data?: linkDetails | null;
status: boolean;
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<void>;
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<void>;
}; };
const defaultValues: IIssueLink | ModuleLink = { const defaultValues: IIssueLink | ModuleLink = {
@ -20,7 +23,14 @@ const defaultValues: IIssueLink | ModuleLink = {
url: "", url: "",
}; };
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => { export const LinkModal: React.FC<Props> = ({
isOpen,
handleClose,
createIssueLink,
updateIssueLink,
status,
data,
}) => {
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -30,11 +40,6 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
defaultValues, defaultValues,
}); });
const onSubmit = async (formData: IIssueLink | ModuleLink) => {
await onFormSubmit({ title: formData.title, url: formData.url });
onClose();
};
const onClose = () => { const onClose = () => {
handleClose(); handleClose();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -43,6 +48,27 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
}, 500); }, 500);
}; };
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
onClose();
};
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}> <Dialog as="div" className="relative z-20" onClose={onClose}>
@ -70,14 +96,14 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-200 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-200 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div> <div>
<div className="space-y-5"> <div className="space-y-5">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-custom-text-100" className="text-lg font-medium leading-6 text-custom-text-100"
> >
Add Link {status ? "Update Link" : "Add Link"}
</Dialog.Title> </Dialog.Title>
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
<div> <div>
@ -113,7 +139,13 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton> <SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}> <PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"} {status
? isSubmitting
? "Updating Link..."
: "Update Link"
: isSubmitting
? "Adding Link..."
: "Add Link"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -1,25 +1,24 @@
// icons // icons
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline"; import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
import { IUserLite, UserAuth } from "types"; import { linkDetails, UserAuth } from "types";
type Props = { type Props = {
links: { links: linkDetails[];
id: string;
created_at: Date;
created_by: string;
created_by_detail: IUserLite;
metadata: any;
title: string;
url: string;
}[];
handleDeleteLink: (linkId: string) => void; handleDeleteLink: (linkId: string) => void;
handleEditLink: (link: linkDetails) => void;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => { export const LinksList: React.FC<Props> = ({
links,
handleDeleteLink,
handleEditLink,
userAuth,
}) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
@ -28,6 +27,13 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
<div key={link.id} className="relative"> <div key={link.id} className="relative">
{!isNotAllowed && ( {!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1"> <div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
onClick={() => handleEditLink(link)}
>
<Icon iconName="edit" className="text-custom-text-200" />
</button>
<a <a
href={link.url} href={link.url}
target="_blank" target="_blank"

View File

@ -86,8 +86,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
}) => { }) => {
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
const [isDropdownActive, setIsDropdownActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
@ -125,7 +127,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
@ -200,7 +202,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
return ( return (
<> <>
<ContextMenu <ContextMenu
position={contextMenuPosition} clickEvent={contextMenuPosition}
title="Quick actions" title="Quick actions"
isOpen={contextMenu} isOpen={contextMenu}
setIsOpen={setContextMenu} setIsOpen={setContextMenu}
@ -242,10 +244,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu(true); setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY }); setContextMenuPosition(e);
}} }}
> >
<div className="group/card relative select-none p-3.5"> <div className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px]">
{!isNotAllowed && ( {!isNotAllowed && (
<div <div
ref={actionSectionRef} ref={actionSectionRef}
@ -295,16 +297,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}> <Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a> <a className="flex flex-col gap-1.5">
{properties.key && ( {properties.key && (
<div className="mb-2.5 text-xs font-medium text-custom-text-200"> <div className="text-xs font-medium text-custom-text-200">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5> <h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
</a> </a>
</Link> </Link>
<div className="mt-2.5 flex overflow-x-scroll items-center gap-2 text-xs"> <div
className={`flex items-center gap-2 text-xs ${
isDropdownActive ? "" : "overflow-x-scroll"
}`}
>
{properties.priority && ( {properties.priority && (
<ViewPrioritySelect <ViewPrioritySelect
issue={issue} issue={issue}
@ -327,6 +333,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
<ViewStartDateSelect <ViewStartDateSelect
issue={issue} issue={issue}
partialUpdateIssue={partialUpdateIssue} partialUpdateIssue={partialUpdateIssue}
handleOnOpen={() => setIsDropdownActive(true)}
handleOnClose={() => setIsDropdownActive(false)}
user={user} user={user}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
@ -335,6 +343,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
<ViewDueDateSelect <ViewDueDateSelect
issue={issue} issue={issue}
partialUpdateIssue={partialUpdateIssue} partialUpdateIssue={partialUpdateIssue}
handleOnOpen={() => setIsDropdownActive(true)}
handleOnClose={() => setIsDropdownActive(false)}
user={user} user={user}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />

View File

@ -33,7 +33,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// helpers // helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
// types // types
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
@ -71,7 +71,7 @@ export const SingleListIssue: React.FC<Props> = ({
}) => { }) => {
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
@ -167,7 +167,7 @@ export const SingleListIssue: React.FC<Props> = ({
return ( return (
<> <>
<ContextMenu <ContextMenu
position={contextMenuPosition} clickEvent={contextMenuPosition}
title="Quick actions" title="Quick actions"
isOpen={contextMenu} isOpen={contextMenu}
setIsOpen={setContextMenu} setIsOpen={setContextMenu}
@ -199,7 +199,7 @@ export const SingleListIssue: React.FC<Props> = ({
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu(true); setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY }); setContextMenuPosition(e);
}} }}
> >
<div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis"> <div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">

View File

@ -38,10 +38,10 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
mutateCycles((prevData) => { mutateCycles((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === cycle.id ...(p.id === cycle.id
? { ? {

View File

@ -45,7 +45,6 @@ type TSingleStatProps = {
handleDeleteCycle: () => void; handleDeleteCycle: () => void;
handleAddToFavorites: () => void; handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void; handleRemoveFromFavorites: () => void;
isCompleted?: boolean;
}; };
const stateGroups = [ const stateGroups = [
@ -82,7 +81,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
handleDeleteCycle, handleDeleteCycle,
handleAddToFavorites, handleAddToFavorites,
handleRemoveFromFavorites, handleRemoveFromFavorites,
isCompleted = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -90,6 +88,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");

View File

@ -34,7 +34,6 @@ type TSingleStatProps = {
handleDeleteCycle: () => void; handleDeleteCycle: () => void;
handleAddToFavorites: () => void; handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void; handleRemoveFromFavorites: () => void;
isCompleted?: boolean;
}; };
const stateGroups = [ const stateGroups = [
@ -113,7 +112,6 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
handleDeleteCycle, handleDeleteCycle,
handleAddToFavorites, handleAddToFavorites,
handleRemoveFromFavorites, handleRemoveFromFavorites,
isCompleted = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -121,6 +119,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");

View File

@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
estimate: estimate.id, estimate: estimate.id,
}; };
mutateProjectDetails((prevData) => { mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id }; return { ...prevData, estimate: estimate.id };

View File

@ -15,10 +15,10 @@ export const updateGanttIssue = (
) => { ) => {
if (!issue || !workspaceSlug || !user) return; if (!issue || !workspaceSlug || !user) return;
mutate((prevData: IIssue[]) => { mutate((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === issue.id ? payload : {}), ...(p.id === issue.id ? payload : {}),
})); }));

View File

@ -72,8 +72,8 @@ export const InboxActionHeader = () => {
false false
); );
mutateInboxIssues( mutateInboxIssues(
(prevData) => (prevData: any) =>
(prevData ?? []).map((i) => (prevData ?? []).map((i: any) =>
i.bridge_id === inboxIssueId i.bridge_id === inboxIssueId
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] } ? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
: i : i

View File

@ -293,6 +293,7 @@ export const InboxMainContent: React.FC = () => {
</div> </div>
<div> <div>
<IssueDescriptionForm <IssueDescriptionForm
workspaceSlug={workspaceSlug as string}
issue={{ issue={{
name: issueDetails.name, name: issueDetails.name,
description: issueDetails.description, description: issueDetails.description,

View File

@ -54,7 +54,10 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
const handleCommentDelete = async (commentId: string) => { const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutateIssueActivities((prevData) => prevData?.filter((p) => p.id !== commentId), false); mutateIssueActivities(
(prevData: any) => prevData?.filter((p: any) => p.id !== commentId),
false
);
await issuesService await issuesService
.deleteIssueComment( .deleteIssueComment(
@ -131,7 +134,9 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
<div <div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`} 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.display_name.charAt(0)} {activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name.charAt(0)
: activityItem.actor_detail.display_name.charAt(0)}
</div> </div>
)} )}
</div> </div>
@ -150,7 +155,9 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
) : ( ) : (
<Link href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}> <Link href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}>
<a className="text-gray font-medium"> <a className="text-gray font-medium">
{activityItem.actor_detail.display_name} {activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name
: activityItem.actor_detail.display_name}
</a> </a>
</Link> </Link>
)}{" "} )}{" "}
@ -168,6 +175,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
return ( return (
<div key={activityItem.id} className="mt-4"> <div key={activityItem.id} className="mt-4">
<CommentCard <CommentCard
workspaceSlug={workspaceSlug as string}
comment={activityItem as IIssueComment} comment={activityItem as IIssueComment}
onSubmit={handleCommentUpdate} onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete} handleCommentDeletion={handleCommentDelete}

View File

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { mutate } from "swr"; import { mutate } from "swr";
@ -12,28 +11,18 @@ import issuesServices from "services/issues.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Loader, SecondaryButton } from "components/ui"; import { SecondaryButton } from "components/ui";
// types // types
import type { ICurrentUserResponse, IIssueComment } from "types"; import type { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
loading: () => ( );
<Loader className="mb-5">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
const defaultValues: Partial<IIssueComment> = { const defaultValues: Partial<IIssueComment> = {
comment_json: "", comment_json: "",
@ -51,6 +40,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
handleSubmit, handleSubmit,
control, control,
setValue, setValue,
watch,
formState: { isSubmitting }, formState: { isSubmitting },
reset, reset,
} = useForm<IIssueComment>({ defaultValues }); } = useForm<IIssueComment>({ defaultValues });
@ -99,15 +89,25 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="issue-comments-section"> <div className="issue-comments-section">
<Controller <Controller
name="comment_json" name="comment_html"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value, onChange } }) => (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={value} workspaceSlug={workspaceSlug as string}
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
placeholder="Enter your comment..."
ref={editorRef} ref={editorRef}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
setValue("comment_json", comment_json);
}}
/> />
)} )}
/> />

View File

@ -1,7 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// icons // icons
@ -15,25 +13,22 @@ import { CommentReaction } from "components/issues";
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
import type { IIssueComment } from "types"; import type { IIssueComment } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false }); const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
import { IRemirrorRichTextEditor } from "components/rich-text-editor"; TiptapEditor.displayName = "TiptapEditor";
const WrappedRemirrorRichTextEditor = React.forwardRef<
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
type Props = { type Props = {
workspaceSlug: string;
comment: IIssueComment; comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void; onSubmit: (comment: IIssueComment) => void;
handleCommentDeletion: (comment: string) => void; handleCommentDeletion: (comment: string) => void;
}; };
export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => { export const CommentCard: React.FC<Props> = ({ comment, workspaceSlug, onSubmit, handleCommentDeletion }) => {
const { user } = useUser(); const { user } = useUser();
const editorRef = React.useRef<any>(null); const editorRef = React.useRef<any>(null);
@ -45,6 +40,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit,
setFocus, setFocus,
watch,
setValue, setValue,
} = useForm<IIssueComment>({ } = useForm<IIssueComment>({
defaultValues: comment, defaultValues: comment,
@ -56,8 +52,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
onSubmit(formData); onSubmit(formData);
editorRef.current?.setEditorValue(formData.comment_json); editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_json); showEditorRef.current?.setEditorValue(formData.comment_html);
}; };
useEffect(() => { useEffect(() => {
@ -70,7 +66,11 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<img <img
src={comment.actor_detail.avatar} src={comment.actor_detail.avatar}
alt={comment.actor_detail.display_name} alt={
comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name
}
height={30} height={30}
width={30} width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200" className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
@ -79,7 +79,9 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
<div <div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`} className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
> >
{comment.actor_detail.display_name.charAt(0)} {comment.actor_detail.is_bot
? comment.actor_detail.first_name.charAt(0)
: comment.actor_detail.display_name.charAt(0)}
</div> </div>
)} )}
@ -106,15 +108,19 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`} className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
onSubmit={handleSubmit(onEnter)} onSubmit={handleSubmit(onEnter)}
> >
<WrappedRemirrorRichTextEditor <div>
value={comment.comment_html} <TiptapEditor
onBlur={(jsonValue, htmlValue) => { workspaceSlug={workspaceSlug as string}
setValue("comment_json", jsonValue); ref={editorRef}
setValue("comment_html", htmlValue); value={watch("comment_html")}
}} debouncedUpdatesEnabled={false}
placeholder="Enter Your comment..." customClassName="min-h-[50px] p-3 shadow-sm"
ref={editorRef} onChange={(comment_json: Object, comment_html: string) => {
/> setValue("comment_json", comment_json);
setValue("comment_html", comment_html);
}}
/>
</div>
<div className="flex gap-1 self-end"> <div className="flex gap-1 self-end">
<button <button
type="submit" type="submit"
@ -133,14 +139,13 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
</div> </div>
</form> </form>
<div className={`${isEditing ? "hidden" : ""}`}> <div className={`${isEditing ? "hidden" : ""}`}>
<WrappedRemirrorRichTextEditor <TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
value={comment.comment_html} value={comment.comment_html}
editable={false} editable={false}
noBorder
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
ref={showEditorRef}
/> />
<CommentReaction projectId={comment.project} commentId={comment.id} /> <CommentReaction projectId={comment.project} commentId={comment.id} />
</div> </div>
</div> </div>

View File

@ -1,23 +1,16 @@
import { FC, useCallback, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// hooks // hooks
import useReloadConfirmations from "hooks/use-reload-confirmation"; import useReloadConfirmations from "hooks/use-reload-confirmation";
// components // components
import { Loader, TextArea } from "components/ui"; import { TextArea } from "components/ui";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader>
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import Tiptap from "components/tiptap";
import { useDebouncedCallback } from "use-debounce";
export interface IssueDescriptionFormValues { export interface IssueDescriptionFormValues {
name: string; name: string;
@ -31,6 +24,7 @@ export interface IssueDetailsProps {
description: string; description: string;
description_html: string; description_html: string;
}; };
workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
isAllowed: boolean; isAllowed: boolean;
} }
@ -38,9 +32,10 @@ export interface IssueDetailsProps {
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
issue, issue,
handleFormSubmit, handleFormSubmit,
workspaceSlug,
isAllowed, isAllowed,
}) => { }) => {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false); const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
@ -63,7 +58,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
const handleDescriptionFormSubmit = useCallback( const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!formData.name || formData.name.length === 0 || formData.name.length > 255) return; if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
await handleFormSubmit({ await handleFormSubmit({
name: formData.name ?? "", name: formData.name ?? "",
@ -74,6 +69,18 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
[handleFormSubmit] [handleFormSubmit]
); );
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert]);
// reset form values // reset form values
useEffect(() => { useEffect(() => {
if (!issue) return; if (!issue) return;
@ -83,6 +90,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
}); });
}, [issue, reset]); }, [issue, reset]);
const debouncedTitleSave = useDebouncedCallback(async () => {
setTimeout(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 500);
}, 1000);
return ( return (
<div className="relative"> <div className="relative">
<div className="relative"> <div className="relative">
@ -92,11 +105,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
placeholder="Enter issue name" placeholder="Enter issue name"
register={register} register={register}
onFocus={() => setCharacterLimit(true)} onFocus={() => setCharacterLimit(true)}
onBlur={() => { onChange={(e) => {
setCharacterLimit(false); setCharacterLimit(false);
setIsSubmitting("submitting");
setIsSubmitting(true); debouncedTitleSave();
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
}} }}
required={true} 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" 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"
@ -106,9 +118,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
{characterLimit && ( {characterLimit && (
<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"> <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 <span
className={`${ className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : "" }`}
}`}
> >
{watch("name").length} {watch("name").length}
</span> </span>
@ -119,45 +130,45 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
<span>{errors.name ? errors.name.message : null}</span> <span>{errors.name ? errors.name.message : null}</span>
<div className="relative"> <div className="relative">
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => { render={({ field: { value, onChange } }) => {
if (!value && !watch("description_html")) return <></>; if (!value && !watch("description_html")) return <></>;
return ( return (
<RemirrorRichTextEditor <Tiptap
value={ value={
!value || !value ||
value === "" || value === "" ||
(typeof value === "object" && Object.keys(value).length === 0) (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html") ? watch("description_html")
: value : value
} }
onJSONChange={(jsonValue) => { workspaceSlug={workspaceSlug}
debouncedUpdatesEnabled={true}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
customClassName="min-h-[150px] shadow-sm"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
setShowAlert(true); setShowAlert(true);
setValue("description", jsonValue); setIsSubmitting("submitting");
onChange(description_html);
setValue("description", description);
handleSubmit(handleDescriptionFormSubmit)().finally(() => {
setIsSubmitting("submitted");
});
}} }}
onHTMLChange={(htmlValue) => {
setShowAlert(true);
setValue("description_html", htmlValue);
}}
onBlur={() => {
setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)()
.then(() => setShowAlert(false))
.finally(() => setIsSubmitting(false));
}}
placeholder="Description"
editable={isAllowed}
/> />
); );
}} }}
/> />
{isSubmitting && ( <div
<div className="absolute bottom-1 right-1 text-xs text-custom-text-200 bg-custom-background-100 p-3 z-10"> 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"
Saving... }`}
</div> >
)} {isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,5 @@
import React, { FC, useState, useEffect, useRef } from "react"; import React, { FC, useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// react-hook-form // react-hook-form
@ -36,24 +35,14 @@ import {
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
// rich-text-editor // rich-text-editor
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader className="mt-4">
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor"; const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
);
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
const defaultValues: Partial<IIssue> = { const defaultValues: Partial<IIssue> = {
project: "", project: "",
@ -374,21 +363,31 @@ export const IssueForm: FC<IssueFormProps> = ({
</button> </button>
</div> </div>
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => ( render={({ field: { value, onChange } }) => {
<WrappedRemirrorRichTextEditor if (!value && !watch("description_html")) return <></>;
value={
!value || (typeof value === "object" && Object.keys(value).length === 0) return (
? watch("description_html") <TiptapEditor
: value workspaceSlug={workspaceSlug as string}
} ref={editorRef}
onJSONChange={(jsonValue) => setValue("description", jsonValue)} debouncedUpdatesEnabled={false}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} value={
placeholder="Description" !value ||
ref={editorRef} value === "" ||
/> (typeof value === "object" && Object.keys(value).length === 0)
)} ? watch("description_html")
: value
}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/>
);
}}
/> />
<GptAssistantModal <GptAssistantModal
isOpen={gptAssistantModal} isOpen={gptAssistantModal}

View File

@ -50,11 +50,11 @@ export const IssueMainContent: React.FC<Props> = ({
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
workspaceSlug && projectId && issueDetails?.parent workspaceSlug && projectId && issueDetails?.parent
? () => ? () =>
issuesService.subIssues( issuesService.subIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
issueDetails.parent ?? "" issueDetails.parent ?? ""
) )
: null : null
); );
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
@ -97,9 +97,8 @@ export const IssueMainContent: React.FC<Props> = ({
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={issue.id} key={issue.id}
renderAs="a" renderAs="a"
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${ href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id
issue.id }`}
}`}
className="flex items-center gap-2 py-2" className="flex items-center gap-2 py-2"
> >
<LayerDiagonalIcon className="h-4 w-4" /> <LayerDiagonalIcon className="h-4 w-4" />
@ -125,6 +124,7 @@ export const IssueMainContent: React.FC<Props> = ({
</div> </div>
) : null} ) : null}
<IssueDescriptionForm <IssueDescriptionForm
workspaceSlug={workspaceSlug as string}
issue={issueDetails} issue={issueDetails}
handleFormSubmit={submitChanges} handleFormSubmit={submitChanges}
isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable} isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable}

View File

@ -5,9 +5,7 @@ import useSWR from "swr";
// services // services
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// ui // ui
import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui"; import { AssigneesList, Avatar, CustomSearchSelect, Icon } from "components/ui";
// icons
import { UserGroupIcon } from "@heroicons/react/24/outline";
// fetch-keys // fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -44,15 +42,15 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
value={value} value={value}
onChange={onChange} onChange={onChange}
options={options} options={options}
label={ customButton={
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 cursor-pointer text-xs text-custom-text-200">
{value && value.length > 0 && Array.isArray(value) ? ( {value && value.length > 0 && Array.isArray(value) ? (
<div className="flex items-center justify-center gap-2"> <div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList userIds={value} length={3} showLength={true} /> <AssigneesList userIds={value} length={3} showLength={true} />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-300 hover:bg-custom-background-80">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" /> <Icon iconName="person" className="!text-base !leading-4" />
<span className="text-custom-text-200">Assignee</span> <span className="text-custom-text-200">Assignee</span>
</div> </div>
)} )}

View File

@ -20,7 +20,7 @@ export const IssueDateSelect: React.FC<Props> = ({ label, maxDate, minDate, onCh
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200"> <Popover.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200">
<span className="flex items-center justify-center gap-2 px-2 py-1 text-xs text-custom-text-200"> <span className="flex items-center justify-center gap-2 px-2 py-1 text-xs text-custom-text-200 hover:bg-custom-background-80">
{value ? ( {value ? (
<> <>
<span className="text-custom-text-100">{renderShortDateWithYearFormat(value)}</span> <span className="text-custom-text-100">{renderShortDateWithYearFormat(value)}</span>

View File

@ -59,17 +59,17 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
> >
{({ open }: any) => ( {({ open }: any) => (
<> <>
<Combobox.Button className="flex cursor-pointer items-center rounded-md border border-custom-border-200 text-xs shadow-sm duration-200 hover:bg-custom-background-80"> <Combobox.Button className="flex items-center gap-2 cursor-pointer text-xs text-custom-text-200">
{value && value.length > 0 ? ( {value && value.length > 0 ? (
<span className="flex items-center justify-center gap-2 px-3 py-1 text-xs"> <span className="flex items-center justify-center gap-2 text-xs">
<IssueLabelsList <IssueLabelsList
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []} labels={value.map((v) => issueLabels?.find((l) => l.id === v)) ?? []}
length={3} length={3}
showLength={true} showLength={true}
/> />
</span> </span>
) : ( ) : (
<span className="flex items-center justify-center gap-2 px-2.5 py-1 text-xs"> <span className="flex items-center justify-center gap-2 px-2 py-1 text-xs rounded shadow-sm border border-custom-border-300 hover:bg-custom-background-80">
<TagIcon className="h-3.5 w-3.5 text-custom-text-200" /> <TagIcon className="h-3.5 w-3.5 text-custom-text-200" />
<span className=" text-custom-text-200">Label</span> <span className=" text-custom-text-200">Label</span>
</span> </span>

View File

@ -85,7 +85,7 @@ export const SidebarLabelSelect: React.FC<Props> = ({
.then((res) => { .then((res) => {
reset(defaultValues); reset(defaultValues);
issueLabelMutate((prevData) => [...(prevData ?? []), res], false); issueLabelMutate((prevData: any) => [...(prevData ?? []), res], false);
submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] }); submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] });

View File

@ -37,7 +37,7 @@ import { LinkIcon, CalendarDaysIcon, TrashIcon, PlusIcon } from "@heroicons/reac
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import type { ICycle, IIssue, IIssueLink, IModule } from "types"; import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys // fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys"; import { ISSUE_DETAILS } from "constants/fetch-keys";
@ -77,6 +77,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}) => { }) => {
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [linkModal, setLinkModal] = useState(false); const [linkModal, setLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -156,6 +157,43 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}); });
}; };
const handleUpdateLink = async (formData: IIssueLink, linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
const payload = { metadata: {}, ...formData };
const updatedLinks = issueDetail.issue_link.map((l) =>
l.id === linkId
? {
...l,
title: formData.title,
url: formData.url,
}
: l
);
mutate<IIssue>(
ISSUE_DETAILS(issueDetail.id),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issuesService
.updateIssueLink(
workspaceSlug as string,
projectId as string,
issueDetail.id,
linkId,
payload
)
.then((res) => {
mutate(ISSUE_DETAILS(issueDetail.id));
})
.catch((err) => {
console.log(err);
});
};
const handleDeleteLink = async (linkId: string) => { const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetail) return; if (!workspaceSlug || !projectId || !issueDetail) return;
@ -220,14 +258,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const maxDate = targetDate ? new Date(targetDate) : null; const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const handleEditLink = (link: linkDetails) => {
setSelectedLinkToUpdate(link);
setLinkModal(true);
};
const isNotAllowed = memberRole.isGuest || memberRole.isViewer; const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
return ( return (
<> <>
<LinkModal <LinkModal
isOpen={linkModal} isOpen={linkModal}
handleClose={() => setLinkModal(false)} handleClose={() => {
onFormSubmit={handleCreateLink} setLinkModal(false);
setSelectedLinkToUpdate(null);
}}
data={selectedLinkToUpdate}
status={selectedLinkToUpdate ? true : false}
createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink}
/> />
<DeleteIssueModal <DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
@ -396,7 +445,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
start_date: val, start_date: val,
}) })
} }
className="bg-custom-background-90" className="bg-custom-background-100"
wrapperClassName="w-full"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={isNotAllowed || uneditable}
/> />
@ -424,7 +474,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
target_date: val, target_date: val,
}) })
} }
className="bg-custom-background-90" className="bg-custom-background-100"
wrapperClassName="w-full"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={isNotAllowed || uneditable}
/> />
@ -488,6 +539,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<LinksList <LinksList
links={issueDetail.issue_link} links={issueDetail.issue_link}
handleDeleteLink={handleDeleteLink} handleDeleteLink={handleDeleteLink}
handleEditLink={handleEditLink}
userAuth={memberRole} userAuth={memberRole}
/> />
) : null} ) : null}

View File

@ -13,6 +13,8 @@ import useIssuesView from "hooks/use-issues-view";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
handleOnOpen?: () => void;
handleOnClose?: () => void;
tooltipPosition?: "top" | "bottom"; tooltipPosition?: "top" | "bottom";
noBorder?: boolean; noBorder?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
@ -22,6 +24,8 @@ type Props = {
export const ViewDueDateSelect: React.FC<Props> = ({ export const ViewDueDateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
handleOnOpen,
handleOnClose,
tooltipPosition = "top", tooltipPosition = "top",
noBorder = false, noBorder = false,
user, user,
@ -80,6 +84,8 @@ export const ViewDueDateSelect: React.FC<Props> = ({
}`} }`}
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
noBorder={noBorder} noBorder={noBorder}
handleOnOpen={handleOnOpen}
handleOnClose={handleOnClose}
disabled={isNotAllowed} disabled={isNotAllowed}
/> />
</div> </div>

View File

@ -13,6 +13,8 @@ import useIssuesView from "hooks/use-issues-view";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
handleOnOpen?: () => void;
handleOnClose?: () => void;
tooltipPosition?: "top" | "bottom"; tooltipPosition?: "top" | "bottom";
noBorder?: boolean; noBorder?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
@ -22,6 +24,8 @@ type Props = {
export const ViewStartDateSelect: React.FC<Props> = ({ export const ViewStartDateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
handleOnOpen,
handleOnClose,
tooltipPosition = "top", tooltipPosition = "top",
noBorder = false, noBorder = false,
user, user,
@ -72,6 +76,8 @@ export const ViewStartDateSelect: React.FC<Props> = ({
}`} }`}
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
noBorder={noBorder} noBorder={noBorder}
handleOnOpen={handleOnOpen}
handleOnClose={handleOnClose}
disabled={isNotAllowed} disabled={isNotAllowed}
/> />
</div> </div>

View File

@ -49,8 +49,8 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent,
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutate( mutate(
(prevData) => (prevData: any) =>
prevData?.map((l) => { prevData?.map((l: any) => {
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" }; if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
return l; return l;

View File

@ -42,10 +42,10 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
if (!workspaceSlug || !user) return; if (!workspaceSlug || !user) return;
mutateModules((prevData) => { mutateModules((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const newList = prevData.map((p) => ({ const newList = prevData.map((p: any) => ({
...p, ...p,
...(p.id === module.id ...(p.id === module.id
? { ? {

View File

@ -37,7 +37,7 @@ import { LinkIcon } from "@heroicons/react/20/solid";
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper"; import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, IModule, ModuleLink } from "types"; import { ICurrentUserResponse, IIssue, linkDetails, IModule, ModuleLink } from "types";
// fetch-keys // fetch-keys
import { MODULE_DETAILS } from "constants/fetch-keys"; import { MODULE_DETAILS } from "constants/fetch-keys";
// constant // constant
@ -61,6 +61,7 @@ type Props = {
export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIssues, user }) => { export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIssues, user }) => {
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [moduleLinkModal, setModuleLinkModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -115,6 +116,37 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
}); });
}; };
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
if (!workspaceSlug || !projectId || !module) return;
const payload = { metadata: {}, ...formData };
const updatedLinks = module.link_module.map((l) =>
l.id === linkId
? {
...l,
title: formData.title,
url: formData.url,
}
: l
);
mutate<IModule>(
MODULE_DETAILS(module.id),
(prevData) => ({ ...(prevData as IModule), link_module: updatedLinks }),
false
);
await modulesService
.updateModuleLink(workspaceSlug as string, projectId as string, module.id, linkId, payload)
.then((res) => {
mutate(MODULE_DETAILS(module.id));
})
.catch((err) => {
console.log(err);
});
};
const handleDeleteLink = async (linkId: string) => { const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !module) return;
@ -170,12 +202,23 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
? Math.round((module.completed_issues / module.total_issues) * 100) ? Math.round((module.completed_issues / module.total_issues) * 100)
: null; : null;
const handleEditLink = (link: linkDetails) => {
setSelectedLinkToUpdate(link);
setModuleLinkModal(true);
};
return ( return (
<> <>
<LinkModal <LinkModal
isOpen={moduleLinkModal} isOpen={moduleLinkModal}
handleClose={() => setModuleLinkModal(false)} handleClose={() => {
onFormSubmit={handleCreateLink} setModuleLinkModal(false);
setSelectedLinkToUpdate(null);
}}
data={selectedLinkToUpdate}
status={selectedLinkToUpdate ? true : false}
createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink}
/> />
<DeleteModuleModal <DeleteModuleModal
isOpen={moduleDeleteModal} isOpen={moduleDeleteModal}
@ -544,7 +587,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
</Disclosure> </Disclosure>
</div> </div>
<div className="flex w-full flex-col border-t border-custom-border-200 px-6 py-6 text-xs"> <div className="flex w-full flex-col border-t border-custom-border-200 px-6 pt-6 pb-10 text-xs">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<h4 className="text-sm font-medium text-custom-text-200">Links</h4> <h4 className="text-sm font-medium text-custom-text-200">Links</h4>
<button <button
@ -558,6 +601,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
{memberRole && module.link_module && module.link_module.length > 0 ? ( {memberRole && module.link_module && module.link_module.length > 0 ? (
<LinksList <LinksList
links={module.link_module} links={module.link_module}
handleEditLink={handleEditLink}
handleDeleteLink={handleDeleteLink} handleDeleteLink={handleDeleteLink}
userAuth={memberRole} userAuth={memberRole}
/> />

View File

@ -53,7 +53,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick={() => { onClick={() => {
markNotificationReadStatus(notification.id); markNotificationReadStatus(notification.id);
router.push( router.push(
`/${workspaceSlug}/projects/${notification.project}/issues/${notification.data.issue.id}` `/${workspaceSlug}/projects/${notification.project}/${
notification.data.issue_activity.field === "archived_at" ? "archived-issues" : "issues"
}/${notification.data.issue.id}`
); );
}} }}
className={`group w-full flex items-center gap-4 p-3 pl-6 relative cursor-pointer ${ className={`group w-full flex items-center gap-4 p-3 pl-6 relative cursor-pointer ${
@ -78,7 +80,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
) : ( ) : (
<div className="w-12 h-12 bg-custom-background-80 rounded-full flex justify-center items-center"> <div className="w-12 h-12 bg-custom-background-80 rounded-full flex justify-center items-center">
<span className="text-custom-text-100 font-medium text-lg"> <span className="text-custom-text-100 font-medium text-lg">
{notification.triggered_by_details.display_name?.[0] ? ( {notification.triggered_by_details.is_bot ? (
notification.triggered_by_details.first_name?.[0]?.toUpperCase()
) : notification.triggered_by_details.display_name?.[0] ? (
notification.triggered_by_details.display_name?.[0]?.toUpperCase() notification.triggered_by_details.display_name?.[0]?.toUpperCase()
) : ( ) : (
<Icon iconName="person" className="h-6 w-6" /> <Icon iconName="person" className="h-6 w-6" />
@ -89,7 +93,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
</div> </div>
<div className="space-y-2.5 w-full overflow-hidden"> <div className="space-y-2.5 w-full overflow-hidden">
<div className="text-sm w-full break-words"> <div className="text-sm w-full break-words">
<span className="font-semibold">{notification.triggered_by_details.display_name} </span> <span className="font-semibold">
{notification.triggered_by_details.is_bot
? notification.triggered_by_details.first_name
: notification.triggered_by_details.display_name}{" "}
</span>
{notification.data.issue_activity.field !== "comment" && {notification.data.issue_activity.field !== "comment" &&
notification.data.issue_activity.verb}{" "} notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment" {notification.data.issue_activity.field === "comment"

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { mutate } from "swr"; import { mutate } from "swr";
@ -18,11 +17,12 @@ import useToast from "hooks/use-toast";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
// ui // ui
import { Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types // types
import { ICurrentUserResponse, IPageBlock } from "types"; import { ICurrentUserResponse, IPageBlock } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
handleClose: () => void; handleClose: () => void;
@ -39,22 +39,11 @@ const defaultValues = {
description_html: null, description_html: null,
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
loading: () => ( );
<Loader className="mx-4 mt-6">
<Loader.Item height="100px" width="100%" />
</Loader>
),
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = React.forwardRef< TiptapEditor.displayName = "TiptapEditor";
IRemirrorRichTextEditor,
IRemirrorRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
export const CreateUpdateBlockInline: React.FC<Props> = ({ export const CreateUpdateBlockInline: React.FC<Props> = ({
handleClose, handleClose,
@ -242,9 +231,9 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
description: description:
!data.description || data.description === "" !data.description || data.description === ""
? { ? {
type: "doc", type: "doc",
content: [{ type: "paragraph" }], content: [{ type: "paragraph" }],
} }
: data.description, : data.description,
description_html: data.description_html ?? "<p></p>", description_html: data.description_html ?? "<p></p>",
}); });
@ -297,23 +286,23 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
</div> </div>
<div className="page-block-section relative -mt-2 text-custom-text-200"> <div className="page-block-section relative -mt-2 text-custom-text-200">
<Controller <Controller
name="description" name="description_html"
control={control} control={control}
render={({ field: { value } }) => { render={({ field: { value, onChange } }) => {
if (!data) if (!data)
return ( return (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={{ workspaceSlug={workspaceSlug as string}
type: "doc", ref={editorRef}
content: [{ type: "paragraph" }], value={"<p></p>"}
}} debouncedUpdatesEnabled={false}
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm" customClassName="text-sm"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
ref={editorRef} onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/> />
); );
else if (!value || !watch("description_html")) else if (!value || !watch("description_html"))
@ -322,21 +311,22 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
); );
return ( return (
<WrappedRemirrorRichTextEditor <TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={ value={
value && value !== "" && Object.keys(value).length > 0 value && value !== "" && Object.keys(value).length > 0
? value ? value
: watch("description_html") && watch("description_html") !== ""
? watch("description_html")
: { type: "doc", content: [{ type: "paragraph" }] } : { type: "doc", content: [{ type: "paragraph" }] }
} }
onJSONChange={(jsonValue) => setValue("description", jsonValue)} debouncedUpdatesEnabled={false}
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
placeholder="Write something..."
customClassName="text-sm" customClassName="text-sm"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
ref={editorRef} onChange={(description: Object, description_html: string) => {
onChange(description_html);
setValue("description", description);
}}
/> />
); );
}} }}
@ -344,9 +334,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
<div className="m-2 mt-6 flex"> <div className="m-2 mt-6 flex">
<button <button
type="button" type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${ className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : ""
iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : "" }`}
}`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
> >
@ -378,8 +367,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
? "Updating..." ? "Updating..."
: "Update block" : "Update block"
: isSubmitting : isSubmitting
? "Adding..." ? "Adding..."
: "Add block"} : "Add block"}
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -1,7 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// ui // ui
@ -16,16 +14,6 @@ type Props = {
data?: IPage | null; data?: IPage | null;
}; };
// rich-text-editor
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
ssr: false,
loading: () => (
<Loader>
<Loader.Item height="12rem" width="100%" />
</Loader>
),
});
const defaultValues = { const defaultValues = {
name: "", name: "",
description: "", description: "",

View File

@ -19,7 +19,6 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { GptAssistantModal } from "components/core"; import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages"; import { CreateUpdateBlockInline } from "components/pages";
import RemirrorRichTextEditor, { IRemirrorRichTextEditor } from "components/rich-text-editor";
// ui // ui
import { CustomMenu, TextArea } from "components/ui"; import { CustomMenu, TextArea } from "components/ui";
// icons // icons
@ -39,6 +38,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types"; import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types";
// fetch-keys // fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
block: IPageBlock; block: IPageBlock;
@ -48,12 +48,12 @@ type Props = {
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
const WrappedRemirrorRichTextEditor = React.forwardRef< const TiptapEditor = React.forwardRef<
IRemirrorRichTextEditor, ITiptapRichTextEditor,
IRemirrorRichTextEditor ITiptapRichTextEditor
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />); >((props, ref) => <Tiptap {...props} forwardedRef={ref} />);
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; TiptapEditor.displayName = "TiptapEditor";
export const SinglePageBlock: React.FC<Props> = ({ export const SinglePageBlock: React.FC<Props> = ({
block, block,
@ -328,9 +328,8 @@ export const SinglePageBlock: React.FC<Props> = ({
</div> </div>
) : ( ) : (
<div <div
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${ className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : "" }`}
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
> >
@ -344,9 +343,8 @@ export const SinglePageBlock: React.FC<Props> = ({
</button> </button>
<div <div
ref={actionSectionRef} ref={actionSectionRef}
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${ className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${isMenuActive ? "!flex" : ""
isMenuActive ? "!flex" : "" }`}
}`}
> >
{block.issue && block.sync && ( {block.issue && block.sync && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs"> <div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
@ -360,9 +358,8 @@ export const SinglePageBlock: React.FC<Props> = ({
)} )}
<button <button
type="button" type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${ className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : ""
iAmFeelingLucky ? "cursor-wait" : "" }`}
}`}
onClick={handleAutoGenerateDescription} onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky} disabled={iAmFeelingLucky}
> >
@ -458,18 +455,18 @@ export const SinglePageBlock: React.FC<Props> = ({
{showBlockDetails {showBlockDetails
? block.description_html.length > 7 && ( ? block.description_html.length > 7 && (
<WrappedRemirrorRichTextEditor <TiptapEditor
value={block.description_html} workspaceSlug={workspaceSlug as string}
customClassName="text-sm" value={block.description_html}
noBorder customClassName="text-sm min-h-[150px]"
borderOnFocus={false} noBorder
/> borderOnFocus={false}
) />
: block.description_stripped.length > 0 && ( ) : block.description_stripped.length > 0 && (
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate"> <p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
{block.description_stripped} {block.description_stripped}
</p> </p>
)} )}
</div> </div>
</div> </div>
<GptAssistantModal <GptAssistantModal

View File

@ -1,7 +1,7 @@
// ui // ui
import { BarGraph, ProfileEmptyState, Loader } from "components/ui"; import { BarGraph, ProfileEmptyState, Loader } from "components/ui";
// image // image
import priorityGraph from "public/empty-state/priority_graph.svg"; import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
// helpers // helpers
import { capitalizeFirstLetter } from "helpers/string.helper"; import { capitalizeFirstLetter } from "helpers/string.helper";
// types // types
@ -12,10 +12,10 @@ type Props = {
}; };
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => ( export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => (
<div className="space-y-2"> <div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Issues by Priority</h3> <h3 className="text-lg font-medium">Issues by Priority</h3>
{userProfile ? ( {userProfile ? (
<div className="border border-custom-border-100 rounded"> <div className="flex-grow border border-custom-border-100 rounded">
{userProfile.priority_distribution.length > 0 ? ( {userProfile.priority_distribution.length > 0 ? (
<BarGraph <BarGraph
data={userProfile.priority_distribution.map((priority) => ({ data={userProfile.priority_distribution.map((priority) => ({
@ -63,11 +63,11 @@ export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) =>
}} }}
/> />
) : ( ) : (
<div className="p-7"> <div className="flex-grow p-7">
<ProfileEmptyState <ProfileEmptyState
title="No Data yet" title="No Data yet"
description="Create issues to view the them by priority in the graph for better analysis." description="Create issues to view the them by priority in the graph for better analysis."
image={priorityGraph} image={emptyBarGraph}
/> />
</div> </div>
)} )}

View File

@ -16,9 +16,9 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
if (!userProfile) return null; if (!userProfile) return null;
return ( return (
<div className="space-y-2"> <div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Issues by State</h3> <h3 className="text-lg font-medium">Issues by State</h3>
<div className="border border-custom-border-100 rounded p-7"> <div className="flex-grow border border-custom-border-100 rounded p-7">
{userProfile.state_distribution.length > 0 ? ( {userProfile.state_distribution.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div> <div>

View File

@ -110,7 +110,7 @@ export const ProfileIssuesView = () => {
draggedItem[groupByProperty] = destinationGroup; draggedItem[groupByProperty] = destinationGroup;
mutateProfileIssues((prevData) => { mutateProfileIssues((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const sourceGroupArray = [...groupedIssues[sourceGroup]]; const sourceGroupArray = [...groupedIssues[sourceGroup]];

View File

@ -105,9 +105,11 @@ export const ProfileSidebar = () => {
</div> </div>
<div className="px-5"> <div className="px-5">
<div className="mt-[38px]"> <div className="mt-[38px]">
<h4 className="text-lg font-semibold">{userProjectsData.user_data.display_name}</h4> <h4 className="text-lg font-semibold">
{userProjectsData.user_data.first_name} {userProjectsData.user_data.last_name}
</h4>
<h6 className="text-custom-text-200 text-sm"> <h6 className="text-custom-text-200 text-sm">
{userProjectsData.user_data.display_name} ({userProjectsData.user_data.display_name})
</h6> </h6>
</div> </div>
<div className="mt-6 space-y-5"> <div className="mt-6 space-y-5">

View File

@ -42,6 +42,7 @@ import { NETWORK_CHOICES } from "constants/project";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setToFavorite?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
@ -74,7 +75,12 @@ const IsGuestCondition: React.FC<{
return null; return null;
}; };
export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user }) => { export const CreateProjectModal: React.FC<Props> = ({
isOpen,
setIsOpen,
setToFavorite = false,
user,
}) => {
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -104,6 +110,29 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
reset(defaultValues); reset(defaultValues);
}; };
const handleAddToFavorites = (projectId: string) => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === projectId ? { ...p, is_favorite: true } : p)),
false
);
projectServices
.addProjectToFavorites(workspaceSlug as string, {
project: projectId,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const onSubmit = async (formData: IProject) => { const onSubmit = async (formData: IProject) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -125,6 +154,9 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
title: "Success!", title: "Success!",
message: "Project created successfully.", message: "Project created successfully.",
}); });
if (setToFavorite) {
handleAddToFavorites(res.id);
}
handleClose(); handleClose();
}) })
.catch((err) => { .catch((err) => {

View File

@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
@ -27,6 +29,11 @@ type TConfirmProjectDeletionProps = {
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
const defaultValues = {
projectName: "",
confirmDelete: "",
};
export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
isOpen, isOpen,
data, data,
@ -34,51 +41,41 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
onSuccess, onSuccess,
user, user,
}) => { }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [confirmProjectName, setConfirmProjectName] = useState("");
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject; const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
watch,
} = useForm({ defaultValues });
useEffect(() => { const canDelete =
if (data) setSelectedProject(data); watch("projectName") === data?.name && watch("confirmDelete") === "delete my project";
else {
const timer = setTimeout(() => {
setSelectedProject(null);
clearTimeout(timer);
}, 300);
}
}, [data]);
const handleClose = () => { const handleClose = () => {
setIsDeleteLoading(false);
const timer = setTimeout(() => { const timer = setTimeout(() => {
setConfirmProjectName(""); reset(defaultValues);
setConfirmDeleteMyProject(false);
clearTimeout(timer); clearTimeout(timer);
}, 350); }, 350);
onClose(); onClose();
}; };
const handleDeletion = async () => { const onSubmit = async () => {
if (!data || !workspaceSlug || !canDelete) return; if (!data || !workspaceSlug || !canDelete) return;
setIsDeleteLoading(true);
await projectService await projectService
.deleteProject(workspaceSlug as string, data.id, user) .deleteProject(workspaceSlug.toString(), data.id, user)
.then(() => { .then(() => {
handleClose(); handleClose();
mutate<IProject[]>( mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), PROJECTS_LIST(workspaceSlug.toString(), { is_favorite: "all" }),
(prevData) => prevData?.filter((project: IProject) => project.id !== data.id), (prevData) => prevData?.filter((project: IProject) => project.id !== data.id),
false false
); );
@ -91,8 +88,7 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
title: "Error!", title: "Error!",
message: "Something went wrong. Please try again later.", message: "Something went wrong. Please try again later.",
}) })
) );
.finally(() => setIsDeleteLoading(false));
}; };
return ( return (
@ -122,7 +118,7 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6"> <div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4"> <span className="place-items-center rounded-full bg-red-500/20 p-4">
<ExclamationTriangleIcon <ExclamationTriangleIcon
@ -137,28 +133,29 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
<span> <span>
<p className="text-sm leading-7 text-custom-text-200"> <p className="text-sm leading-7 text-custom-text-200">
Are you sure you want to delete project{" "} Are you sure you want to delete project{" "}
<span className="break-words font-semibold">{selectedProject?.name}</span>? <span className="break-words font-semibold">{data?.name}</span>? All of the
All of the data related to the project will be permanently removed. This data related to the project will be permanently removed. This action cannot be
action cannot be undone undone
</p> </p>
</span> </span>
<div className="text-custom-text-200"> <div className="text-custom-text-200">
<p className="break-words text-sm "> <p className="break-words text-sm ">
Enter the project name{" "} Enter the project name{" "}
<span className="font-medium text-custom-text-100"> <span className="font-medium text-custom-text-100">{data?.name}</span> to
{selectedProject?.name} continue:
</span>{" "}
to continue:
</p> </p>
<Input <Controller
type="text" control={control}
placeholder="Project name"
className="mt-2"
value={confirmProjectName}
onChange={(e) => {
setConfirmProjectName(e.target.value);
}}
name="projectName" name="projectName"
render={({ field: { onChange, value } }) => (
<Input
type="text"
placeholder="Project name"
className="mt-2"
value={value}
onChange={onChange}
/>
)}
/> />
</div> </div>
<div className="text-custom-text-200"> <div className="text-custom-text-200">
@ -167,31 +164,27 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
<span className="font-medium text-custom-text-100">delete my project</span>{" "} <span className="font-medium text-custom-text-100">delete my project</span>{" "}
below: below:
</p> </p>
<Input <Controller
type="text" control={control}
placeholder="Enter 'delete my project'" name="confirmDelete"
className="mt-2" render={({ field: { onChange, value } }) => (
onChange={(e) => { <Input
if (e.target.value === "delete my project") { type="text"
setConfirmDeleteMyProject(true); placeholder="Enter 'delete my project'"
} else { className="mt-2"
setConfirmDeleteMyProject(false); onChange={onChange}
} value={value}
}} />
name="typeDelete" )}
/> />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton <DangerButton type="submit" disabled={!canDelete} loading={isSubmitting}>
onClick={handleDeletion} {isSubmitting ? "Deleting..." : "Delete Project"}
disabled={!canDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Project"}
</DangerButton> </DangerButton>
</div> </div>
</div> </form>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -0,0 +1,474 @@
import React, { useEffect } from "react";
// next imports
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui components
import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui";
import { CustomPopover } from "./popover";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
import { IProjectPublishSettingsViews } from "store/project-publish";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
type Props = {
// user: ICurrentUserResponse | undefined;
};
const defaultValues: Partial<any> = {
id: null,
comments: false,
reactions: false,
votes: false,
inbox: null,
views: ["list", "kanban"],
};
const viewOptions = [
{ key: "list", value: "List" },
{ key: "kanban", value: "Kanban" },
// { key: "calendar", value: "Calendar" },
// { key: "gantt", value: "Gantt" },
// { key: "spreadsheet", value: "Spreadsheet" },
];
export const PublishProjectModal: React.FC<Props> = observer(() => {
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const { setToastAlert } = useToast();
const handleToastAlert = (title: string, type: string, message: string) => {
setToastAlert({
title: title || "Title",
type: "error" || "warning",
message: message || "Message",
});
};
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
? NEXT_PUBLIC_DEPLOY_URL
: "http://localhost:3001";
const router = useRouter();
const { workspaceSlug } = router.query;
const {
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
setValue,
} = useForm<any>({
defaultValues,
reValidateMode: "onChange",
});
const handleClose = () => {
projectPublish.handleProjectModal(null);
reset({ ...defaultValues });
};
useEffect(() => {
if (
projectPublish.projectPublishSettings &&
projectPublish.projectPublishSettings != "not-initialized"
) {
let userBoards: string[] = [];
if (projectPublish.projectPublishSettings?.views) {
const _views: IProjectPublishSettingsViews | null =
projectPublish.projectPublishSettings?.views || null;
if (_views != null) {
if (_views.list) userBoards.push("list");
if (_views.kanban) userBoards.push("kanban");
if (_views.calendar) userBoards.push("calendar");
if (_views.gantt) userBoards.push("gantt");
if (_views.spreadsheet) userBoards.push("spreadsheet");
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
}
}
const updatedData = {
id: projectPublish.projectPublishSettings?.id || null,
comments: projectPublish.projectPublishSettings?.comments || false,
reactions: projectPublish.projectPublishSettings?.reactions || false,
votes: projectPublish.projectPublishSettings?.votes || false,
inbox: projectPublish.projectPublishSettings?.inbox || null,
views: userBoards,
};
reset({ ...updatedData });
}
}, [reset, projectPublish.projectPublishSettings]);
useEffect(() => {
if (
projectPublish.projectPublishModal &&
workspaceSlug &&
projectPublish.project_id != null &&
projectPublish?.projectPublishSettings === "not-initialized"
) {
projectPublish.getProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
null
);
}
}, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]);
const onSettingsPublish = async (formData: any) => {
if (formData.views && formData.views.length > 0) {
const payload = {
comments: formData.comments || false,
reactions: formData.reactions || false,
votes: formData.votes || false,
inbox: formData.inbox || null,
views: {
list: formData.views.includes("list") || false,
kanban: formData.views.includes("kanban") || false,
calendar: formData.views.includes("calendar") || false,
gantt: formData.views.includes("gantt") || false,
spreadsheet: formData.views.includes("spreadsheet") || false,
},
};
const _workspaceSlug = workspaceSlug;
const _projectId = projectPublish.project_id;
return projectPublish
.createProjectSettingsAsync(_workspaceSlug as string, _projectId as string, payload, null)
.then((response) => {
mutateProjectDetails();
handleClose();
console.log("_projectId", _projectId);
if (_projectId)
window.open(`${plane_deploy_url}/${_workspaceSlug}/${_projectId}`, "_blank");
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
} else {
handleToastAlert("Missing fields", "warning", "Please select at least one view to publish");
}
};
const onSettingsUpdate = async (key: string, value: any) => {
const payload = {
comments: key === "comments" ? value : watch("comments"),
reactions: key === "reactions" ? value : watch("reactions"),
votes: key === "votes" ? value : watch("votes"),
inbox: key === "inbox" ? value : watch("inbox"),
views:
key === "views"
? {
list: value.includes("list") ? true : false,
kanban: value.includes("kanban") ? true : false,
calendar: value.includes("calendar") ? true : false,
gantt: value.includes("gantt") ? true : false,
spreadsheet: value.includes("spreadsheet") ? true : false,
}
: {
list: watch("views").includes("list") ? true : false,
kanban: watch("views").includes("kanban") ? true : false,
calendar: watch("views").includes("calendar") ? true : false,
gantt: watch("views").includes("gantt") ? true : false,
spreadsheet: watch("views").includes("spreadsheet") ? true : false,
},
};
return projectPublish
.updateProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
watch("id"),
payload,
null
)
.then((response) => {
mutateProjectDetails();
return response;
})
.catch((error) => {
console.log("error", error);
return error;
});
};
const onSettingsUnPublish = async (formData: any) =>
projectPublish
.deleteProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
formData?.id,
null
)
.then((response) => {
mutateProjectDetails();
reset({ ...defaultValues });
handleClose();
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => {
const [status, setStatus] = React.useState(false);
const copyText = () => {
navigator.clipboard.writeText(copy_link);
setStatus(true);
setTimeout(() => {
setStatus(false);
}, 1000);
};
return (
<div
className="border border-custom-border-100 bg-custom-background-100 text-xs px-2 min-w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer"
onClick={() => copyText()}
>
{status ? "Copied" : "Copy Link"}
</div>
);
};
return (
<Transition.Root show={projectPublish.projectPublishModal} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-200"
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-100"
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="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 space-y-4">
{/* heading */}
<div className="p-3 px-4 pb-0 flex gap-2 justify-between items-center">
<div className="font-medium text-xl">Publish</div>
{projectPublish.loader && (
<div className="text-xs text-custom-text-400">Changes saved</div>
)}
<div
className="hover:bg-custom-background-90 w-[30px] h-[30px] rounded flex justify-center items-center cursor-pointer transition-all"
onClick={handleClose}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
{/* content */}
<div className="space-y-3">
{watch("id") && (
<div className="flex items-center gap-1 px-4 text-custom-primary-100">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">
radio_button_checked
</span>
</div>
<div className="text-sm">This project is live on web</div>
</div>
)}
<div className="mx-4 border border-custom-border-100 bg-custom-background-90 rounded p-3 py-2 relative flex gap-2 items-center">
<div className="relative line-clamp-1 overflow-hidden w-full text-sm">
{`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-1">
<a
href={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
target="_blank"
rel="noreferrer"
>
<div className="border border-custom-border-100 bg-custom-background-100 w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">open_in_new</span>
</div>
</a>
<CopyLinkToClipboard
copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
/>
</div>
</div>
<div className="space-y-3 px-4">
<div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Views</div>
<div>
<CustomPopover
label={
watch("views") && watch("views").length > 0
? viewOptions
.filter(
(_view) => watch("views").includes(_view.key) && _view.value
)
.map((_view) => _view.value)
.join(", ")
: ``
}
placeholder="Select views"
>
<>
{viewOptions &&
viewOptions.length > 0 &&
viewOptions.map((_view) => (
<div
key={_view.value}
className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${
watch("views").includes(_view.key)
? `bg-custom-background-80 text-custom-text-100`
: `hover:bg-custom-background-80 hover:text-custom-text-100`
}`}
onClick={() => {
const _views =
watch("views") && watch("views").length > 0
? watch("views").includes(_view?.key)
? watch("views").filter((_o: string) => _o !== _view?.key)
: [...watch("views"), _view?.key]
: [_view?.key];
setValue("views", _views);
if (watch("id") != null) onSettingsUpdate("views", _views);
}}
>
<div className="text-sm">{_view.value}</div>
<div
className={`w-[18px] h-[18px] relative flex justify-center items-center`}
>
{watch("views") &&
watch("views").length > 0 &&
watch("views").includes(_view.key) && (
<span className="material-symbols-rounded text-[18px]">
done
</span>
)}
</div>
</div>
))}
</>
</CustomPopover>
</div>
</div>
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow comments</div>
<div>
<ToggleSwitch
value={watch("comments") ?? false}
onChange={() => {
const _comments = !watch("comments");
setValue("comments", _comments);
if (watch("id") != null) onSettingsUpdate("comments", _comments);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow reactions</div>
<div>
<ToggleSwitch
value={watch("reactions") ?? false}
onChange={() => {
const _reactions = !watch("reactions");
setValue("reactions", _reactions);
if (watch("id") != null) onSettingsUpdate("reactions", _reactions);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow Voting</div>
<div>
<ToggleSwitch
value={watch("votes") ?? false}
onChange={() => {
const _votes = !watch("votes");
setValue("votes", _votes);
if (watch("id") != null) onSettingsUpdate("votes", _votes);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow issue proposals</div>
<div>
<ToggleSwitch
value={watch("inbox") ?? false}
onChange={() => {
setValue("inbox", !watch("inbox"));
}}
size="sm"
/>
</div>
</div> */}
</div>
</div>
{/* modal handlers */}
<div className="border-t border-custom-border-300 p-3 px-4 relative flex justify-between items-center">
<div className="flex items-center gap-1 text-custom-text-300">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">public</span>
</div>
<div className="text-sm">Anyone with the link can access</div>
</div>
<div className="relative flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
{watch("id") != null ? (
<PrimaryButton
outline
onClick={handleSubmit(onSettingsUnPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Unpublishing..." : "Unpublish"}
</PrimaryButton>
) : (
<PrimaryButton
onClick={handleSubmit(onSettingsPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Publishing..." : "Publish"}
</PrimaryButton>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -0,0 +1,54 @@
import React, { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
export const CustomPopover = ({
children,
label,
placeholder = "Select",
}: {
children: React.ReactNode;
label?: string;
placeholder?: string;
}) => (
<div className="relative">
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`${
open ? "" : ""
} relative flex items-center gap-1 border border-custom-border-300 shadow-sm p-1 px-2 ring-0 outline-none`}
>
<div className="text-sm font-medium">
{label ? label : placeholder ? placeholder : "Select"}
</div>
<div className="w-[20px] h-[20px] relative flex justify-center items-center">
{!open ? (
<span className="material-symbols-rounded text-[20px]">expand_more</span>
) : (
<span className="material-symbols-rounded text-[20px]">expand_less</span>
)}
</div>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-[9999]">
<div className="overflow-hidden rounded-sm border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-lg focus:outline-none">
{children}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);

View File

@ -13,7 +13,7 @@ import useTheme from "hooks/use-theme";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
// components // components
import { DeleteProjectModal, SingleSidebarProject } from "components/project"; import { CreateProjectModal, DeleteProjectModal, SingleSidebarProject } from "components/project";
// services // services
import projectService from "services/project.service"; import projectService from "services/project.service";
// icons // icons
@ -32,6 +32,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
export const ProjectSidebarList: FC = () => { export const ProjectSidebarList: FC = () => {
const store: any = useMobxStore(); const store: any = useMobxStore();
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [deleteProjectModal, setDeleteProjectModal] = useState(false); const [deleteProjectModal, setDeleteProjectModal] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null); const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null);
@ -151,6 +152,12 @@ export const ProjectSidebarList: FC = () => {
return ( return (
<> <>
<CreateProjectModal
isOpen={isProjectModalOpen}
setIsOpen={setIsProjectModalOpen}
setToFavorite
user={user}
/>
<DeleteProjectModal <DeleteProjectModal
isOpen={deleteProjectModal} isOpen={deleteProjectModal}
onClose={() => setDeleteProjectModal(false)} onClose={() => setDeleteProjectModal(false)}
@ -172,17 +179,25 @@ export const ProjectSidebarList: FC = () => {
{({ open }) => ( {({ open }) => (
<> <>
{!store?.theme?.sidebarCollapsed && ( {!store?.theme?.sidebarCollapsed && (
<Disclosure.Button <div className="group flex justify-between items-center text-xs px-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 w-full">
as="button" <Disclosure.Button
type="button" as="button"
className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-400 text-left hover:bg-custom-sidebar-background-80 rounded w-full whitespace-nowrap" type="button"
> className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-400 text-left hover:bg-custom-sidebar-background-80 rounded w-full whitespace-nowrap"
Favorites >
<Icon Favorites
iconName={open ? "arrow_drop_down" : "arrow_right"} <Icon
className="group-hover:opacity-100 opacity-0 !text-lg" iconName={open ? "arrow_drop_down" : "arrow_right"}
/> className="group-hover:opacity-100 opacity-0 !text-lg"
</Disclosure.Button> />
</Disclosure.Button>
<button
className="group-hover:opacity-100 opacity-0"
onClick={() => setIsProjectModalOpen(true)}
>
<Icon iconName="add" />
</button>
</div>
)} )}
<Disclosure.Panel as="div" className="space-y-2"> <Disclosure.Panel as="div" className="space-y-2">
{orderedFavProjects.map((project, index) => ( {orderedFavProjects.map((project, index) => (
@ -241,10 +256,7 @@ export const ProjectSidebarList: FC = () => {
</Disclosure.Button> </Disclosure.Button>
<button <button
className="group-hover:opacity-100 opacity-0" className="group-hover:opacity-100 opacity-0"
onClick={() => { onClick={() => setIsProjectModalOpen(true)}
const e = new KeyboardEvent("keydown", { key: "p" });
document.dispatchEvent(e);
}}
> >
<Icon iconName="add" /> <Icon iconName="add" />
</button> </button>

View File

@ -32,6 +32,11 @@ import { renderEmoji } from "helpers/emoji.helper";
import { IProject } from "types"; import { IProject } from "types";
// fetch-keys // fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys"; import { PROJECTS_LIST } from "constants/fetch-keys";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
type Props = { type Props = {
project: IProject; project: IProject;
@ -76,252 +81,277 @@ const navigation = (workspaceSlug: string, projectId: string) => [
}, },
]; ];
export const SingleSidebarProject: React.FC<Props> = ({ export const SingleSidebarProject: React.FC<Props> = observer(
project, ({
sidebarCollapse, project,
provided, sidebarCollapse,
snapshot, provided,
handleDeleteProject, snapshot,
handleCopyText, handleDeleteProject,
shortContextMenu = false, handleCopyText,
}) => { shortContextMenu = false,
const router = useRouter(); }) => {
const { workspaceSlug, projectId } = router.query; const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { setToastAlert } = useToast(); const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const isAdmin = project.member_role === 20; const { setToastAlert } = useToast();
const handleAddToFavorites = () => { const isAdmin = project.member_role === 20;
if (!workspaceSlug) return;
mutate<IProject[]>( const handleAddToFavorites = () => {
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), if (!workspaceSlug) return;
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService mutate<IProject[]>(
.addProjectToFavorites(workspaceSlug as string, { PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
project: project.id, (prevData) =>
}) (prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
.catch(() => false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.", message: "Couldn't remove the project from favorites. Please try again.",
}) })
); );
}; };
const handleRemoveFromFavorites = () => { return (
if (!workspaceSlug) return; <Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
mutate<IProject[]>( <>
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }), <div
(prevData) => className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)), snapshot?.isDragging ? "opacity-60" : ""
false }`}
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
return (
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
<>
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
>
{provided && (
<Tooltip
tooltipContent={
project.sort_order === null
? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
> >
<Disclosure.Button {provided && (
as="div" <Tooltip
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${ tooltipContent={
sidebarCollapse ? "justify-center" : `justify-between` project.sort_order === null
}`} ? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
> >
<div <Disclosure.Button
className={`flex items-center flex-grow w-full truncate gap-x-2 ${ as="div"
sidebarCollapse ? "justify-center" : "" className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
sidebarCollapse ? "justify-center" : `justify-between`
}`} }`}
> >
{project.emoji ? ( <div
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase"> className={`flex items-center flex-grow w-full truncate gap-x-2 ${
{renderEmoji(project.emoji)} sidebarCollapse ? "justify-center" : ""
</span> }`}
) : project.icon_prop ? ( >
<div className="h-7 w-7 flex-shrink-0 grid place-items-center"> {project.emoji ? (
{renderEmoji(project.icon_prop)} <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
</div> {renderEmoji(project.emoji)}
) : ( </span>
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white"> ) : project.icon_prop ? (
{project?.name.charAt(0)} <div className="h-7 w-7 flex-shrink-0 grid place-items-center">
</span> {renderEmoji(project.icon_prop)}
)} </div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
{!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
{project.name}
</p>
)}
</div>
{!sidebarCollapse && ( {!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}> <ExpandMoreOutlined
{project.name} fontSize="small"
</p> className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/>
)} )}
</div> </Disclosure.Button>
{!sidebarCollapse && ( </Tooltip>
<ExpandMoreOutlined
fontSize="small"
className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/>
)}
</Disclosure.Button>
</Tooltip>
{!sidebarCollapse && ( {!sidebarCollapse && (
<CustomMenu <CustomMenu
className="hidden group-hover:block flex-shrink-0" className="hidden group-hover:block flex-shrink-0"
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400" buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis ellipsis
> >
{!shortContextMenu && isAdmin && ( {!shortContextMenu && isAdmin && (
<CustomMenu.MenuItem onClick={handleDeleteProject}> <CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 "> <span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
<span>Delete project</span> <span>Delete project</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{!project.is_favorite && ( {!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}> <CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
<span>Add to favorites</span> <span>Copy project link</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)}
{project.is_favorite && ( {/* publish project settings */}
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}> {isAdmin && (
<span className="flex items-center justify-start gap-2"> <CustomMenu.MenuItem
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" /> onClick={() => projectPublish.handleProjectModal(project?.id)}
<span>Remove from favorites</span> >
</span> <div className="flex-shrink-0 relative flex items-center justify-start gap-2">
</CustomMenu.MenuItem> <div className="rounded transition-all w-4 h-4 flex justify-center items-center text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 duration-300 cursor-pointer">
)} <span className="material-symbols-rounded text-[16px]">ios_share</span>
<CustomMenu.MenuItem onClick={handleCopyText}> </div>
<span className="flex items-center justify-start gap-2"> <div>Publish</div>
<LinkIcon className="h-4 w-4" /> </div>
<span>Copy project link</span> {/* <PublishProjectModal /> */}
</span> </CustomMenu.MenuItem>
</CustomMenu.MenuItem> )}
{project.archive_in > 0 && (
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`) router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)
} }
> >
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" /> <Icon iconName="settings" className="!text-base !leading-4" />
<span>Archived Issues</span> <span>Settings</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} </CustomMenu>
<CustomMenu.MenuItem )}
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} </div>
>
<div className="flex items-center justify-start gap-2">
<Icon iconName="settings" className="!text-base !leading-4" />
<span>Settings</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
<Transition <Transition
enter="transition duration-100 ease-out" enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0" enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100" enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out" leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100" leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0" leaveTo="transform scale-95 opacity-0"
> >
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}> <Disclosure.Panel
{navigation(workspaceSlug as string, project?.id).map((item) => { className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}
if ( >
(item.name === "Cycles" && !project.cycle_view) || {navigation(workspaceSlug as string, project?.id).map((item) => {
(item.name === "Modules" && !project.module_view) || if (
(item.name === "Views" && !project.issue_views_view) || (item.name === "Cycles" && !project.cycle_view) ||
(item.name === "Pages" && !project.page_view) (item.name === "Modules" && !project.module_view) ||
) (item.name === "Views" && !project.issue_views_view) ||
return; (item.name === "Pages" && !project.page_view)
)
return;
return ( return (
<Link key={item.name} href={item.href}> <Link key={item.name} href={item.href}>
<a className="block w-full"> <a className="block w-full">
<Tooltip <Tooltip
tooltipContent={`${project?.name}: ${item.name}`} tooltipContent={`${project?.name}: ${item.name}`}
position="right" position="right"
className="ml-2" className="ml-2"
disabled={!sidebarCollapse} disabled={!sidebarCollapse}
>
<div
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
> >
<item.Icon <div
sx={{ className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
fontSize: 18, router.asPath.includes(item.href)
}} ? "bg-custom-primary-100/10 text-custom-primary-100"
/> : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
{!sidebarCollapse && item.name} } ${sidebarCollapse ? "justify-center" : ""}`}
</div> >
</Tooltip> <item.Icon
</a> sx={{
</Link> fontSize: 18,
); }}
})} />
</Disclosure.Panel> {!sidebarCollapse && item.name}
</Transition> </div>
</> </Tooltip>
)} </a>
</Disclosure> </Link>
); );
}; })}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
}
);

View File

@ -1,236 +0,0 @@
import { useCallback, useState, useImperativeHandle } from "react";
import { useRouter } from "next/router";
import { InvalidContentHandler } from "remirror";
import {
BoldExtension,
ItalicExtension,
CalloutExtension,
PlaceholderExtension,
CodeBlockExtension,
CodeExtension,
HistoryExtension,
LinkExtension,
UnderlineExtension,
HeadingExtension,
OrderedListExtension,
ListItemExtension,
BulletListExtension,
ImageExtension,
DropCursorExtension,
StrikeExtension,
MentionAtomExtension,
FontSizeExtension,
} from "remirror/extensions";
import {
Remirror,
useRemirror,
EditorComponent,
OnChangeJSON,
OnChangeHTML,
FloatingToolbar,
FloatingWrapper,
} from "@remirror/react";
import { TableExtension } from "@remirror/extension-react-tables";
// tlds
import tlds from "tlds";
// services
import fileService from "services/file.service";
// components
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
import { MentionAutoComplete } from "./mention-autocomplete";
export interface IRemirrorRichTextEditor {
placeholder?: string;
mentions?: any[];
tags?: any[];
onBlur?: (jsonValue: any, htmlValue: any) => void;
onJSONChange?: (jsonValue: any) => void;
onHTMLChange?: (htmlValue: any) => void;
value?: any;
showToolbar?: boolean;
editable?: boolean;
customClassName?: string;
gptOption?: boolean;
noBorder?: boolean;
borderOnFocus?: boolean;
forwardedRef?: any;
}
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
const {
placeholder,
mentions = [],
tags = [],
onBlur = () => {},
onJSONChange = () => {},
onHTMLChange = () => {},
value = "",
showToolbar = true,
editable = true,
customClassName,
gptOption = false,
noBorder = false,
borderOnFocus = true,
forwardedRef,
} = props;
const [disableToolbar, setDisableToolbar] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
// remirror error handler
const onError: InvalidContentHandler = useCallback(
({ json, invalidContent, transformers }: any) =>
// Automatically remove all invalid nodes and marks.
transformers.remove(json, invalidContent),
[]
);
const uploadImageHandler = (value: any): any => {
try {
const formData = new FormData();
formData.append("asset", value[0].file);
formData.append("attributes", JSON.stringify({}));
return [
() =>
new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug as string, formData)
.then((response) => response.asset);
resolve({
align: "left",
alt: "Not Found",
height: "100%",
width: "35%",
src: imageUrl,
});
}),
];
} catch {
return [];
}
};
// remirror manager
const { manager, state } = useRemirror({
extensions: () => [
new BoldExtension(),
new ItalicExtension(),
new UnderlineExtension(),
new HeadingExtension({ levels: [1, 2, 3] }),
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
new OrderedListExtension(),
new ListItemExtension(),
new BulletListExtension({ enableSpine: true }),
new CalloutExtension({ defaultType: "warn" }),
new CodeBlockExtension(),
new CodeExtension(),
new PlaceholderExtension({
placeholder: placeholder || "Enter text...",
emptyNodeClass: "empty-node",
}),
new HistoryExtension(),
new LinkExtension({
autoLink: true,
autoLinkAllowedTLDs: tlds,
selectTextOnClick: true,
defaultTarget: "_blank",
}),
new ImageExtension({
enableResizing: true,
uploadHandler: uploadImageHandler,
createPlaceholder() {
const div = document.createElement("div");
div.className =
"w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse";
return div;
},
}),
new DropCursorExtension(),
new StrikeExtension(),
new MentionAtomExtension({
matchers: [
{ name: "at", char: "@" },
{ name: "tag", char: "#" },
],
}),
new TableExtension(),
],
content: value,
selection: "start",
stringHandler: "html",
onError,
});
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
},
setEditorValue: (value: any) => {
manager.view.updateState(
manager.createState({
content: value,
selection: "end",
})
);
},
}));
return (
<div className="relative">
<Remirror
manager={manager}
initialContent={state}
classNames={[
`p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 ${
noBorder ? "" : "border border-custom-border-200"
} ${
borderOnFocus ? "focus:border border-custom-border-200" : "focus:border-0"
} ${customClassName}`,
]}
editable={editable}
onBlur={(event) => {
const html = event.helpers.getHTML();
const json = event.helpers.getJSON();
setDisableToolbar(true);
onBlur(json, html);
}}
onFocus={() => setDisableToolbar(false)}
>
<div className="prose prose-brand max-w-full prose-p:my-1">
<EditorComponent />
</div>
{editable && !disableToolbar && (
<FloatingWrapper
positioner="always"
renderOutsideEditor
floatingLabel="Custom Floating Toolbar"
>
<FloatingToolbar className="z-50 overflow-hidden rounded">
<CustomFloatingToolbar
gptOption={gptOption}
editorState={state}
setDisableToolbar={setDisableToolbar}
/>
</FloatingToolbar>
</FloatingWrapper>
)}
<MentionAutoComplete mentions={mentions} tags={tags} />
{<OnChangeJSON onChange={onJSONChange} />}
{<OnChangeHTML onChange={onHTMLChange} />}
</Remirror>
</div>
);
};
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
export default RemirrorRichTextEditor;

View File

@ -1,64 +0,0 @@
import { useState, useEffect, FC } from "react";
// remirror imports
import { cx } from "@remirror/core";
import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react";
// export const;
export interface IMentionAutoComplete {
mentions?: any[];
tags?: any[];
}
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
const { mentions = [], tags = [] } = props;
// states
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({
items: options,
});
useEffect(() => {
if (!state) {
return;
}
const searchTerm = state.query.full.toLowerCase();
let filteredOptions: MentionAtomNodeAttributes[] = [];
if (state.name === "tag") {
filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm));
} else if (state.name === "at") {
filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm));
}
filteredOptions = filteredOptions.sort().slice(0, 5);
setOptions(filteredOptions);
}, [state, mentions, tags]);
const enabled = Boolean(state);
return (
<FloatingWrapper positioner="cursor" enabled={enabled} placement="bottom-start">
<div {...getMenuProps()} className="suggestions">
{enabled &&
options.map((user, index) => {
const isHighlighted = indexIsSelected(index);
const isHovered = indexIsHovered(index);
return (
<div
key={user.id}
className={cx("suggestion", isHighlighted && "highlighted", isHovered && "hovered")}
{...getItemProps({
item: user,
index,
})}
>
{user.label}
</div>
);
})}
</div>
</FloatingWrapper>
);
};

View File

@ -1,145 +0,0 @@
import React, { useEffect, useState } from "react";
import { TableExtension } from "@remirror/extension-react-tables";
import {
EditorComponent,
ReactComponentExtension,
Remirror,
TableComponents,
tableControllerPluginKey,
ThemeProvider,
useCommands,
useRemirror,
useRemirrorContext,
} from "@remirror/react";
import type { AnyExtension } from "remirror";
const CommandMenu: React.FC = () => {
const { createTable, ...commands } = useCommands();
return (
<div>
<p>commands:</p>
<p
style={{
display: "flex",
flexDirection: "column",
justifyItems: "flex-start",
alignItems: "flex-start",
}}
>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*3 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3-headers"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
>
insert a 3*3 table with headers
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-4-10"
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
>
insert a 4*10 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-30"
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*30 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-8-100"
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
>
insert a 8*100 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableColumnAfter()}
>
add a column after the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableRowBefore()}
>
add a row before the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.deleteTable()}
>
delete the table
</button>
</p>
</div>
);
};
const ProsemirrorDocData: React.FC = () => {
const ctx = useRemirrorContext({ autoUpdate: false });
const [jsonPluginState, setJsonPluginState] = useState("");
const [jsonDoc, setJsonDoc] = useState("");
const { addHandler, view } = ctx;
useEffect(() => {
addHandler("updated", () => {
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
setJsonPluginState(
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
);
});
}, [addHandler, view]);
return (
<div>
<p>tableControllerPluginKey.getState(view.state)</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonPluginState}</code>
</pre>
<p>view.state.doc.toJSON()</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonDoc}</code>
</pre>
</div>
);
};
const Table = ({
children,
extensions,
}: {
children?: React.ReactElement;
extensions: () => AnyExtension[];
}): JSX.Element => {
const { manager, state } = useRemirror({ extensions });
return (
<ThemeProvider>
<Remirror manager={manager} initialContent={state}>
<EditorComponent />
<TableComponents />
<CommandMenu />
<ProsemirrorDocData />
{children}
</Remirror>
</ThemeProvider>
);
};
const Basic = (): JSX.Element => <Table extensions={defaultExtensions} />;
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
export default Basic;

View File

@ -1,316 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
ToggleCodeButton,
ToggleHeadingButton,
useActive,
CommandButton,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
import { EditorState } from "remirror";
type Props = {
gptOption?: boolean;
editorState: Readonly<EditorState>;
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
};
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
setIsEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[
href,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
setIsEditing,
]
);
};
const DelayAutoFocusInput = ({
autoFocus,
setDisableToolbar,
...rest
}: HTMLProps<HTMLInputElement> & {
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
setDisableToolbar(false);
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus, setDisableToolbar]);
useEffect(() => {
setDisableToolbar(false);
}, [setDisableToolbar]);
return (
<>
<label htmlFor="link-input" className="text-sm">
Add Link
</label>
<input
ref={inputRef}
{...rest}
onKeyDown={(e) => {
if (rest.onKeyDown) rest.onKeyDown(e);
setDisableToolbar(false);
}}
className={`${rest.className} mt-1`}
onFocus={() => {
setDisableToolbar(false);
}}
onBlur={() => {
setDisableToolbar(true);
}}
/>
</>
);
};
export const CustomFloatingToolbar: React.FC<Props> = ({
gptOption,
editorState,
setDisableToolbar,
}) => {
const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
return (
<div className="z-[99999] flex flex-col items-center gap-y-2 divide-x divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-80 p-1 px-0.5 shadow-md">
<div className="flex items-center gap-y-2 divide-x divide-custom-border-200">
<div className="flex items-center gap-x-1 px-2">
<ToggleHeadingButton
attrs={{
level: 1,
}}
/>
<ToggleHeadingButton
attrs={{
level: 2,
}}
/>
<ToggleHeadingButton
attrs={{
level: 3,
}}
/>
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
{gptOption && (
<div className="flex items-center gap-x-1 px-2">
<button
type="button"
className="rounded py-1 px-1.5 text-xs hover:bg-custom-background-90"
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
>
AI
</button>
</div>
)}
<div className="flex items-center gap-x-1 px-2">
<ToggleCodeButton />
</div>
{activeLink ? (
<div className="flex items-center gap-x-1 px-2">
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</div>
) : (
<CommandButton
commandName="updateLink"
onSelect={() => {
if (isEditing) {
setIsEditing(false);
} else {
handleClickEdit();
}
}}
icon="link"
enabled
active={isEditing}
/>
)}
</div>
{isEditing && (
<div className="p-2 w-full">
<DelayAutoFocusInput
autoFocus
placeholder="Paste your link here..."
id="link-input"
setDisableToolbar={setDisableToolbar}
className="w-full px-2 py-0.5"
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</div>
)}
</div>
);
};

View File

@ -1,57 +0,0 @@
// remirror
import { useCommands, useActive } from "@remirror/react";
// ui
import { CustomMenu } from "components/ui";
const HeadingControls = () => {
const { toggleHeading, focus } = useCommands();
const active = useActive();
return (
<div className="flex items-center gap-1">
<CustomMenu
width="lg"
label={`${
active.heading({ level: 1 })
? "Heading 1"
: active.heading({ level: 2 })
? "Heading 2"
: active.heading({ level: 3 })
? "Heading 3"
: "Normal text"
}`}
>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 1 });
focus();
}}
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
>
Heading 1
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 2 });
focus();
}}
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
>
Heading 2
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 3 });
focus();
}}
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
>
Heading 3
</CustomMenu.MenuItem>
</CustomMenu>
</div>
);
};
export default HeadingControls;

View File

@ -1,35 +0,0 @@
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
RedoButton,
UndoButton,
} from "@remirror/react";
// headings
import HeadingControls from "./heading-controls";
export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x">
<div className="flex items-center gap-x-1 px-2">
<RedoButton />
<UndoButton />
</div>
<div className="px-2">
<HeadingControls />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
</div>
);

View File

@ -1,215 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
import {
CommandButton,
FloatingToolbar,
FloatingWrapper,
useActive,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref]
);
};
const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus]);
return <input ref={inputRef} {...rest} />;
};
export const FloatingLinkToolbar = () => {
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const { empty } = useCurrentSelection();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
const linkEditButtons = activeLink ? (
<>
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</>
) : (
<CommandButton commandName="updateLink" onSelect={handleClickEdit} icon="link" enabled />
);
return (
<>
{!isEditing && (
<FloatingToolbar className="rounded bg-custom-background-80 p-1 shadow-lg">
{linkEditButtons}
</FloatingToolbar>
)}
{!isEditing && empty && (
<FloatingToolbar
positioner={linkPositioner}
className="rounded bg-custom-background-80 p-1 shadow-lg"
>
{linkEditButtons}
</FloatingToolbar>
)}
<FloatingWrapper
positioner="always"
placement="bottom"
enabled={isEditing}
renderOutsideEditor
>
<DelayAutoFocusInput
autoFocus
placeholder="Enter link..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</FloatingWrapper>
</>
);
};

View File

@ -1,55 +0,0 @@
import { useCommands } from "@remirror/react";
export const TableControls = () => {
const { createTable, ...commands } = useCommands();
return (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-table"
width="18"
height="18"
viewBox="0 0 24 24"
stroke="#2c3e50"
fill="none"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<line x1="4" y1="10" x2="20" y2="10" />
<line x1="10" y1="4" x2="10" y2="20" />
</svg>
</button>
<button
type="button"
onClick={() => commands.deleteTable()}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-trash"
width="18"
height="18"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#2c3e50"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
);
};

View File

@ -0,0 +1,119 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
import { FC, useState } from "react";
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import { NodeSelector } from "./node-selector";
import { LinkSelector } from "./link-selector";
import { cn } from "../utils";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor.isActive("bold"),
command: () => props.editor.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor.isActive("italic"),
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor.isActive("underline"),
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor.isActive("strike"),
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor.isActive("code"),
command: () => props.editor.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ editor }) => {
if (!editor.isEditable) {
return false;
}
if (editor.isActive("image")) {
return false;
}
return editor.view.state.selection.content().size > 0;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
<div className="flex">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</BubbleMenu>
);
};

View File

@ -0,0 +1,90 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { cn } from "../utils";
import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
const input = inputRef.current;
const url = input?.value;
if (url && isValidHttpUrl(url)) {
editor.chain().focus().setLink({ href: url }).run();
setIsOpen(false);
}
}, [editor, inputRef, setIsOpen]);
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
return (
<div className="relative">
<button
type="button"
className={cn(
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
)}
onClick={() => {
setIsOpen(!isOpen);
}}
>
<p className="text-base"></p>
<p
className={cn("underline underline-offset-4", {
"text-custom-text-100": editor.isActive("link"),
})}
>
Link
</p>
</button>
{isOpen && (
<div
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); onLinkSubmit();
}
}}
>
<input
ref={inputRef}
type="url"
placeholder="Paste a link"
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsOpen(false);
}}
>
<Trash className="h-4 w-4" />
</button>
) : (
<button className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90" type="button"
onClick={() => {
onLinkSubmit();
}}
>
<Check className="h-4 w-4" />
</button>
)}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,130 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from "../bubble-menu";
import { cn } from "../utils";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "H1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "H2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "H3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<div className="relative h-full">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={() => {
item.command();
setIsOpen(false);
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
)}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-custom-border-300 p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</button>
))}
</section>
)}
</div>
);
};

View File

@ -0,0 +1,12 @@
export default function isValidHttpUrl(string: string): boolean {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}

View File

@ -0,0 +1,57 @@
import { Editor } from "@tiptap/react";
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(
".ProseMirror-selectednode",
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
} as any);
editor.commands.setNodeSelection(selection.from);
}
};
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio={true}
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
delta,
}:
any) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable={true}
renderDirections={["w", "e"]}
onScale={({
target,
transform,
}:
any) => {
target!.style.transform = transform;
}}
/>
</>
);
};

View File

@ -0,0 +1,137 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
lowlight.registerLanguage("ts", ts);
export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 2,
},
gapcursor: false,
}),
CodeBlockLowlight.configure({
lowlight,
}),
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
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,
}),
];

View File

@ -0,0 +1,22 @@
import Image from "@tiptap/extension-image";
import TrackImageDeletionPlugin from "../plugins/delete-image";
import UploadImagesPlugin from "../plugins/upload-image";
const UpdatedImage = Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin()];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: '35%',
},
height: {
default: null,
},
};
},
});
export default UpdatedImage;

View File

@ -0,0 +1,100 @@
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { useImperativeHandle, useRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
export interface ITiptapRichTextEditor {
value: string;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
workspaceSlug: string;
editable?: boolean;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
}
const Tiptap = (props: ITiptapRichTextEditor) => {
const {
onChange,
debouncedUpdatesEnabled,
forwardedRef,
editable,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
noBorder,
workspaceSlug,
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
content: value,
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange, editor });
} else {
onChange?.(editor.getJSON(), editor.getHTML());
}
},
});
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
}));
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
setTimeout(async () => {
if (onChange) {
onChange(editor.getJSON(), editor.getHTML());
}
}, 500);
}, 1000);
const editorClassNames = `relative w-full max-w-screen-lg sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
return (
<div
id="tiptap-container"
onClick={() => {
editor?.chain().focus().run();
}}
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
>
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>
);
};
export default Tiptap;

View File

@ -0,0 +1,56 @@
import { Plugin, PluginKey } 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 TrackImageDeletionPlugin = () =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') 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);
}
}
});
removedImages.forEach((node) => {
const src = node.attrs.src;
onNodeDeleted(src);
});
});
return null;
},
});
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");
}
}

View File

@ -0,0 +1,127 @@
// @ts-nocheck
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import fileService from "services/file.service";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
);
return found.length ? found[0].from : null;
}
export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
if (!workspaceSlug) {
return;
}
setIsSubmitting?.("submitting")
const src = await UploadImageHandler(file, workspaceSlug);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
}
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
if (!workspaceSlug) {
return Promise.reject("Workspace slug is missing");
}
try {
const formData = new FormData();
formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
});
} catch (error) {
console.log(error);
return Promise.reject(error);
}
};

View File

@ -0,0 +1,56 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps {
return {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
}
return true;
}
return false;
},
};
}

View File

@ -0,0 +1,339 @@
import React, { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Text,
TextQuote,
Code,
MinusSquare,
CheckSquare,
ImageIcon,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => ({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, workspaceSlug, setIsSubmitting);
}
};
input.click();
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[command, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
>
{items.map((item: CommandItemProps, index: number) => (
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
)}
key={index}
onClick={() => selectItem(index)}
>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-custom-text-300">{item.description}</p>
</div>
</button>
))}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#tiptap-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
export const SlashCommand = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
Command.configure({
suggestion: {
items: getSuggestionItems(workspaceSlug, setIsSubmitting),
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -106,7 +106,7 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
))} ))}
{users.length > length ? ( {users.length > length ? (
<div className="-ml-3.5 relative h-6 w-6 rounded"> <div className="-ml-3.5 relative h-6 w-6 rounded">
<div className="grid place-items-center rounded bg-custom-background-80 text-xs capitalize h-6 w-6 text-custom-text-200 border-[0.5px] border-custom-border-300"> <div className="flex items-center rounded bg-custom-background-80 text-xs capitalize h-6 w-6 text-custom-text-200 border-[0.5px] border-custom-border-300">
<Icon iconName="add" className="text-xs !leading-3 -mr-0.5" /> <Icon iconName="add" className="text-xs !leading-3 -mr-0.5" />
{users.length - length} {users.length - length}
</div> </div>

View File

@ -8,10 +8,13 @@ type Props = {
renderAs?: "input" | "button"; renderAs?: "input" | "button";
value: Date | string | null | undefined; value: Date | string | null | undefined;
onChange: (val: string | null) => void; onChange: (val: string | null) => void;
handleOnOpen?: () => void;
handleOnClose?: () => void;
placeholder?: string; placeholder?: string;
displayShortForm?: boolean; displayShortForm?: boolean;
error?: boolean; error?: boolean;
noBorder?: boolean; noBorder?: boolean;
wrapperClassName?: string;
className?: string; className?: string;
isClearable?: boolean; isClearable?: boolean;
disabled?: boolean; disabled?: boolean;
@ -23,10 +26,13 @@ export const CustomDatePicker: React.FC<Props> = ({
renderAs = "button", renderAs = "button",
value, value,
onChange, onChange,
handleOnOpen,
handleOnClose,
placeholder = "Select date", placeholder = "Select date",
displayShortForm = false, displayShortForm = false,
error = false, error = false,
noBorder = false, noBorder = false,
wrapperClassName = "",
className = "", className = "",
isClearable = true, isClearable = true,
disabled = false, disabled = false,
@ -40,6 +46,9 @@ export const CustomDatePicker: React.FC<Props> = ({
if (!val) onChange(null); if (!val) onChange(null);
else onChange(renderDateFormat(val)); else onChange(renderDateFormat(val));
}} }}
onCalendarOpen={handleOnOpen}
onCalendarClose={handleOnClose}
wrapperClassName={wrapperClassName}
className={`${ className={`${
renderAs === "input" renderAs === "input"
? "block px-2 py-2 text-sm focus:outline-none" ? "block px-2 py-2 text-sm focus:outline-none"

View File

@ -1,47 +1,76 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
type Props = { type Props = {
position: { clickEvent: React.MouseEvent | null;
x: number;
y: number;
};
children: React.ReactNode; children: React.ReactNode;
title?: string | JSX.Element; title?: string | JSX.Element;
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const ContextMenu = ({ position, children, title, isOpen, setIsOpen }: Props) => { const ContextMenu = ({ clickEvent, children, title, isOpen, setIsOpen }: Props) => {
const contextMenuRef = useRef<HTMLDivElement>(null);
// Close the context menu when clicked outside
useOutsideClickDetector(contextMenuRef, () => {
if (isOpen) setIsOpen(false);
});
useEffect(() => { useEffect(() => {
const hideContextMenu = () => { const hideContextMenu = () => {
if (isOpen) setIsOpen(false); if (isOpen) setIsOpen(false);
}; };
window.addEventListener("click", hideContextMenu); const escapeKeyEvent = (e: KeyboardEvent) => {
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") hideContextMenu(); if (e.key === "Escape") hideContextMenu();
}); };
window.addEventListener("click", hideContextMenu);
window.addEventListener("keydown", escapeKeyEvent);
return () => { return () => {
window.removeEventListener("click", hideContextMenu); window.removeEventListener("click", hideContextMenu);
window.removeEventListener("keydown", hideContextMenu); window.removeEventListener("keydown", escapeKeyEvent);
}; };
}, [isOpen, setIsOpen]); }, [isOpen, setIsOpen]);
useEffect(() => {
const contextMenu = contextMenuRef.current;
if (contextMenu && isOpen) {
const contextMenuWidth = contextMenu.clientWidth;
const contextMenuHeight = contextMenu.clientHeight;
const clickX = clickEvent?.pageX || 0;
const clickY = clickEvent?.pageY || 0;
let top = clickY;
// check if there's enough space at the bottom, otherwise show at the top
if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight;
// check if there's enough space on the right, otherwise show on the left
let left = clickX;
if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth;
contextMenu.style.top = `${top}px`;
contextMenu.style.left = `${left}px`;
}
}, [clickEvent, isOpen]);
return ( return (
<div <div
className={`fixed z-20 h-full w-full ${ className={`fixed z-50 top-0 left-0 h-full w-full ${
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
}`} }`}
> >
<div <div
className={`fixed z-20 flex min-w-[8rem] flex-col items-stretch gap-1 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs shadow-lg`} ref={contextMenuRef}
style={{ className={`fixed z-50 flex min-w-[8rem] flex-col items-stretch gap-1 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs shadow-lg`}
left: `${position.x}px`,
top: `${position.y}px`,
}}
> >
{title && ( {title && (
<h4 className="border-b border-custom-border-200 px-1 py-1 pb-2 text-[0.8rem] font-medium"> <h4 className="border-b border-custom-border-200 px-1 py-1 pb-2 text-[0.8rem] font-medium">

View File

@ -29,9 +29,9 @@ export const Input: React.FC<Props> = ({
type={type} type={type}
id={id} id={id}
value={value} value={value}
{...(register && register(name, validations))} {...(register && register(name ?? "", validations))}
onChange={(e) => { onChange={(e) => {
register && register(name).onChange(e); register && register(name ?? "").onChange(e);
onChange && onChange(e); onChange && onChange(e);
}} }}
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${ className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${

View File

@ -3,7 +3,7 @@ import type { UseFormRegister, RegisterOptions } from "react-hook-form";
export interface Props extends React.ComponentPropsWithoutRef<"input"> { export interface Props extends React.ComponentPropsWithoutRef<"input"> {
label?: string; label?: string;
name: string; name?: string;
value?: string | number | readonly string[]; value?: string | number | readonly string[];
mode?: "primary" | "transparent" | "trueTransparent" | "secondary" | "disabled"; mode?: "primary" | "transparent" | "trueTransparent" | "secondary" | "disabled";
register?: UseFormRegister<any>; register?: UseFormRegister<any>;

View File

@ -1,7 +1,11 @@
import React from "react"; import React from "react";
// ui
import { Tooltip } from "components/ui";
// types
import { IIssueLabels } from "types";
type IssueLabelsListProps = { type IssueLabelsListProps = {
labels?: (string | undefined)[]; labels?: (IIssueLabels | undefined)[];
length?: number; length?: number;
showLength?: boolean; showLength?: boolean;
}; };
@ -14,18 +18,16 @@ export const IssueLabelsList: React.FC<IssueLabelsListProps> = ({
<> <>
{labels && ( {labels && (
<> <>
{labels.slice(0, length).map((color, index) => ( <Tooltip
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}> position="top"
<span tooltipHeading="Labels"
className={`h-4 w-4 flex-shrink-0 rounded-full border border-custom-border-200 tooltipContent={labels.map((l) => l?.name).join(", ")}
`} >
style={{ <div className="flex items-center gap-1.5 px-2 py-1 text-custom-text-200 rounded shadow-sm border border-custom-border-300">
backgroundColor: color && color !== "" ? color : "#000000", <span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
}} {`${labels.length} Labels`}
/>
</div> </div>
))} </Tooltip>
{labels.length > length ? <span>+{labels.length - length}</span> : null}
</> </>
)} )}
</> </>

View File

@ -11,8 +11,8 @@ type Props = {
export const ProfileEmptyState: React.FC<Props> = ({ title, description, image }) => ( export const ProfileEmptyState: React.FC<Props> = ({ title, description, image }) => (
<div className={`h-full w-full mx-auto grid place-items-center p-8 `}> <div className={`h-full w-full mx-auto grid place-items-center p-8 `}>
<div className="text-center flex flex-col items-center w-full"> <div className="text-center flex flex-col items-center w-full">
<div className="flex items-center justify-center h-14 w-14 rounded-full bg-custom-primary-10"> <div className="flex items-center justify-center h-14 w-14 rounded-full bg-custom-background-90">
<Image src={image} className="w-8" alt={title} /> <Image src={image} width={32} alt={title} />
</div> </div>
<h6 className="text-base font-semibold mt-3.5 mb-3">{title}</h6> <h6 className="text-base font-semibold mt-3.5 mb-3">{title}</h6>
{description && <p className="text-sm text-custom-text-300">{description}</p>} {description && <p className="text-sm text-custom-text-300">{description}</p>}

View File

@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
@ -26,57 +28,63 @@ type Props = {
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
}; };
const defaultValues = {
workspaceName: "",
confirmDelete: "",
};
export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => { export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [confirmWorkspaceName, setConfirmWorkspaceName] = useState("");
const [confirmDeleteMyWorkspace, setConfirmDeleteMyWorkspace] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState<IWorkspace | null>(null);
const router = useRouter(); const router = useRouter();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
useEffect(() => { const {
if (data) setSelectedWorkspace(data); control,
else { formState: { isSubmitting },
const timer = setTimeout(() => { handleSubmit,
setSelectedWorkspace(null); reset,
clearTimeout(timer); watch,
}, 350); } = useForm({ defaultValues });
}
}, [data]);
const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace; const canDelete =
watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
const handleClose = () => { const handleClose = () => {
setIsDeleteLoading(false); const timer = setTimeout(() => {
setConfirmWorkspaceName(""); reset(defaultValues);
setConfirmDeleteMyWorkspace(false); clearTimeout(timer);
}, 350);
onClose(); onClose();
}; };
const handleDeletion = async () => { const onSubmit = async () => {
setIsDeleteLoading(true);
if (!data || !canDelete) return; if (!data || !canDelete) return;
await workspaceService await workspaceService
.deleteWorkspace(data.slug, user) .deleteWorkspace(data.slug, user)
.then(() => { .then(() => {
handleClose(); handleClose();
router.push("/"); router.push("/");
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) => mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
prevData?.filter((workspace) => workspace.id !== data.id) prevData?.filter((workspace) => workspace.id !== data.id)
); );
setToastAlert({ setToastAlert({
type: "success", type: "success",
message: "Workspace deleted successfully", title: "Success!",
title: "Success", message: "Workspace deleted successfully.",
}); });
}) })
.catch((error) => { .catch(() =>
console.log(error); setToastAlert({
setIsDeleteLoading(false); type: "error",
}); title: "Error!",
message: "Something went wrong. Please try again later.",
})
);
}; };
return ( return (
@ -106,7 +114,7 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl"> <Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6"> <div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4"> <span className="place-items-center rounded-full bg-red-500/20 p-4">
<ExclamationTriangleIcon <ExclamationTriangleIcon
@ -131,20 +139,21 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
<div className="text-custom-text-200"> <div className="text-custom-text-200">
<p className="break-words text-sm "> <p className="break-words text-sm ">
Enter the workspace name{" "} Enter the workspace name{" "}
<span className="font-medium text-custom-text-100"> <span className="font-medium text-custom-text-100">{data?.name}</span> to
{selectedWorkspace?.name} continue:
</span>{" "}
to continue:
</p> </p>
<Input <Controller
type="text" control={control}
placeholder="Workspace name"
className="mt-2"
value={confirmWorkspaceName}
onChange={(e) => {
setConfirmWorkspaceName(e.target.value);
}}
name="workspaceName" name="workspaceName"
render={({ field: { onChange, value } }) => (
<Input
type="text"
placeholder="Workspace name"
className="mt-2"
onChange={onChange}
value={value}
/>
)}
/> />
</div> </div>
@ -154,28 +163,28 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
<span className="font-medium text-custom-text-100">delete my workspace</span>{" "} <span className="font-medium text-custom-text-100">delete my workspace</span>{" "}
below: below:
</p> </p>
<Input <Controller
type="text" control={control}
placeholder="Enter 'delete my workspace'" name="confirmDelete"
className="mt-2" render={({ field: { onChange, value } }) => (
onChange={(e) => { <Input
if (e.target.value === "delete my workspace") { type="text"
setConfirmDeleteMyWorkspace(true); placeholder="Enter 'delete my workspace'"
} else { className="mt-2"
setConfirmDeleteMyWorkspace(false); onChange={onChange}
} value={value}
}} />
name="typeDelete" )}
/> />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading || !canDelete}> <DangerButton type="submit" disabled={!canDelete} loading={isSubmitting}>
{isDeleteLoading ? "Deleting..." : "Delete Workspace"} {isSubmitting ? "Deleting..." : "Delete Workspace"}
</DangerButton> </DangerButton>
</div> </div>
</div> </form>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -49,8 +49,6 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
const helpOptionsRef = useRef<HTMLDivElement | null>(null); const helpOptionsRef = useRef<HTMLDivElement | null>(null);
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false)); useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
return ( return (

View File

@ -45,12 +45,14 @@ export const IssuesList: React.FC<Props> = ({ issues, type }) => {
> >
<h4 className="capitalize">{type}</h4> <h4 className="capitalize">{type}</h4>
<h4 className="col-span-2">Issue</h4> <h4 className="col-span-2">Issue</h4>
<h4>Due Date</h4> <h4>{type === "overdue" ? "Due" : "Start"} Date</h4>
</div> </div>
<div className="max-h-72 overflow-y-scroll"> <div className="max-h-72 overflow-y-scroll">
{issues.length > 0 ? ( {issues.length > 0 ? (
issues.map((issue) => { issues.map((issue) => {
const dateDifference = getDateDifference(new Date(issue.target_date as string)); const date = type === "overdue" ? issue.target_date : issue.start_date;
const dateDifference = getDateDifference(new Date(date as string));
return ( return (
<Link <Link
@ -75,7 +77,7 @@ export const IssuesList: React.FC<Props> = ({ issues, type }) => {
</h5> </h5>
<h5 className="col-span-2">{truncateText(issue.name, 30)}</h5> <h5 className="col-span-2">{truncateText(issue.name, 30)}</h5>
<h5 className="cursor-default"> <h5 className="cursor-default">
{renderShortDateWithYearFormat(new Date(issue.target_date as string))} {renderShortDateWithYearFormat(new Date(date?.toString() ?? ""))}
</h5> </h5>
</div> </div>
</a> </a>

View File

@ -15,7 +15,7 @@ export const WorkspaceSidebarQuickAction = () => {
}`} }`}
> >
<button <button
className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-2 ${ className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5 ${
store?.theme?.sidebarCollapsed store?.theme?.sidebarCollapsed
? "px-2 hover:bg-custom-sidebar-background-80" ? "px-2 hover:bg-custom-sidebar-background-80"
: "px-3 shadow border-[0.5px] border-custom-border-300" : "px-3 shadow border-[0.5px] border-custom-border-300"
@ -25,7 +25,7 @@ export const WorkspaceSidebarQuickAction = () => {
document.dispatchEvent(e); document.dispatchEvent(e);
}} }}
> >
<Icon iconName="edit_square" className="!text-xl !leading-5 text-custom-sidebar-text-300" /> <Icon iconName="edit_square" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
{!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>} {!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>}
</button> </button>
@ -40,7 +40,7 @@ export const WorkspaceSidebarQuickAction = () => {
document.dispatchEvent(e); document.dispatchEvent(e);
}} }}
> >
<Icon iconName="search" className="!text-xl !leading-5 text-custom-sidebar-text-300" /> <Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
</button> </button>
</div> </div>
); );

View File

@ -42,10 +42,10 @@ export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
value: "target_date", value: "target_date",
label: "Due date", label: "Due date",
}, },
// { {
// value: "start_date", value: "start_date",
// label: "Start date", label: "Start date",
// }, },
{ {
value: "created_at", value: "created_at",
label: "Created date", label: "Created date",

View File

@ -122,7 +122,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
}, },
}; };
mutateInboxDetails((prevData) => { mutateInboxDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
@ -156,7 +156,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
filters: { ...initialState.filters }, filters: { ...initialState.filters },
}; };
mutateInboxDetails((prevData) => { mutateInboxDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {

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