mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into feat/csv_exporter
This commit is contained in:
commit
f60db24503
@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# public boards deploy url
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -70,4 +70,6 @@ package-lock.json
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
|
10
README.md
10
README.md
@ -61,6 +61,16 @@ chmod +x setup.sh
|
||||
|
||||
> 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
|
||||
|
||||
```bash
|
||||
|
@ -88,6 +88,7 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@ -103,6 +104,7 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
is_deployed = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
|
@ -48,6 +48,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
||||
project_ids=project_ids,
|
||||
token_id=exporter.token,
|
||||
multiple=multiple,
|
||||
slug=slug,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
|
@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
serializer_class = SlackProjectSyncSerializer
|
||||
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):
|
||||
try:
|
||||
serializer = SlackProjectSyncSerializer(data=request.data)
|
||||
@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
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:
|
||||
return Response(
|
||||
{"error": "Workspace Integration does not exist"},
|
||||
|
@ -370,7 +370,7 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
|
@ -122,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.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()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -145,6 +147,14 @@ class ProjectViewSet(BaseViewSet):
|
||||
member_id=self.request.user.id,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
is_deployed=Exists(
|
||||
ProjectDeployBoard.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@ -216,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
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(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
@ -383,7 +395,9 @@ class InviteProjectEndpoint(BaseAPIView):
|
||||
validate_email(email)
|
||||
# Check if user is already a member of workspace
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id, member__email=email
|
||||
project_id=project_id,
|
||||
member__email=email,
|
||||
member__is_bot=False,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "User is already member of workspace"},
|
||||
@ -1087,7 +1101,9 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
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")
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
@ -47,7 +47,7 @@ from plane.api.serializers import (
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer
|
||||
WorkspaceMemberAdminSerializer,
|
||||
)
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
@ -107,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -192,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -625,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, role=20
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
member__is_bot=False,
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
@ -988,11 +994,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__gte=timezone.now(),
|
||||
start_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
).values("id", "name", "workspace__slug", "project_id", "start_date")
|
||||
|
||||
return Response(
|
||||
{
|
||||
@ -1077,6 +1083,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
.annotate(priority_count=Count("priority"))
|
||||
.filter(priority_count__gte=1)
|
||||
.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
@ -1455,7 +1462,8 @@ class WorkspaceMembersEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
).select_related("workspace", "member")
|
||||
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
|
||||
return Response(serialzier.data, status=status.HTTP_200_OK)
|
||||
|
@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
|
||||
segmented = segment
|
||||
|
||||
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 = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("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:
|
||||
@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
|
||||
else:
|
||||
generated_row.append("0")
|
||||
# x-axis replacement for names
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
|
||||
if len(assignee):
|
||||
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
# 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:]):
|
||||
# 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):
|
||||
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
|
||||
csv_buffer = io.StringIO()
|
||||
@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
|
||||
else distribution.get(item)[0].get("estimate "),
|
||||
]
|
||||
# x-axis replacement to names
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
|
@ -4,7 +4,6 @@ import io
|
||||
import json
|
||||
import boto3
|
||||
import zipfile
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
@ -15,19 +14,18 @@ from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from botocore.client import Config
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import NamedStyle
|
||||
from openpyxl.utils.datetime import to_excel
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, ExporterHistory, Project
|
||||
from plane.db.models import Issue, ExporterHistory
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
def dateTimeConverter(time):
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
|
||||
def dateConverter(time):
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y")
|
||||
|
||||
def create_csv_file(data):
|
||||
csv_buffer = io.StringIO()
|
||||
@ -41,25 +39,16 @@ def create_csv_file(data):
|
||||
|
||||
|
||||
def create_json_file(data):
|
||||
return json.dumps(data, cls=DateTimeEncoder)
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def create_xlsx_file(data):
|
||||
workbook = Workbook()
|
||||
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:
|
||||
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()
|
||||
workbook.save(xlsx_buffer)
|
||||
xlsx_buffer.seek(0)
|
||||
@ -76,7 +65,7 @@ def create_zip_file(files):
|
||||
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",
|
||||
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,
|
||||
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(
|
||||
zip_file,
|
||||
@ -128,15 +117,15 @@ def generate_table_row(issue):
|
||||
else "",
|
||||
issue["labels__name"],
|
||||
issue["issue_cycle__cycle__name"],
|
||||
issue["issue_cycle__cycle__start_date"],
|
||||
issue["issue_cycle__cycle__end_date"],
|
||||
dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
issue["issue_module__module__name"],
|
||||
issue["issue_module__module__start_date"],
|
||||
issue["issue_module__module__target_date"],
|
||||
issue["created_at"],
|
||||
issue["updated_at"],
|
||||
issue["completed_at"],
|
||||
issue["archived_at"],
|
||||
dateConverter(issue["issue_module__module__start_date"]),
|
||||
dateConverter(issue["issue_module__module__target_date"]),
|
||||
dateTimeConverter(issue["created_at"]),
|
||||
dateTimeConverter(issue["updated_at"]),
|
||||
dateTimeConverter(issue["completed_at"]),
|
||||
dateTimeConverter(issue["archived_at"]),
|
||||
]
|
||||
|
||||
|
||||
@ -156,15 +145,15 @@ def generate_json_row(issue):
|
||||
else "",
|
||||
"Labels": issue["labels__name"],
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": issue["issue_cycle__cycle__start_date"],
|
||||
"Cycle End Date": issue["issue_cycle__cycle__end_date"],
|
||||
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
"Module Name": issue["issue_module__module__name"],
|
||||
"Module Start Date": issue["issue_module__module__start_date"],
|
||||
"Module Target Date": issue["issue_module__module__target_date"],
|
||||
"Created At": issue["created_at"],
|
||||
"Updated At": issue["updated_at"],
|
||||
"Completed At": issue["completed_at"],
|
||||
"Archived At": issue["archived_at"],
|
||||
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
"Archived At": dateTimeConverter(issue["archived_at"]),
|
||||
}
|
||||
|
||||
|
||||
@ -244,7 +233,7 @@ def generate_xlsx(header, project_id, issues, files):
|
||||
|
||||
|
||||
@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:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
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)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
|
||||
|
||||
except Exception as e:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
@ -184,19 +184,24 @@ def track_description(
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
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')}",
|
||||
)
|
||||
)
|
||||
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
|
||||
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
|
||||
last_activity.created_at = timezone.now()
|
||||
last_activity.save(update_fields=["created_at"])
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
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
|
||||
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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),
|
||||
]
|
@ -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),
|
||||
]
|
@ -98,7 +98,7 @@ class ModuleIssue(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()
|
||||
module = models.ForeignKey(
|
||||
Module, on_delete=models.CASCADE, related_name="link_module"
|
||||
|
@ -1,3 +1,8 @@
|
||||
// ui
|
||||
import { ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyUsers from "public/empty-state/empty_users.svg";
|
||||
|
||||
type Props = {
|
||||
users: {
|
||||
avatar: string | null;
|
||||
@ -8,10 +13,16 @@ type Props = {
|
||||
id: string;
|
||||
}[];
|
||||
title: string;
|
||||
emptyStateMessage: 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]">
|
||||
<h6 className="text-base font-medium">{title}</h6>
|
||||
{users.length > 0 ? (
|
||||
@ -47,7 +58,9 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title, workspaceS
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
@ -63,6 +63,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
id: user?.created_by__id,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
emptyStateMessage="Co-workers and the number issues created by them appears here."
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<AnalyticsLeaderboard
|
||||
@ -75,6 +76,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
id: user?.assignees__id,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
emptyStateMessage="Co-workers and the number issues closed by them appears here."
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||
|
@ -1,5 +1,7 @@
|
||||
// ui
|
||||
import { BarGraph } from "components/ui";
|
||||
import { BarGraph, ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
|
||||
// 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">
|
||||
No matching data found.
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState
|
||||
title="No Data yet"
|
||||
description="Analysis of pending issues by co-workers appears here."
|
||||
image={emptyBarGraph}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,5 +1,7 @@
|
||||
// ui
|
||||
import { LineGraph } from "components/ui";
|
||||
import { LineGraph, ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyGraph from "public/empty-state/empty_graph.svg";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
@ -48,7 +50,13 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
|
||||
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>
|
||||
);
|
||||
|
@ -9,12 +9,18 @@ import userService from "services/user.service";
|
||||
import useUser from "hooks/use-user";
|
||||
// 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 = {
|
||||
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 { setTheme } = useTheme();
|
||||
@ -23,29 +29,11 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
|
||||
const updateUserTheme = (newTheme: string) => {
|
||||
if (!user) return;
|
||||
|
||||
unsetCustomCssVariables();
|
||||
|
||||
setTheme(newTheme);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
theme: {
|
||||
...prevData.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
};
|
||||
}, false);
|
||||
|
||||
userService.updateUser({
|
||||
theme: {
|
||||
...user.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
});
|
||||
return store.user
|
||||
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
|
||||
.then((response: any) => response)
|
||||
.catch((error: any) => error);
|
||||
};
|
||||
|
||||
// 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 }) => {
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,11 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
@ -26,8 +23,10 @@ import inboxService from "services/inbox.service";
|
||||
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// mobx store
|
||||
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 [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@ -47,13 +46,12 @@ export const CommandPalette: React.FC = () => {
|
||||
const { user } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
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
|
||||
);
|
||||
|
||||
@ -78,55 +76,52 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(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 (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element).classList?.contains("remirror-editor")
|
||||
(e.target as Element).classList?.contains("ProseMirror")
|
||||
)
|
||||
return;
|
||||
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
} 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(() => {
|
||||
@ -201,4 +196,4 @@ export const CommandPalette: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
})
|
@ -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 dynamic from "next/dynamic";
|
||||
|
||||
// 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 { IIssue, IPageBlock } from "types";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
@ -32,17 +32,11 @@ type FormData = {
|
||||
task: string;
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
|
||||
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
|
||||
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
|
||||
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
|
||||
);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const GptAssistantModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
@ -146,15 +140,15 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<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 ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||
<div className="remirror-section text-sm">
|
||||
<div className="text-sm">
|
||||
Content:
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
value={htmlContent ?? `<p>${content}</p>`}
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
@ -166,7 +160,8 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{response !== "" && (
|
||||
<div className="page-block-section text-sm">
|
||||
Response:
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
value={`<p>${response}</p>`}
|
||||
customClassName="-mx-3 -my-3"
|
||||
noBorder
|
||||
@ -185,11 +180,10 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
type="text"
|
||||
name="task"
|
||||
register={register}
|
||||
placeholder={`${
|
||||
content && content !== ""
|
||||
placeholder={`${content && content !== ""
|
||||
? "Tell AI what action to perform on this content..."
|
||||
: "Ask AI anything..."
|
||||
}`}
|
||||
}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
||||
@ -225,8 +219,8 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{isSubmitting
|
||||
? "Generating response..."
|
||||
: response === ""
|
||||
? "Generate response"
|
||||
: "Generate again"}
|
||||
? "Generate response"
|
||||
: "Generate again"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -7,12 +7,15 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { IIssueLink, ModuleLink } from "types";
|
||||
import type { IIssueLink, linkDetails, ModuleLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
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 = {
|
||||
@ -20,7 +23,14 @@ const defaultValues: IIssueLink | ModuleLink = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
|
||||
export const LinkModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
createIssueLink,
|
||||
updateIssueLink,
|
||||
status,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -30,11 +40,6 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
await onFormSubmit({ title: formData.title, url: formData.url });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
@ -43,6 +48,27 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
}, 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 (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<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"
|
||||
>
|
||||
<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 className="space-y-5">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Add Link
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
@ -113,7 +139,13 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding Link..." : "Add Link"}
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,25 +1,24 @@
|
||||
// icons
|
||||
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "components/ui";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IUserLite, UserAuth } from "types";
|
||||
import { linkDetails, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
links: {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
links: linkDetails[];
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: linkDetails) => void;
|
||||
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;
|
||||
|
||||
return (
|
||||
@ -28,6 +27,13 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
||||
<div key={link.id} className="relative">
|
||||
{!isNotAllowed && (
|
||||
<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
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
|
@ -86,8 +86,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
}) => {
|
||||
// context menu
|
||||
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 [isDropdownActive, setIsDropdownActive] = useState(false);
|
||||
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@ -125,7 +127,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
@ -200,7 +202,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
position={contextMenuPosition}
|
||||
clickEvent={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
@ -242,10 +244,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
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 && (
|
||||
<div
|
||||
ref={actionSectionRef}
|
||||
@ -295,16 +297,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a>
|
||||
<a className="flex flex-col gap-1.5">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
|
||||
</a>
|
||||
</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 && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
@ -327,6 +333,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleOnOpen={() => setIsDropdownActive(true)}
|
||||
handleOnClose={() => setIsDropdownActive(false)}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
@ -335,6 +343,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleOnOpen={() => setIsDropdownActive(true)}
|
||||
handleOnClose={() => setIsDropdownActive(false)}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
|
@ -33,7 +33,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
|
||||
@ -71,7 +71,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
}) => {
|
||||
// context menu
|
||||
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 { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
@ -167,7 +167,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
position={contextMenuPosition}
|
||||
clickEvent={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
@ -199,7 +199,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
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">
|
||||
|
@ -38,10 +38,10 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
mutateCycles((prevData) => {
|
||||
mutateCycles((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === cycle.id
|
||||
? {
|
||||
|
@ -45,7 +45,6 @@ type TSingleStatProps = {
|
||||
handleDeleteCycle: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
const stateGroups = [
|
||||
@ -82,7 +81,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
handleDeleteCycle,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -90,6 +88,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
|
||||
|
@ -34,7 +34,6 @@ type TSingleStatProps = {
|
||||
handleDeleteCycle: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
const stateGroups = [
|
||||
@ -113,7 +112,6 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
handleDeleteCycle,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -121,6 +119,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
|
||||
|
@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
estimate: estimate.id,
|
||||
};
|
||||
|
||||
mutateProjectDetails((prevData) => {
|
||||
mutateProjectDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return { ...prevData, estimate: estimate.id };
|
||||
|
@ -15,10 +15,10 @@ export const updateGanttIssue = (
|
||||
) => {
|
||||
if (!issue || !workspaceSlug || !user) return;
|
||||
|
||||
mutate((prevData: IIssue[]) => {
|
||||
mutate((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === issue.id ? payload : {}),
|
||||
}));
|
||||
|
@ -72,8 +72,8 @@ export const InboxActionHeader = () => {
|
||||
false
|
||||
);
|
||||
mutateInboxIssues(
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) =>
|
||||
(prevData: any) =>
|
||||
(prevData ?? []).map((i: any) =>
|
||||
i.bridge_id === inboxIssueId
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
|
||||
: i
|
||||
|
@ -293,6 +293,7 @@ export const InboxMainContent: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<IssueDescriptionForm
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
issue={{
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
|
@ -54,7 +54,10 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
const handleCommentDelete = async (commentId: string) => {
|
||||
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
|
||||
.deleteIssueComment(
|
||||
@ -131,7 +134,9 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
<div
|
||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
|
||||
>
|
||||
{activityItem.actor_detail.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>
|
||||
@ -150,7 +155,9 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}>
|
||||
<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>
|
||||
</Link>
|
||||
)}{" "}
|
||||
@ -168,6 +175,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-4">
|
||||
<CommentCard
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
comment={activityItem as IIssueComment}
|
||||
onSubmit={handleCommentUpdate}
|
||||
handleCommentDeletion={handleCommentDelete}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
@ -12,28 +11,18 @@ import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Loader, SecondaryButton } from "components/ui";
|
||||
import { SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssueComment } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mb-5">
|
||||
<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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_json: "",
|
||||
@ -51,6 +40,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<IIssueComment>({ defaultValues });
|
||||
@ -99,15 +89,25 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="issue-comments-section">
|
||||
<Controller
|
||||
name="comment_json"
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={value}
|
||||
onJSONChange={(jsonValue) => setValue("comment_json", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)}
|
||||
placeholder="Enter your comment..."
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -1,7 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// icons
|
||||
@ -15,25 +13,22 @@ import { CommentReaction } from "components/issues";
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// 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";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
comment: IIssueComment;
|
||||
onSubmit: (comment: IIssueComment) => 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 editorRef = React.useRef<any>(null);
|
||||
@ -45,6 +40,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
@ -56,8 +52,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
|
||||
onSubmit(formData);
|
||||
|
||||
editorRef.current?.setEditorValue(formData.comment_json);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_json);
|
||||
editorRef.current?.setEditorValue(formData.comment_html);
|
||||
showEditorRef.current?.setEditorValue(formData.comment_html);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -70,7 +66,11 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
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}
|
||||
width={30}
|
||||
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
|
||||
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>
|
||||
)}
|
||||
|
||||
@ -106,15 +108,19 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||
onSubmit={handleSubmit(onEnter)}
|
||||
>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={comment.comment_html}
|
||||
onBlur={(jsonValue, htmlValue) => {
|
||||
setValue("comment_json", jsonValue);
|
||||
setValue("comment_html", htmlValue);
|
||||
}}
|
||||
placeholder="Enter Your comment..."
|
||||
ref={editorRef}
|
||||
/>
|
||||
<div>
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
value={watch("comment_html")}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
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">
|
||||
<button
|
||||
type="submit"
|
||||
@ -133,14 +139,13 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={showEditorRef}
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
noBorder
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
ref={showEditorRef}
|
||||
/>
|
||||
|
||||
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,23 +1,16 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// components
|
||||
import { Loader, TextArea } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader>
|
||||
<Loader.Item height="12rem" width="100%" />
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
import { TextArea } from "components/ui";
|
||||
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import Tiptap from "components/tiptap";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export interface IssueDescriptionFormValues {
|
||||
name: string;
|
||||
@ -31,6 +24,7 @@ export interface IssueDetailsProps {
|
||||
description: string;
|
||||
description_html: string;
|
||||
};
|
||||
workspaceSlug: string;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
isAllowed: boolean;
|
||||
}
|
||||
@ -38,9 +32,10 @@ export interface IssueDetailsProps {
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
issue,
|
||||
handleFormSubmit,
|
||||
workspaceSlug,
|
||||
isAllowed,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
@ -63,7 +58,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
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({
|
||||
name: formData.name ?? "",
|
||||
@ -74,6 +69,18 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
[handleFormSubmit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert]);
|
||||
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
@ -83,6 +90,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
});
|
||||
}, [issue, reset]);
|
||||
|
||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||
setTimeout(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
@ -92,11 +105,10 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
placeholder="Enter issue name"
|
||||
register={register}
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onBlur={() => {
|
||||
onChange={(e) => {
|
||||
setCharacterLimit(false);
|
||||
|
||||
setIsSubmitting(true);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
|
||||
setIsSubmitting("submitting");
|
||||
debouncedTitleSave();
|
||||
}}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||
@ -106,9 +118,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
{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">
|
||||
<span
|
||||
className={`${
|
||||
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||
}`}
|
||||
className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
{watch("name").length}
|
||||
</span>
|
||||
@ -119,45 +130,45 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
<span>{errors.name ? errors.name.message : null}</span>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value && !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: 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);
|
||||
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 className="absolute bottom-1 right-1 text-xs text-custom-text-200 bg-custom-background-100 p-3 z-10">
|
||||
Saving...
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
@ -36,24 +35,14 @@ import {
|
||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
// 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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
@ -374,21 +363,31 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value && !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
value={
|
||||
!value ||
|
||||
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
|
||||
isOpen={gptAssistantModal}
|
||||
|
@ -50,11 +50,11 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
|
||||
workspaceSlug && projectId && issueDetails?.parent
|
||||
? () =>
|
||||
issuesService.subIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueDetails.parent ?? ""
|
||||
)
|
||||
issuesService.subIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueDetails.parent ?? ""
|
||||
)
|
||||
: null
|
||||
);
|
||||
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
|
||||
@ -97,9 +97,8 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
<CustomMenu.MenuItem
|
||||
key={issue.id}
|
||||
renderAs="a"
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
|
||||
issue.id
|
||||
}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id
|
||||
}`}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<LayerDiagonalIcon className="h-4 w-4" />
|
||||
@ -125,6 +124,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
</div>
|
||||
) : null}
|
||||
<IssueDescriptionForm
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
issue={issueDetails}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable}
|
||||
|
@ -5,9 +5,7 @@ import useSWR from "swr";
|
||||
// services
|
||||
import projectServices from "services/project.service";
|
||||
// ui
|
||||
import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
import { AssigneesList, Avatar, CustomSearchSelect, Icon } from "components/ui";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -44,15 +42,15 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
customButton={
|
||||
<div className="flex items-center gap-2 cursor-pointer text-xs text-custom-text-200">
|
||||
{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} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<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">
|
||||
<Icon iconName="person" className="!text-base !leading-4" />
|
||||
<span className="text-custom-text-200">Assignee</span>
|
||||
</div>
|
||||
)}
|
||||
|
@ -20,7 +20,7 @@ export const IssueDateSelect: React.FC<Props> = ({ label, maxDate, minDate, onCh
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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 ? (
|
||||
<>
|
||||
<span className="text-custom-text-100">{renderShortDateWithYearFormat(value)}</span>
|
||||
|
@ -59,17 +59,17 @@ export const IssueLabelSelect: React.FC<Props> = ({ setIsOpen, value, onChange,
|
||||
>
|
||||
{({ 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 ? (
|
||||
<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
|
||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)?.color) ?? []}
|
||||
labels={value.map((v) => issueLabels?.find((l) => l.id === v)) ?? []}
|
||||
length={3}
|
||||
showLength={true}
|
||||
/>
|
||||
</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" />
|
||||
<span className=" text-custom-text-200">Label</span>
|
||||
</span>
|
||||
|
@ -85,7 +85,7 @@ export const SidebarLabelSelect: React.FC<Props> = ({
|
||||
.then((res) => {
|
||||
reset(defaultValues);
|
||||
|
||||
issueLabelMutate((prevData) => [...(prevData ?? []), res], false);
|
||||
issueLabelMutate((prevData: any) => [...(prevData ?? []), res], false);
|
||||
|
||||
submitChanges({ labels_list: [...(issueDetails?.labels ?? []), res.id] });
|
||||
|
||||
|
@ -37,7 +37,7 @@ import { LinkIcon, CalendarDaysIcon, TrashIcon, PlusIcon } from "@heroicons/reac
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import type { ICycle, IIssue, IIssueLink, IModule } from "types";
|
||||
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
|
||||
// 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 [linkModal, setLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
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) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
@ -220,14 +258,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const handleEditLink = (link: linkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setLinkModal(true);
|
||||
};
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={linkModal}
|
||||
handleClose={() => setLinkModal(false)}
|
||||
onFormSubmit={handleCreateLink}
|
||||
handleClose={() => {
|
||||
setLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={handleCreateLink}
|
||||
updateIssueLink={handleUpdateLink}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
@ -396,7 +445,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
start_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-custom-background-90"
|
||||
className="bg-custom-background-100"
|
||||
wrapperClassName="w-full"
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
/>
|
||||
@ -424,7 +474,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-custom-background-90"
|
||||
className="bg-custom-background-100"
|
||||
wrapperClassName="w-full"
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
/>
|
||||
@ -488,6 +539,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
handleEditLink={handleEditLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
|
@ -13,6 +13,8 @@ import useIssuesView from "hooks/use-issues-view";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
handleOnOpen?: () => void;
|
||||
handleOnClose?: () => void;
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
@ -22,6 +24,8 @@ type Props = {
|
||||
export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
handleOnOpen,
|
||||
handleOnClose,
|
||||
tooltipPosition = "top",
|
||||
noBorder = false,
|
||||
user,
|
||||
@ -80,6 +84,8 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
}`}
|
||||
minDate={minDate ?? undefined}
|
||||
noBorder={noBorder}
|
||||
handleOnOpen={handleOnOpen}
|
||||
handleOnClose={handleOnClose}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -13,6 +13,8 @@ import useIssuesView from "hooks/use-issues-view";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
handleOnOpen?: () => void;
|
||||
handleOnClose?: () => void;
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
@ -22,6 +24,8 @@ type Props = {
|
||||
export const ViewStartDateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
handleOnOpen,
|
||||
handleOnClose,
|
||||
tooltipPosition = "top",
|
||||
noBorder = false,
|
||||
user,
|
||||
@ -72,6 +76,8 @@ export const ViewStartDateSelect: React.FC<Props> = ({
|
||||
}`}
|
||||
maxDate={maxDate ?? undefined}
|
||||
noBorder={noBorder}
|
||||
handleOnOpen={handleOnOpen}
|
||||
handleOnClose={handleOnClose}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -49,8 +49,8 @@ export const LabelsListModal: React.FC<Props> = ({ isOpen, handleClose, parent,
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate(
|
||||
(prevData) =>
|
||||
prevData?.map((l) => {
|
||||
(prevData: any) =>
|
||||
prevData?.map((l: any) => {
|
||||
if (l.id === label.id) return { ...l, parent: parent?.id ?? "" };
|
||||
|
||||
return l;
|
||||
|
@ -42,10 +42,10 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules })
|
||||
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
mutateModules((prevData) => {
|
||||
mutateModules((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === module.id
|
||||
? {
|
||||
|
@ -37,7 +37,7 @@ import { LinkIcon } from "@heroicons/react/20/solid";
|
||||
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IModule, ModuleLink } from "types";
|
||||
import { ICurrentUserResponse, IIssue, linkDetails, IModule, ModuleLink } from "types";
|
||||
// fetch-keys
|
||||
import { MODULE_DETAILS } from "constants/fetch-keys";
|
||||
// constant
|
||||
@ -61,6 +61,7 @@ type Props = {
|
||||
export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIssues, user }) => {
|
||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||
const [moduleLinkModal, setModuleLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
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) => {
|
||||
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)
|
||||
: null;
|
||||
|
||||
const handleEditLink = (link: linkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setModuleLinkModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={moduleLinkModal}
|
||||
handleClose={() => setModuleLinkModal(false)}
|
||||
onFormSubmit={handleCreateLink}
|
||||
handleClose={() => {
|
||||
setModuleLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={handleCreateLink}
|
||||
updateIssueLink={handleUpdateLink}
|
||||
/>
|
||||
<DeleteModuleModal
|
||||
isOpen={moduleDeleteModal}
|
||||
@ -544,7 +587,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
</Disclosure>
|
||||
</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">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">Links</h4>
|
||||
<button
|
||||
@ -558,6 +601,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
{memberRole && module.link_module && module.link_module.length > 0 ? (
|
||||
<LinksList
|
||||
links={module.link_module}
|
||||
handleEditLink={handleEditLink}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
|
@ -53,7 +53,9 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
onClick={() => {
|
||||
markNotificationReadStatus(notification.id);
|
||||
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 ${
|
||||
@ -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">
|
||||
<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()
|
||||
) : (
|
||||
<Icon iconName="person" className="h-6 w-6" />
|
||||
@ -89,7 +93,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
</div>
|
||||
<div className="space-y-2.5 w-full overflow-hidden">
|
||||
<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.verb}{" "}
|
||||
{notification.data.issue_activity.field === "comment"
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
@ -18,11 +17,12 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
// ui
|
||||
import { Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IPageBlock } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
@ -39,22 +39,11 @@ const defaultValues = {
|
||||
description_html: null,
|
||||
};
|
||||
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Loader className="mx-4 mt-6">
|
||||
<Loader.Item height="100px" 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<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
handleClose,
|
||||
@ -242,9 +231,9 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
description:
|
||||
!data.description || data.description === ""
|
||||
? {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
}
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
}
|
||||
: data.description,
|
||||
description_html: data.description_html ?? "<p></p>",
|
||||
});
|
||||
@ -297,23 +286,23 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="page-block-section relative -mt-2 text-custom-text-200">
|
||||
<Controller
|
||||
name="description"
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value } }) => {
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!data)
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={{
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
}}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
value={"<p></p>"}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
ref={editorRef}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
else if (!value || !watch("description_html"))
|
||||
@ -322,21 +311,22 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
value={
|
||||
value && value !== "" && Object.keys(value).length > 0
|
||||
? value
|
||||
: watch("description_html") && watch("description_html") !== ""
|
||||
? watch("description_html")
|
||||
: { type: "doc", content: [{ type: "paragraph" }] }
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Write something..."
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
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" : ""
|
||||
}`}
|
||||
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" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
@ -378,8 +367,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
? "Updating..."
|
||||
: "Update block"
|
||||
: isSubmitting
|
||||
? "Adding..."
|
||||
: "Add block"}
|
||||
? "Adding..."
|
||||
: "Add block"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
@ -16,16 +14,6 @@ type Props = {
|
||||
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 = {
|
||||
name: "",
|
||||
description: "",
|
||||
|
@ -19,7 +19,6 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { CreateUpdateBlockInline } from "components/pages";
|
||||
import RemirrorRichTextEditor, { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
// ui
|
||||
import { CustomMenu, TextArea } from "components/ui";
|
||||
// icons
|
||||
@ -39,6 +38,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
|
||||
type Props = {
|
||||
block: IPageBlock;
|
||||
@ -48,12 +48,12 @@ type Props = {
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const WrappedRemirrorRichTextEditor = React.forwardRef<
|
||||
IRemirrorRichTextEditor,
|
||||
IRemirrorRichTextEditor
|
||||
>((props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />);
|
||||
const TiptapEditor = React.forwardRef<
|
||||
ITiptapRichTextEditor,
|
||||
ITiptapRichTextEditor
|
||||
>((props, ref) => <Tiptap {...props} forwardedRef={ref} />);
|
||||
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
|
||||
export const SinglePageBlock: React.FC<Props> = ({
|
||||
block,
|
||||
@ -328,9 +328,8 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
|
||||
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
||||
}`}
|
||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
@ -344,9 +343,8 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
</button>
|
||||
<div
|
||||
ref={actionSectionRef}
|
||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
|
||||
isMenuActive ? "!flex" : ""
|
||||
}`}
|
||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${isMenuActive ? "!flex" : ""
|
||||
}`}
|
||||
>
|
||||
{block.issue && block.sync && (
|
||||
<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
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
@ -458,18 +455,18 @@ export const SinglePageBlock: React.FC<Props> = ({
|
||||
|
||||
{showBlockDetails
|
||||
? block.description_html.length > 7 && (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={block.description_html}
|
||||
customClassName="text-sm"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
)
|
||||
: block.description_stripped.length > 0 && (
|
||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
||||
{block.description_stripped}
|
||||
</p>
|
||||
)}
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
value={block.description_html}
|
||||
customClassName="text-sm min-h-[150px]"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
/>
|
||||
) : block.description_stripped.length > 0 && (
|
||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
||||
{block.description_stripped}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GptAssistantModal
|
||||
|
@ -1,7 +1,7 @@
|
||||
// ui
|
||||
import { BarGraph, ProfileEmptyState, Loader } from "components/ui";
|
||||
// image
|
||||
import priorityGraph from "public/empty-state/priority_graph.svg";
|
||||
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
|
||||
// helpers
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
@ -12,10 +12,10 @@ type Props = {
|
||||
};
|
||||
|
||||
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>
|
||||
{userProfile ? (
|
||||
<div className="border border-custom-border-100 rounded">
|
||||
<div className="flex-grow border border-custom-border-100 rounded">
|
||||
{userProfile.priority_distribution.length > 0 ? (
|
||||
<BarGraph
|
||||
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
|
||||
title="No Data yet"
|
||||
description="Create issues to view the them by priority in the graph for better analysis."
|
||||
image={priorityGraph}
|
||||
image={emptyBarGraph}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -16,9 +16,9 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<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 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<div>
|
||||
|
@ -110,7 +110,7 @@ export const ProfileIssuesView = () => {
|
||||
|
||||
draggedItem[groupByProperty] = destinationGroup;
|
||||
|
||||
mutateProfileIssues((prevData) => {
|
||||
mutateProfileIssues((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = [...groupedIssues[sourceGroup]];
|
||||
|
@ -105,9 +105,11 @@ export const ProfileSidebar = () => {
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<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">
|
||||
{userProjectsData.user_data.display_name}
|
||||
({userProjectsData.user_data.display_name})
|
||||
</h6>
|
||||
</div>
|
||||
<div className="mt-6 space-y-5">
|
||||
|
@ -42,6 +42,7 @@ import { NETWORK_CHOICES } from "constants/project";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setToFavorite?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
@ -74,7 +75,12 @@ const IsGuestCondition: React.FC<{
|
||||
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 { setToastAlert } = useToast();
|
||||
@ -104,6 +110,29 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
|
||||
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) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
@ -125,6 +154,9 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
|
||||
title: "Success!",
|
||||
message: "Project created successfully.",
|
||||
});
|
||||
if (setToFavorite) {
|
||||
handleAddToFavorites(res.id);
|
||||
}
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
@ -27,6 +29,11 @@ type TConfirmProjectDeletionProps = {
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
projectName: "",
|
||||
confirmDelete: "",
|
||||
};
|
||||
|
||||
export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
isOpen,
|
||||
data,
|
||||
@ -34,51 +41,41 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
onSuccess,
|
||||
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 { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const canDelete = confirmProjectName === data?.name && confirmDeleteMyProject;
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectedProject(data);
|
||||
else {
|
||||
const timer = setTimeout(() => {
|
||||
setSelectedProject(null);
|
||||
clearTimeout(timer);
|
||||
}, 300);
|
||||
}
|
||||
}, [data]);
|
||||
const canDelete =
|
||||
watch("projectName") === data?.name && watch("confirmDelete") === "delete my project";
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setConfirmProjectName("");
|
||||
setConfirmDeleteMyProject(false);
|
||||
reset(defaultValues);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
const onSubmit = async () => {
|
||||
if (!data || !workspaceSlug || !canDelete) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await projectService
|
||||
.deleteProject(workspaceSlug as string, data.id, user)
|
||||
.deleteProject(workspaceSlug.toString(), data.id, user)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
|
||||
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),
|
||||
false
|
||||
);
|
||||
@ -91,8 +88,7 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeleteLoading(false));
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -122,7 +118,7 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
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">
|
||||
<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">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
@ -137,28 +133,29 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
<span>
|
||||
<p className="text-sm leading-7 text-custom-text-200">
|
||||
Are you sure you want to delete project{" "}
|
||||
<span className="break-words font-semibold">{selectedProject?.name}</span>?
|
||||
All of the data related to the project will be permanently removed. This
|
||||
action cannot be undone
|
||||
<span className="break-words font-semibold">{data?.name}</span>? All of the
|
||||
data related to the project will be permanently removed. This action cannot be
|
||||
undone
|
||||
</p>
|
||||
</span>
|
||||
<div className="text-custom-text-200">
|
||||
<p className="break-words text-sm ">
|
||||
Enter the project name{" "}
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{selectedProject?.name}
|
||||
</span>{" "}
|
||||
to continue:
|
||||
<span className="font-medium text-custom-text-100">{data?.name}</span> to
|
||||
continue:
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Project name"
|
||||
className="mt-2"
|
||||
value={confirmProjectName}
|
||||
onChange={(e) => {
|
||||
setConfirmProjectName(e.target.value);
|
||||
}}
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Project name"
|
||||
className="mt-2"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<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>{" "}
|
||||
below:
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter 'delete my project'"
|
||||
className="mt-2"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "delete my project") {
|
||||
setConfirmDeleteMyProject(true);
|
||||
} else {
|
||||
setConfirmDeleteMyProject(false);
|
||||
}
|
||||
}}
|
||||
name="typeDelete"
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmDelete"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter 'delete my project'"
|
||||
className="mt-2"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton
|
||||
onClick={handleDeletion}
|
||||
disabled={!canDelete}
|
||||
loading={isDeleteLoading}
|
||||
>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete Project"}
|
||||
<DangerButton type="submit" disabled={!canDelete} loading={isSubmitting}>
|
||||
{isSubmitting ? "Deleting..." : "Delete Project"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
474
apps/app/components/project/publish-project/modal.tsx
Normal file
474
apps/app/components/project/publish-project/modal.tsx
Normal 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>
|
||||
);
|
||||
});
|
54
apps/app/components/project/publish-project/popover.tsx
Normal file
54
apps/app/components/project/publish-project/popover.tsx
Normal 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>
|
||||
);
|
@ -13,7 +13,7 @@ import useTheme from "hooks/use-theme";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import useProjects from "hooks/use-projects";
|
||||
// components
|
||||
import { DeleteProjectModal, SingleSidebarProject } from "components/project";
|
||||
import { CreateProjectModal, DeleteProjectModal, SingleSidebarProject } from "components/project";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// icons
|
||||
@ -32,6 +32,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
export const ProjectSidebarList: FC = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [deleteProjectModal, setDeleteProjectModal] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null);
|
||||
|
||||
@ -151,6 +152,12 @@ export const ProjectSidebarList: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
setIsOpen={setIsProjectModalOpen}
|
||||
setToFavorite
|
||||
user={user}
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
isOpen={deleteProjectModal}
|
||||
onClose={() => setDeleteProjectModal(false)}
|
||||
@ -172,17 +179,25 @@ export const ProjectSidebarList: FC = () => {
|
||||
{({ open }) => (
|
||||
<>
|
||||
{!store?.theme?.sidebarCollapsed && (
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
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
|
||||
iconName={open ? "arrow_drop_down" : "arrow_right"}
|
||||
className="group-hover:opacity-100 opacity-0 !text-lg"
|
||||
/>
|
||||
</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">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
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
|
||||
iconName={open ? "arrow_drop_down" : "arrow_right"}
|
||||
className="group-hover:opacity-100 opacity-0 !text-lg"
|
||||
/>
|
||||
</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">
|
||||
{orderedFavProjects.map((project, index) => (
|
||||
@ -241,10 +256,7 @@ export const ProjectSidebarList: FC = () => {
|
||||
</Disclosure.Button>
|
||||
<button
|
||||
className="group-hover:opacity-100 opacity-0"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "p" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
onClick={() => setIsProjectModalOpen(true)}
|
||||
>
|
||||
<Icon iconName="add" />
|
||||
</button>
|
||||
|
@ -32,6 +32,11 @@ import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { IProject } from "types";
|
||||
// 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 = {
|
||||
project: IProject;
|
||||
@ -76,252 +81,277 @@ const navigation = (workspaceSlug: string, projectId: string) => [
|
||||
},
|
||||
];
|
||||
|
||||
export const SingleSidebarProject: React.FC<Props> = ({
|
||||
project,
|
||||
sidebarCollapse,
|
||||
provided,
|
||||
snapshot,
|
||||
handleDeleteProject,
|
||||
handleCopyText,
|
||||
shortContextMenu = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
export const SingleSidebarProject: React.FC<Props> = observer(
|
||||
({
|
||||
project,
|
||||
sidebarCollapse,
|
||||
provided,
|
||||
snapshot,
|
||||
handleDeleteProject,
|
||||
handleCopyText,
|
||||
shortContextMenu = false,
|
||||
}) => {
|
||||
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 = () => {
|
||||
if (!workspaceSlug) return;
|
||||
const isAdmin = project.member_role === 20;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
|
||||
false
|
||||
);
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
projectService
|
||||
.addProjectToFavorites(workspaceSlug as string, {
|
||||
project: project.id,
|
||||
})
|
||||
.catch(() =>
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
|
||||
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({
|
||||
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({
|
||||
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}
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="div"
|
||||
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
|
||||
sidebarCollapse ? "justify-center" : `justify-between`
|
||||
}`}
|
||||
{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}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
|
||||
sidebarCollapse ? "justify-center" : ""
|
||||
<Disclosure.Button
|
||||
as="div"
|
||||
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
|
||||
sidebarCollapse ? "justify-center" : `justify-between`
|
||||
}`}
|
||||
>
|
||||
{project.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
|
||||
{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>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
|
||||
sidebarCollapse ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
{project.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
|
||||
{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 && (
|
||||
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
|
||||
{project.name}
|
||||
</p>
|
||||
<ExpandMoreOutlined
|
||||
fontSize="small"
|
||||
className={`flex-shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<ExpandMoreOutlined
|
||||
fontSize="small"
|
||||
className={`flex-shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
|
||||
/>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</Tooltip>
|
||||
</Disclosure.Button>
|
||||
</Tooltip>
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<CustomMenu
|
||||
className="hidden group-hover:block flex-shrink-0"
|
||||
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
|
||||
ellipsis
|
||||
>
|
||||
{!shortContextMenu && isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteProject}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete project</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
|
||||
{!sidebarCollapse && (
|
||||
<CustomMenu
|
||||
className="hidden group-hover:block flex-shrink-0"
|
||||
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
|
||||
ellipsis
|
||||
>
|
||||
{!shortContextMenu && isAdmin && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteProject}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete project</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!project.is_favorite && (
|
||||
<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">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
<span>Add to favorites</span>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy project link</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">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy project link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{project.archive_in > 0 && (
|
||||
|
||||
{/* publish project settings */}
|
||||
{isAdmin && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => projectPublish.handleProjectModal(project?.id)}
|
||||
>
|
||||
<div className="flex-shrink-0 relative flex items-center justify-start gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div>Publish</div>
|
||||
</div>
|
||||
{/* <PublishProjectModal /> */}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{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
|
||||
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">
|
||||
<ArchiveOutlined fontSize="small" />
|
||||
<span>Archived Issues</span>
|
||||
<Icon iconName="settings" className="!text-base !leading-4" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)}
|
||||
>
|
||||
<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>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}>
|
||||
{navigation(workspaceSlug as string, project?.id).map((item) => {
|
||||
if (
|
||||
(item.name === "Cycles" && !project.cycle_view) ||
|
||||
(item.name === "Modules" && !project.module_view) ||
|
||||
(item.name === "Views" && !project.issue_views_view) ||
|
||||
(item.name === "Pages" && !project.page_view)
|
||||
)
|
||||
return;
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel
|
||||
className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}
|
||||
>
|
||||
{navigation(workspaceSlug as string, project?.id).map((item) => {
|
||||
if (
|
||||
(item.name === "Cycles" && !project.cycle_view) ||
|
||||
(item.name === "Modules" && !project.module_view) ||
|
||||
(item.name === "Views" && !project.issue_views_view) ||
|
||||
(item.name === "Pages" && !project.page_view)
|
||||
)
|
||||
return;
|
||||
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
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" : ""}`}
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<item.Icon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
{!sidebarCollapse && item.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
<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
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
{!sidebarCollapse && item.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
);
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
119
apps/app/components/tiptap/bubble-menu/index.tsx
Normal file
119
apps/app/components/tiptap/bubble-menu/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
90
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal file
90
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
130
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal file
130
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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:";
|
||||
}
|
||||
|
57
apps/app/components/tiptap/extensions/image-resize.tsx
Normal file
57
apps/app/components/tiptap/extensions/image-resize.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
137
apps/app/components/tiptap/extensions/index.tsx
Normal file
137
apps/app/components/tiptap/extensions/index.tsx
Normal 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,
|
||||
}),
|
||||
];
|
22
apps/app/components/tiptap/extensions/updated-image.tsx
Normal file
22
apps/app/components/tiptap/extensions/updated-image.tsx
Normal 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;
|
100
apps/app/components/tiptap/index.tsx
Normal file
100
apps/app/components/tiptap/index.tsx
Normal 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;
|
56
apps/app/components/tiptap/plugins/delete-image.tsx
Normal file
56
apps/app/components/tiptap/plugins/delete-image.tsx
Normal 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");
|
||||
}
|
||||
}
|
127
apps/app/components/tiptap/plugins/upload-image.tsx
Normal file
127
apps/app/components/tiptap/plugins/upload-image.tsx
Normal 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);
|
||||
}
|
||||
};
|
56
apps/app/components/tiptap/props.tsx
Normal file
56
apps/app/components/tiptap/props.tsx
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
339
apps/app/components/tiptap/slash-command/index.tsx
Normal file
339
apps/app/components/tiptap/slash-command/index.tsx
Normal 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;
|
6
apps/app/components/tiptap/utils.ts
Normal file
6
apps/app/components/tiptap/utils.ts
Normal 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));
|
||||
}
|
@ -106,7 +106,7 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
|
||||
))}
|
||||
{users.length > length ? (
|
||||
<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" />
|
||||
{users.length - length}
|
||||
</div>
|
||||
|
@ -8,10 +8,13 @@ type Props = {
|
||||
renderAs?: "input" | "button";
|
||||
value: Date | string | null | undefined;
|
||||
onChange: (val: string | null) => void;
|
||||
handleOnOpen?: () => void;
|
||||
handleOnClose?: () => void;
|
||||
placeholder?: string;
|
||||
displayShortForm?: boolean;
|
||||
error?: boolean;
|
||||
noBorder?: boolean;
|
||||
wrapperClassName?: string;
|
||||
className?: string;
|
||||
isClearable?: boolean;
|
||||
disabled?: boolean;
|
||||
@ -23,10 +26,13 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
renderAs = "button",
|
||||
value,
|
||||
onChange,
|
||||
handleOnOpen,
|
||||
handleOnClose,
|
||||
placeholder = "Select date",
|
||||
displayShortForm = false,
|
||||
error = false,
|
||||
noBorder = false,
|
||||
wrapperClassName = "",
|
||||
className = "",
|
||||
isClearable = true,
|
||||
disabled = false,
|
||||
@ -40,6 +46,9 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
if (!val) onChange(null);
|
||||
else onChange(renderDateFormat(val));
|
||||
}}
|
||||
onCalendarOpen={handleOnOpen}
|
||||
onCalendarClose={handleOnClose}
|
||||
wrapperClassName={wrapperClassName}
|
||||
className={`${
|
||||
renderAs === "input"
|
||||
? "block px-2 py-2 text-sm focus:outline-none"
|
||||
|
@ -1,47 +1,76 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
type Props = {
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
clickEvent: React.MouseEvent | null;
|
||||
children: React.ReactNode;
|
||||
title?: string | JSX.Element;
|
||||
isOpen: 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(() => {
|
||||
const hideContextMenu = () => {
|
||||
if (isOpen) setIsOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("click", hideContextMenu);
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
const escapeKeyEvent = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") hideContextMenu();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("click", hideContextMenu);
|
||||
window.addEventListener("keydown", escapeKeyEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", hideContextMenu);
|
||||
window.removeEventListener("keydown", hideContextMenu);
|
||||
window.removeEventListener("keydown", escapeKeyEvent);
|
||||
};
|
||||
}, [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 (
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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`}
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
}}
|
||||
ref={contextMenuRef}
|
||||
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`}
|
||||
>
|
||||
{title && (
|
||||
<h4 className="border-b border-custom-border-200 px-1 py-1 pb-2 text-[0.8rem] font-medium">
|
||||
|
@ -29,9 +29,9 @@ export const Input: React.FC<Props> = ({
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
{...(register && register(name, validations))}
|
||||
{...(register && register(name ?? "", validations))}
|
||||
onChange={(e) => {
|
||||
register && register(name).onChange(e);
|
||||
register && register(name ?? "").onChange(e);
|
||||
onChange && onChange(e);
|
||||
}}
|
||||
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
|
||||
|
2
apps/app/components/ui/input/types.d.ts
vendored
2
apps/app/components/ui/input/types.d.ts
vendored
@ -3,7 +3,7 @@ import type { UseFormRegister, RegisterOptions } from "react-hook-form";
|
||||
|
||||
export interface Props extends React.ComponentPropsWithoutRef<"input"> {
|
||||
label?: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
value?: string | number | readonly string[];
|
||||
mode?: "primary" | "transparent" | "trueTransparent" | "secondary" | "disabled";
|
||||
register?: UseFormRegister<any>;
|
||||
|
@ -1,7 +1,11 @@
|
||||
import React from "react";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
|
||||
type IssueLabelsListProps = {
|
||||
labels?: (string | undefined)[];
|
||||
labels?: (IIssueLabels | undefined)[];
|
||||
length?: number;
|
||||
showLength?: boolean;
|
||||
};
|
||||
@ -14,18 +18,16 @@ export const IssueLabelsList: React.FC<IssueLabelsListProps> = ({
|
||||
<>
|
||||
{labels && (
|
||||
<>
|
||||
{labels.slice(0, length).map((color, index) => (
|
||||
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border border-custom-border-200
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: color && color !== "" ? color : "#000000",
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
position="top"
|
||||
tooltipHeading="Labels"
|
||||
tooltipContent={labels.map((l) => l?.name).join(", ")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-custom-text-200 rounded shadow-sm border border-custom-border-300">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${labels.length} Labels`}
|
||||
</div>
|
||||
))}
|
||||
{labels.length > length ? <span>+{labels.length - length}</span> : null}
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -11,8 +11,8 @@ type Props = {
|
||||
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="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">
|
||||
<Image src={image} className="w-8" alt={title} />
|
||||
<div className="flex items-center justify-center h-14 w-14 rounded-full bg-custom-background-90">
|
||||
<Image src={image} width={32} alt={title} />
|
||||
</div>
|
||||
<h6 className="text-base font-semibold mt-3.5 mb-3">{title}</h6>
|
||||
{description && <p className="text-sm text-custom-text-300">{description}</p>}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
@ -26,57 +28,63 @@ type Props = {
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
workspaceName: "",
|
||||
confirmDelete: "",
|
||||
};
|
||||
|
||||
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 { setToastAlert } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setSelectedWorkspace(data);
|
||||
else {
|
||||
const timer = setTimeout(() => {
|
||||
setSelectedWorkspace(null);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
}
|
||||
}, [data]);
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace;
|
||||
const canDelete =
|
||||
watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
setConfirmWorkspaceName("");
|
||||
setConfirmDeleteMyWorkspace(false);
|
||||
const timer = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
const onSubmit = async () => {
|
||||
if (!data || !canDelete) return;
|
||||
|
||||
await workspaceService
|
||||
.deleteWorkspace(data.slug, user)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
|
||||
router.push("/");
|
||||
|
||||
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
|
||||
prevData?.filter((workspace) => workspace.id !== data.id)
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
message: "Workspace deleted successfully",
|
||||
title: "Success",
|
||||
title: "Success!",
|
||||
message: "Workspace deleted successfully.",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
@ -131,20 +139,21 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
|
||||
<div className="text-custom-text-200">
|
||||
<p className="break-words text-sm ">
|
||||
Enter the workspace name{" "}
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{selectedWorkspace?.name}
|
||||
</span>{" "}
|
||||
to continue:
|
||||
<span className="font-medium text-custom-text-100">{data?.name}</span> to
|
||||
continue:
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
className="mt-2"
|
||||
value={confirmWorkspaceName}
|
||||
onChange={(e) => {
|
||||
setConfirmWorkspaceName(e.target.value);
|
||||
}}
|
||||
<Controller
|
||||
control={control}
|
||||
name="workspaceName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
className="mt-2"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</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>{" "}
|
||||
below:
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter 'delete my workspace'"
|
||||
className="mt-2"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "delete my workspace") {
|
||||
setConfirmDeleteMyWorkspace(true);
|
||||
} else {
|
||||
setConfirmDeleteMyWorkspace(false);
|
||||
}
|
||||
}}
|
||||
name="typeDelete"
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmDelete"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter 'delete my workspace'"
|
||||
className="mt-2"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDeletion} loading={isDeleteLoading || !canDelete}>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete Workspace"}
|
||||
<DangerButton type="submit" disabled={!canDelete} loading={isSubmitting}>
|
||||
{isSubmitting ? "Deleting..." : "Delete Workspace"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -49,8 +49,6 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setS
|
||||
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
|
||||
|
||||
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
|
||||
|
||||
return (
|
||||
|
@ -45,12 +45,14 @@ export const IssuesList: React.FC<Props> = ({ issues, type }) => {
|
||||
>
|
||||
<h4 className="capitalize">{type}</h4>
|
||||
<h4 className="col-span-2">Issue</h4>
|
||||
<h4>Due Date</h4>
|
||||
<h4>{type === "overdue" ? "Due" : "Start"} Date</h4>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-scroll">
|
||||
{issues.length > 0 ? (
|
||||
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 (
|
||||
<Link
|
||||
@ -75,7 +77,7 @@ export const IssuesList: React.FC<Props> = ({ issues, type }) => {
|
||||
</h5>
|
||||
<h5 className="col-span-2">{truncateText(issue.name, 30)}</h5>
|
||||
<h5 className="cursor-default">
|
||||
{renderShortDateWithYearFormat(new Date(issue.target_date as string))}
|
||||
{renderShortDateWithYearFormat(new Date(date?.toString() ?? ""))}
|
||||
</h5>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -15,7 +15,7 @@ export const WorkspaceSidebarQuickAction = () => {
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
? "px-2 hover:bg-custom-sidebar-background-80"
|
||||
: "px-3 shadow border-[0.5px] border-custom-border-300"
|
||||
@ -25,7 +25,7 @@ export const WorkspaceSidebarQuickAction = () => {
|
||||
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>}
|
||||
</button>
|
||||
|
||||
@ -40,7 +40,7 @@ export const WorkspaceSidebarQuickAction = () => {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
@ -42,10 +42,10 @@ export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
|
||||
value: "target_date",
|
||||
label: "Due date",
|
||||
},
|
||||
// {
|
||||
// value: "start_date",
|
||||
// label: "Start date",
|
||||
// },
|
||||
{
|
||||
value: "start_date",
|
||||
label: "Start date",
|
||||
},
|
||||
{
|
||||
value: "created_at",
|
||||
label: "Created date",
|
||||
|
@ -122,7 +122,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
},
|
||||
};
|
||||
|
||||
mutateInboxDetails((prevData) => {
|
||||
mutateInboxDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -156,7 +156,7 @@ export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
filters: { ...initialState.filters },
|
||||
};
|
||||
|
||||
mutateInboxDetails((prevData) => {
|
||||
mutateInboxDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user