forked from github/plane
Merge branch 'develop' of github.com:makeplane/plane into chore/serializers
This commit is contained in:
commit
e1b77d400a
@ -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
|
||||
|
@ -92,6 +92,7 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@ -113,6 +114,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)
|
||||
|
@ -106,7 +106,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")
|
||||
@ -191,7 +193,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")
|
||||
@ -624,7 +628,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
|
||||
):
|
||||
@ -1455,7 +1461,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,238 @@
|
||||
# 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.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),
|
||||
]
|
@ -28,13 +28,13 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
|
||||
setTheme(newTheme);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
theme: {
|
||||
...prevData.theme,
|
||||
...prevData?.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
@ -151,10 +145,10 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||
<div className="remirror-section text-sm">
|
||||
<div id="tiptap-container" className="text-sm">
|
||||
Content:
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
<TiptapEditor
|
||||
value={htmlContent ?? `<p>${content}</p>`}
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{response !== "" && (
|
||||
<div className="page-block-section text-sm">
|
||||
Response:
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
value={`<p>${response}</p>`}
|
||||
customClassName="-mx-3 -my-3"
|
||||
noBorder
|
||||
|
@ -45,26 +45,18 @@ export const ThemeSwitch: React.FC<Props> = observer(
|
||||
currentThemeObj ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
borderColor: currentThemeObj.icon.border,
|
||||
background: currentThemeObj.icon.color1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: currentThemeObj.icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: currentThemeObj.icon.border,
|
||||
background: currentThemeObj.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{currentThemeObj.label}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: currentThemeObj.icon.border,
|
||||
background: currentThemeObj.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"Select your theme"
|
||||
@ -106,26 +98,18 @@ export const ThemeSwitch: React.FC<Props> = observer(
|
||||
<CustomSelect.Option key={value} value={{ value, type }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
borderColor: icon.border,
|
||||
background: icon.color1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: icon.border,
|
||||
background: icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{label}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: icon.border,
|
||||
background: icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
@ -125,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
|
@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData) =>
|
||||
(prevData: any) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
|
@ -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
|
||||
? {
|
||||
|
@ -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 };
|
||||
|
@ -68,7 +68,7 @@ const IntegrationGuide = () => {
|
||||
<p className="text-sm text-custom-text-200">{service.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/export?provider=${service.provider}`}>
|
||||
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
<span className="capitalize">{service.type}</span> now
|
||||
|
@ -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
|
||||
|
@ -100,7 +100,7 @@ const IntegrationGuide = () => {
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/settings/import?provider=${service.provider}`}
|
||||
href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}
|
||||
>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
|
@ -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(
|
||||
|
@ -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 });
|
||||
@ -97,17 +87,26 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="issue-comments-section">
|
||||
<div id="tiptap-container" 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
|
||||
ref={editorRef}
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("comment_html")
|
||||
: value
|
||||
}
|
||||
customClassName="p-3 min-h-[50px]"
|
||||
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,17 +13,13 @@ 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 = {
|
||||
comment: IIssueComment;
|
||||
@ -45,6 +39,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
@ -56,8 +51,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(() => {
|
||||
@ -106,15 +101,18 @@ 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 id="tiptap-container">
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
value={watch("comment_html")}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3"
|
||||
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 +131,12 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
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;
|
||||
@ -40,7 +33,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
handleFormSubmit,
|
||||
isAllowed,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
@ -63,7 +56,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 +67,14 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
[handleFormSubmit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!issue) return;
|
||||
@ -83,6 +84,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 +99,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 +112,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>
|
||||
@ -117,47 +122,41 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<span>{errors.name ? errors.name.message : null}</span>
|
||||
<div className="relative">
|
||||
<div id="tiptap-container" 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) => {
|
||||
setShowAlert(true);
|
||||
setValue("description", jsonValue);
|
||||
debouncedUpdatesEnabled={true}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName="min-h-[150px]"
|
||||
editorContentCustomClassNames="pb-9"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
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: "",
|
||||
@ -344,7 +333,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div id="tiptap-container" className="relative">
|
||||
<div className="flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
@ -374,21 +363,30 @@ 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
|
||||
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" />
|
||||
|
@ -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] });
|
||||
|
||||
|
@ -396,7 +396,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
start_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-custom-background-90"
|
||||
className="bg-custom-background-90 w-full"
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
/>
|
||||
@ -424,7 +424,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-custom-background-90"
|
||||
className="bg-custom-background-90 w-full"
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
/>
|
||||
|
@ -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
|
||||
? {
|
||||
|
@ -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 ${
|
||||
|
@ -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,
|
||||
@ -295,25 +284,27 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div className="page-block-section relative -mt-2 text-custom-text-200">
|
||||
<div
|
||||
id="tiptap-container"
|
||||
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
|
||||
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,7 +313,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
<TiptapEditor
|
||||
ref={editorRef}
|
||||
value={
|
||||
value && value !== "" && Object.keys(value).length > 0
|
||||
? value
|
||||
@ -330,13 +322,14 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
? 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -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,17 @@ 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
|
||||
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
|
||||
|
@ -110,7 +110,7 @@ export const ProfileIssuesView = () => {
|
||||
|
||||
draggedItem[groupByProperty] = destinationGroup;
|
||||
|
||||
mutateProfileIssues((prevData) => {
|
||||
mutateProfileIssues((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = [...groupedIssues[sourceGroup]];
|
||||
|
446
apps/app/components/project/publish-project/modal.tsx
Normal file
446
apps/app/components/project/publish-project/modal.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
// user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<any> = {
|
||||
id: null,
|
||||
comments: false,
|
||||
reactions: false,
|
||||
votes: false,
|
||||
inbox: null,
|
||||
views: [],
|
||||
};
|
||||
|
||||
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 { 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) => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
return projectPublish
|
||||
.createProjectSettingsAsync(
|
||||
workspaceSlug as string,
|
||||
projectPublish.project_id as string,
|
||||
payload,
|
||||
null
|
||||
)
|
||||
.then((response) => response)
|
||||
.catch((error) => {
|
||||
console.error("error", error);
|
||||
return error;
|
||||
});
|
||||
};
|
||||
|
||||
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) => 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) => {
|
||||
reset({ ...defaultValues });
|
||||
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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
115
apps/app/components/tiptap/bubble-menu/index.tsx
Normal file
115
apps/app/components/tiptap/bubble-menu/index.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
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}
|
||||
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>
|
||||
);
|
||||
};
|
73
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal file
73
apps/app/components/tiptap/bubble-menu/link-selector.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
interface LinkSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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 && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const input = form.elements[0] as HTMLInputElement;
|
||||
editor.chain().focus().setLink({ href: input.value }).run();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="Paste a link"
|
||||
className="flex-1 bg-custom-background-100 border border-custom-primary-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<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">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
125
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal file
125
apps/app/components/tiptap/bubble-menu/node-selector.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
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
|
||||
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"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
};
|
142
apps/app/components/tiptap/extensions/index.tsx
Normal file
142
apps/app/components/tiptap/extensions/index.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import TiptapImage from "@tiptap/extension-image";
|
||||
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 UploadImagesPlugin from "../plugins/upload-image";
|
||||
import UniqueID from "@tiptap-pro/extension-unique-id";
|
||||
|
||||
lowlight.registerLanguage("ts", ts);
|
||||
|
||||
const CustomImage = TiptapImage.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [UploadImagesPlugin()];
|
||||
},
|
||||
});
|
||||
|
||||
export const TiptapExtensions = [
|
||||
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({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomImage.configure({
|
||||
allowBase64: true,
|
||||
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,
|
||||
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,
|
||||
}),
|
||||
];
|
138
apps/app/components/tiptap/index.tsx
Normal file
138
apps/app/components/tiptap/index.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
// @ts-nocheck
|
||||
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 { Node } from "@tiptap/pm/model";
|
||||
import { Editor as CoreEditor } from "@tiptap/core";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import fileService from "services/file.service";
|
||||
|
||||
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;
|
||||
editable?: boolean;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
}
|
||||
|
||||
const Tiptap = (props: ITiptapRichTextEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
forwardedRef,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
editable: editable ?? true,
|
||||
editorProps: TiptapEditorProps,
|
||||
extensions: TiptapExtensions,
|
||||
content: value,
|
||||
onUpdate: async ({ editor }) => {
|
||||
// for instant feedback loop
|
||||
setIsSubmitting?.("submitting");
|
||||
checkForNodeDeletions(editor);
|
||||
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 previousState = useRef<EditorState>();
|
||||
|
||||
const onNodeDeleted = useCallback(async (node: Node) => {
|
||||
if (node.type.name === "image") {
|
||||
const assetUrlWithWorkspaceId = new URL(node.attrs.src).pathname.substring(1);
|
||||
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
|
||||
if (resStatus === 204) {
|
||||
console.log("file deleted successfully");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForNodeDeletions = useCallback(
|
||||
(editor: CoreEditor) => {
|
||||
const prevNodesById: Record<string, Node> = {};
|
||||
previousState.current?.doc.forEach((node) => {
|
||||
if (node.attrs.id) {
|
||||
prevNodesById[node.attrs.id] = node;
|
||||
}
|
||||
});
|
||||
|
||||
const nodesById: Record<string, Node> = {};
|
||||
editor.state?.doc.forEach((node) => {
|
||||
if (node.attrs.id) {
|
||||
nodesById[node.attrs.id] = node;
|
||||
}
|
||||
});
|
||||
|
||||
previousState.current = editor.state;
|
||||
|
||||
for (const [id, node] of Object.entries(prevNodesById)) {
|
||||
if (nodesById[id] === undefined) {
|
||||
onNodeDeleted(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onNodeDeleted]
|
||||
);
|
||||
|
||||
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 sm:shadow-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
|
||||
onClick={() => {
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
className={`tiptap-editor-container ${editorClassNames}`}
|
||||
>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className={`${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tiptap;
|
120
apps/app/components/tiptap/plugins/upload-image.tsx
Normal file
120
apps/app/components/tiptap/plugins/upload-image.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
// @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) {
|
||||
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);
|
||||
};
|
||||
|
||||
const src = await UploadImageHandler(file);
|
||||
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): Promise<string> => {
|
||||
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("plane", 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 const TiptapEditorProps: EditorProps = {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
337
apps/app/components/tiptap/slash-command/index.tsx
Normal file
337
apps/app/components/tiptap/slash-command/index.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
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 = ({ 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);
|
||||
}
|
||||
};
|
||||
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;
|
||||
|
||||
const container = document.querySelector("#tiptap-container") as HTMLElement;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SlashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
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));
|
||||
}
|
@ -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 (
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -401,7 +401,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -432,7 +432,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -463,7 +463,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -494,7 +494,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -525,7 +525,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
@ -647,7 +647,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
user
|
||||
);
|
||||
} else {
|
||||
mutateMyViewProps((prevData) => {
|
||||
mutateMyViewProps((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
|
@ -120,7 +120,7 @@ const UserNotificationContextProvider: React.FC<{
|
||||
const handleReadMutation = (action: "read" | "unread") => {
|
||||
const notificationCountNumber = action === "read" ? -1 : 1;
|
||||
|
||||
mutateNotificationCount((prev) => {
|
||||
mutateNotificationCount((prev: any) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const notificationType: keyof NotificationCount =
|
||||
@ -143,8 +143,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
|
||||
|
||||
notificationsMutate(
|
||||
(previousNotifications) =>
|
||||
previousNotifications?.map((notification) =>
|
||||
(previousNotifications: any) =>
|
||||
previousNotifications?.map((notification: any) =>
|
||||
notification.id === notificationId
|
||||
? { ...notification, read_at: isRead ? null : new Date() }
|
||||
: notification
|
||||
@ -199,7 +199,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
});
|
||||
} else {
|
||||
notificationsMutate(
|
||||
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId),
|
||||
(prev: any) =>
|
||||
prev?.filter((prevNotification: any) => prevNotification.id !== notificationId),
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
@ -222,8 +223,8 @@ const UserNotificationContextProvider: React.FC<{
|
||||
null;
|
||||
|
||||
notificationsMutate(
|
||||
(previousNotifications) =>
|
||||
previousNotifications?.map((notification) =>
|
||||
(previousNotifications: any) =>
|
||||
previousNotifications?.map((notification: any) =>
|
||||
notification.id === notificationId
|
||||
? { ...notification, snoozed_till: isSnoozed ? null : new Date(dateTime!) }
|
||||
: notification
|
||||
|
@ -56,7 +56,7 @@ const useCommentReaction = (
|
||||
user.user
|
||||
);
|
||||
|
||||
mutateCommentReactions((prev) => [...(prev || []), data]);
|
||||
mutateCommentReactions((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -69,8 +69,8 @@ const useCommentReaction = (
|
||||
if (!workspaceSlug || !projectId || !commendId) return;
|
||||
|
||||
mutateCommentReactions(
|
||||
(prevData) =>
|
||||
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
(prevData: any) =>
|
||||
prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
|
@ -64,7 +64,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
|
||||
if (issueProperties && projectId) {
|
||||
mutateIssueProperties(
|
||||
(prev) =>
|
||||
(prev: any) =>
|
||||
({
|
||||
...prev,
|
||||
properties: { ...prev?.properties, [key]: !prev?.properties?.[key] },
|
||||
|
@ -56,7 +56,7 @@ const useIssueReaction = (
|
||||
user.user
|
||||
);
|
||||
|
||||
mutateReaction((prev) => [...(prev || []), data]);
|
||||
mutateReaction((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -69,8 +69,8 @@ const useIssueReaction = (
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutateReaction(
|
||||
(prevData) =>
|
||||
prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
(prevData: any) =>
|
||||
prevData?.filter((r: any) => r.actor !== user?.user?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
|
@ -75,7 +75,7 @@ const useUserNotification = () => {
|
||||
const handleReadMutation = (action: "read" | "unread") => {
|
||||
const notificationCountNumber = action === "read" ? -1 : 1;
|
||||
|
||||
mutateNotificationCount((prev) => {
|
||||
mutateNotificationCount((prev: any) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const notificationType: keyof NotificationCount =
|
||||
@ -93,18 +93,18 @@ const useUserNotification = () => {
|
||||
};
|
||||
|
||||
const mutateNotification = (notificationId: string, value: Object) => {
|
||||
notificationMutate((previousNotifications) => {
|
||||
notificationMutate((previousNotifications: any) => {
|
||||
if (!previousNotifications) return previousNotifications;
|
||||
|
||||
const notificationIndex = Math.floor(
|
||||
previousNotifications
|
||||
.map((d) => d.results)
|
||||
.map((d: any) => d.results)
|
||||
.flat()
|
||||
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
|
||||
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
|
||||
);
|
||||
|
||||
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
|
||||
(notification) => notification.id === notificationId
|
||||
(notification: any) => notification.id === notificationId
|
||||
);
|
||||
|
||||
if (notificationIndexInPage === -1) return previousNotifications;
|
||||
@ -126,18 +126,18 @@ const useUserNotification = () => {
|
||||
};
|
||||
|
||||
const removeNotification = (notificationId: string) => {
|
||||
notificationMutate((previousNotifications) => {
|
||||
notificationMutate((previousNotifications: any) => {
|
||||
if (!previousNotifications) return previousNotifications;
|
||||
|
||||
const notificationIndex = Math.floor(
|
||||
previousNotifications
|
||||
.map((d) => d.results)
|
||||
.map((d: any) => d.results)
|
||||
.flat()
|
||||
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
|
||||
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
|
||||
);
|
||||
|
||||
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
|
||||
(notification) => notification.id === notificationId
|
||||
(notification: any) => notification.id === notificationId
|
||||
);
|
||||
|
||||
if (notificationIndexInPage === -1) return previousNotifications;
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
WorkspaceSidebarQuickAction,
|
||||
} from "components/workspace";
|
||||
import { ProjectSidebarList } from "components/project";
|
||||
import { PublishProjectModal } from "components/project/publish-project/modal";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
@ -37,6 +38,7 @@ const Sidebar: React.FC<SidebarProps> = observer(({ toggleSidebar, setToggleSide
|
||||
<ProjectSidebarList />
|
||||
<WorkspaceHelpSection setSidebarActive={setToggleSidebar} />
|
||||
</div>
|
||||
<PublishProjectModal />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,24 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
// mobx store
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
let rootStore: any = null;
|
||||
let rootStore: RootStore = new RootStore();
|
||||
|
||||
export const MobxStoreContext = createContext(null);
|
||||
export const MobxStoreContext = createContext<RootStore>(rootStore);
|
||||
|
||||
const initializeStore = () => {
|
||||
const _rootStore = rootStore ?? new RootStore();
|
||||
|
||||
const _rootStore: RootStore = rootStore ?? new RootStore();
|
||||
if (typeof window === "undefined") return _rootStore;
|
||||
|
||||
if (!rootStore) rootStore = _rootStore;
|
||||
|
||||
return _rootStore;
|
||||
};
|
||||
|
||||
export const MobxStoreProvider = ({ children }: any) => {
|
||||
const store = initializeStore();
|
||||
|
||||
const store: RootStore = initializeStore();
|
||||
return <MobxStoreContext.Provider value={store}>{children}</MobxStoreContext.Provider>;
|
||||
};
|
||||
|
||||
|
@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@jitsu/nextjs": "^3.1.5",
|
||||
@ -23,21 +25,37 @@
|
||||
"@nivo/line": "0.80.0",
|
||||
"@nivo/pie": "0.80.0",
|
||||
"@nivo/scatterplot": "0.80.0",
|
||||
"@remirror/core": "^2.0.11",
|
||||
"@remirror/extension-react-tables": "^2.2.11",
|
||||
"@remirror/pm": "^2.0.3",
|
||||
"@remirror/react": "^2.0.24",
|
||||
"@sentry/nextjs": "^7.36.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tiptap-pro/extension-unique-id": "^2.1.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
||||
"@tiptap/extension-color": "^2.0.4",
|
||||
"@tiptap/extension-highlight": "^2.0.4",
|
||||
"@tiptap/extension-horizontal-rule": "^2.0.4",
|
||||
"@tiptap/extension-image": "^2.0.4",
|
||||
"@tiptap/extension-link": "^2.0.4",
|
||||
"@tiptap/extension-placeholder": "^2.0.4",
|
||||
"@tiptap/extension-task-item": "^2.0.4",
|
||||
"@tiptap/extension-task-list": "^2.0.4",
|
||||
"@tiptap/extension-text-style": "^2.0.4",
|
||||
"@tiptap/extension-underline": "^2.0.4",
|
||||
"@tiptap/pm": "^2.0.4",
|
||||
"@tiptap/react": "^2.0.4",
|
||||
"@tiptap/starter-kit": "^2.0.4",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/react-datepicker": "^4.8.0",
|
||||
"axios": "^1.1.3",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"highlight.js": "^11.8.0",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"next": "12.3.2",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
@ -50,10 +68,14 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"remirror": "^2.0.23",
|
||||
"sharp": "^0.32.1",
|
||||
"sonner": "^0.6.2",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"tlds": "^1.238.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -76,5 +98,8 @@
|
||||
"tailwindcss": "^3.1.6",
|
||||
"tsconfig": "*",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-model": "1.18.1"
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
||||
import SettingsNavbar from "layouts/settings-navbar";
|
||||
// components
|
||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||
import RemirrorRichTextEditor from "components/rich-text-editor";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
// icons
|
||||
import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
@ -105,15 +105,16 @@ const ProfileActivity = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<RemirrorRichTextEditor
|
||||
<Tiptap
|
||||
value={
|
||||
activityItem.new_value && activityItem.new_value !== ""
|
||||
activityItem?.new_value !== ""
|
||||
? activityItem.new_value
|
||||
: activityItem.old_value
|
||||
}
|
||||
editable={false}
|
||||
noBorder
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
editable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -77,7 +77,7 @@ const Profile: NextPage = () => {
|
||||
await userService
|
||||
.updateUser(payload)
|
||||
.then((res) => {
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return { ...prevData, ...res };
|
||||
@ -112,7 +112,7 @@ const Profile: NextPage = () => {
|
||||
title: "Success!",
|
||||
message: "Profile picture removed successfully.",
|
||||
});
|
||||
mutateUser((prevData) => {
|
||||
mutateUser((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
return { ...prevData, avatar: "" };
|
||||
}, false);
|
||||
|
@ -45,6 +45,7 @@ const defaultValues = {
|
||||
const IssueDetailsPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
// console.log(workspaceSlug, "workspaceSlug")
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
|
@ -629,17 +629,19 @@ const SinglePage: NextPage = () => {
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
showBlockDetails={showBlock}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
<>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
showBlockDetails={showBlock}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
|
@ -139,7 +139,7 @@ const MembersSettings: NextPage = () => {
|
||||
selectedRemoveMember
|
||||
);
|
||||
mutateMembers(
|
||||
(prevData) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
|
||||
(prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember),
|
||||
false
|
||||
);
|
||||
}
|
||||
@ -150,7 +150,8 @@ const MembersSettings: NextPage = () => {
|
||||
selectedInviteRemoveMember
|
||||
);
|
||||
mutateInvitations(
|
||||
(prevData) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
(prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
false
|
||||
);
|
||||
}
|
||||
@ -194,19 +195,23 @@ const MembersSettings: NextPage = () => {
|
||||
? members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-x-6 gap-y-2">
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
|
||||
{member.avatar && member.avatar !== "" ? (
|
||||
{member.avatar && member.avatar !== "" ? (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
|
||||
<img
|
||||
src={member.avatar}
|
||||
alt={member.display_name}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
|
||||
/>
|
||||
) : member.display_name || member.email ? (
|
||||
(member.display_name || member.email)?.charAt(0)
|
||||
) : (
|
||||
"?"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : member.display_name || member.email ? (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
|
||||
{(member.display_name || member.email)?.charAt(0)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{member.member ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
|
||||
|
@ -139,14 +139,15 @@ const MembersSettings: NextPage = () => {
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
mutateMembers((prevData) =>
|
||||
prevData?.filter((item) => item.id !== selectedRemoveMember)
|
||||
mutateMembers((prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedRemoveMember)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (selectedInviteRemoveMember) {
|
||||
mutateInvitations(
|
||||
(prevData) => prevData?.filter((item) => item.id !== selectedInviteRemoveMember),
|
||||
(prevData: any) =>
|
||||
prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
|
||||
false
|
||||
);
|
||||
workspaceService
|
||||
@ -207,19 +208,23 @@ const MembersSettings: NextPage = () => {
|
||||
? members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-x-8 gap-y-2">
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
|
||||
{member.avatar && member.avatar !== "" ? (
|
||||
{member.avatar && member.avatar !== "" ? (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
|
||||
<img
|
||||
src={member.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
|
||||
alt={member.display_name || member.email}
|
||||
/>
|
||||
) : member.display_name || member.email ? (
|
||||
(member.display_name || member.email)?.charAt(0)
|
||||
) : (
|
||||
"?"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : member.display_name || member.email ? (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
|
||||
{(member.display_name || member.email)?.charAt(0)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{member.member ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
|
||||
@ -258,8 +263,8 @@ const MembersSettings: NextPage = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutateMembers(
|
||||
(prevData) =>
|
||||
prevData?.map((m) =>
|
||||
(prevData: any) =>
|
||||
prevData?.map((m: any) =>
|
||||
m.id === member.id ? { ...m, role: value } : m
|
||||
),
|
||||
false
|
||||
|
@ -149,6 +149,10 @@ const HomePage: NextPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTheme("system");
|
||||
}, [setTheme]);
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
{isLoading ? (
|
||||
|
@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
|
@ -40,6 +40,14 @@ class FileServices extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
|
||||
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
|
||||
.then((response) => response?.status)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
|
||||
const lastIndex = assetUrl.lastIndexOf("/");
|
||||
const assetId = assetUrl.substring(lastIndex + 1);
|
||||
@ -50,7 +58,6 @@ class FileServices extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadUserFile(file: FormData): Promise<any> {
|
||||
return this.mediaUpload(`/api/users/file-assets/`, file)
|
||||
.then((response) => response?.data)
|
||||
|
117
apps/app/services/project-publish.service.ts
Normal file
117
apps/app/services/project-publish.service.ts
Normal file
@ -0,0 +1,117 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// types
|
||||
import { ICurrentUserResponse } from "types";
|
||||
import { IProjectPublishSettings } from "store/project-publish";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
class ProjectServices extends APIService {
|
||||
constructor() {
|
||||
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
}
|
||||
|
||||
async getProjectSettingsAsync(
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/`
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent) {
|
||||
// trackEventServices.trackProjectPublishSettingsEvent(
|
||||
// response.data,
|
||||
// "GET_PROJECT_PUBLISH_SETTINGS",
|
||||
// user
|
||||
// );
|
||||
}
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async createProjectSettingsAsync(
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/`,
|
||||
data
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent) {
|
||||
// trackEventServices.trackProjectPublishSettingsEvent(
|
||||
// response.data,
|
||||
// "CREATE_PROJECT_PUBLISH_SETTINGS",
|
||||
// user
|
||||
// );
|
||||
}
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectSettingsAsync(
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/${project_publish_id}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent) {
|
||||
// trackEventServices.trackProjectPublishSettingsEvent(
|
||||
// response.data,
|
||||
// "UPDATE_PROJECT_PUBLISH_SETTINGS",
|
||||
// user
|
||||
// );
|
||||
}
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectSettingsAsync(
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.delete(
|
||||
`/api/workspaces/${workspace_slug}/projects/${project_slug}/project-deploy-boards/${project_publish_id}/`
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent) {
|
||||
// trackEventServices.trackProjectPublishSettingsEvent(
|
||||
// response.data,
|
||||
// "DELETE_PROJECT_PUBLISH_SETTINGS",
|
||||
// user
|
||||
// );
|
||||
}
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectServices;
|
@ -856,6 +856,27 @@ class TrackEventServices extends APIService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// project publish settings track events starts
|
||||
async trackProjectPublishSettingsEvent(
|
||||
data: any,
|
||||
eventName: string,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
const payload: any = data;
|
||||
|
||||
return this.request({
|
||||
url: "/api/track-event",
|
||||
method: "POST",
|
||||
data: {
|
||||
eventName,
|
||||
extra: payload,
|
||||
user: user,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// project publish settings track events ends
|
||||
}
|
||||
|
||||
const trackEventServices = new TrackEventServices();
|
||||
|
279
apps/app/store/project-publish.tsx
Normal file
279
apps/app/store/project-publish.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { RootStore } from "./root";
|
||||
// services
|
||||
import ProjectServices from "services/project-publish.service";
|
||||
|
||||
export type IProjectPublishSettingsViewKeys =
|
||||
| "list"
|
||||
| "gantt"
|
||||
| "kanban"
|
||||
| "calendar"
|
||||
| "spreadsheet"
|
||||
| string;
|
||||
|
||||
export interface IProjectPublishSettingsViews {
|
||||
list: boolean;
|
||||
gantt: boolean;
|
||||
kanban: boolean;
|
||||
calendar: boolean;
|
||||
spreadsheet: boolean;
|
||||
}
|
||||
|
||||
export interface IProjectPublishSettings {
|
||||
id?: string;
|
||||
project?: string;
|
||||
comments: boolean;
|
||||
reactions: boolean;
|
||||
votes: boolean;
|
||||
views: IProjectPublishSettingsViews;
|
||||
inbox: null;
|
||||
}
|
||||
|
||||
export interface IProjectPublishStore {
|
||||
loader: boolean;
|
||||
error: any | null;
|
||||
|
||||
projectPublishModal: boolean;
|
||||
project_id: string | null;
|
||||
projectPublishSettings: IProjectPublishSettings | "not-initialized";
|
||||
|
||||
handleProjectModal: (project_id: string | null) => void;
|
||||
|
||||
getProjectSettingsAsync: (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
user: any
|
||||
) => Promise<void>;
|
||||
createProjectSettingsAsync: (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: any
|
||||
) => Promise<void>;
|
||||
updateProjectSettingsAsync: (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: any
|
||||
) => Promise<void>;
|
||||
deleteProjectSettingsAsync: (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
user: any
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
class ProjectPublishStore implements IProjectPublishStore {
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
|
||||
projectPublishModal: boolean = false;
|
||||
project_id: string | null = null;
|
||||
projectPublishSettings: IProjectPublishSettings | "not-initialized" = "not-initialized";
|
||||
|
||||
// root store
|
||||
rootStore;
|
||||
// service
|
||||
projectPublishService;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
loader: observable,
|
||||
error: observable,
|
||||
|
||||
projectPublishModal: observable,
|
||||
project_id: observable,
|
||||
projectPublishSettings: observable.ref,
|
||||
// action
|
||||
handleProjectModal: action,
|
||||
// computed
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
this.projectPublishService = new ProjectServices();
|
||||
}
|
||||
|
||||
handleProjectModal = (project_id: string | null = null) => {
|
||||
if (project_id) {
|
||||
this.projectPublishModal = !this.projectPublishModal;
|
||||
this.project_id = project_id;
|
||||
} else {
|
||||
this.projectPublishModal = !this.projectPublishModal;
|
||||
this.project_id = null;
|
||||
this.projectPublishSettings = "not-initialized";
|
||||
}
|
||||
};
|
||||
|
||||
getProjectSettingsAsync = async (workspace_slug: string, project_slug: string, user: any) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await this.projectPublishService.getProjectSettingsAsync(
|
||||
workspace_slug,
|
||||
project_slug,
|
||||
user
|
||||
);
|
||||
|
||||
if (response && response.length > 0) {
|
||||
const _projectPublishSettings: IProjectPublishSettings = {
|
||||
id: response[0]?.id,
|
||||
comments: response[0]?.comments,
|
||||
reactions: response[0]?.reactions,
|
||||
votes: response[0]?.votes,
|
||||
views: {
|
||||
list: response[0]?.views?.list || false,
|
||||
kanban: response[0]?.views?.kanban || false,
|
||||
calendar: response[0]?.views?.calendar || false,
|
||||
gantt: response[0]?.views?.gantt || false,
|
||||
spreadsheet: response[0]?.views?.spreadsheet || false,
|
||||
},
|
||||
inbox: response[0]?.inbox || null,
|
||||
project: response[0]?.project || null,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.projectPublishSettings = _projectPublishSettings;
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
} else {
|
||||
this.projectPublishSettings = "not-initialized";
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
createProjectSettingsAsync = async (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: any
|
||||
) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await this.projectPublishService.createProjectSettingsAsync(
|
||||
workspace_slug,
|
||||
project_slug,
|
||||
data,
|
||||
user
|
||||
);
|
||||
|
||||
if (response) {
|
||||
const _projectPublishSettings: IProjectPublishSettings = {
|
||||
id: response?.id || null,
|
||||
comments: response?.comments || false,
|
||||
reactions: response?.reactions || false,
|
||||
votes: response?.votes || false,
|
||||
views: { ...response?.views },
|
||||
inbox: response?.inbox || null,
|
||||
project: response?.project || null,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.projectPublishSettings = _projectPublishSettings;
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
updateProjectSettingsAsync = async (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
data: IProjectPublishSettings,
|
||||
user: any
|
||||
) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await this.projectPublishService.updateProjectSettingsAsync(
|
||||
workspace_slug,
|
||||
project_slug,
|
||||
project_publish_id,
|
||||
data,
|
||||
user
|
||||
);
|
||||
|
||||
if (response) {
|
||||
const _projectPublishSettings: IProjectPublishSettings = {
|
||||
id: response?.id || null,
|
||||
comments: response?.comments || false,
|
||||
reactions: response?.reactions || false,
|
||||
votes: response?.votes || false,
|
||||
views: { ...response?.views },
|
||||
inbox: response?.inbox || null,
|
||||
project: response?.project || null,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.projectPublishSettings = _projectPublishSettings;
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
deleteProjectSettingsAsync = async (
|
||||
workspace_slug: string,
|
||||
project_slug: string,
|
||||
project_publish_id: string,
|
||||
user: any
|
||||
) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await this.projectPublishService.deleteProjectSettingsAsync(
|
||||
workspace_slug,
|
||||
project_slug,
|
||||
project_publish_id,
|
||||
user
|
||||
);
|
||||
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
this.projectPublishSettings = "not-initialized";
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default ProjectPublishStore;
|
@ -3,15 +3,18 @@ import { enableStaticRendering } from "mobx-react-lite";
|
||||
// store imports
|
||||
import UserStore from "./user";
|
||||
import ThemeStore from "./theme";
|
||||
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
export class RootStore {
|
||||
user;
|
||||
theme;
|
||||
projectPublish: IProjectPublishStore;
|
||||
|
||||
constructor() {
|
||||
this.user = new UserStore(this);
|
||||
this.theme = new ThemeStore(this);
|
||||
this.projectPublish = new ProjectPublishStore(this);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,96 @@
|
||||
.empty-node::after {
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 15px;
|
||||
margin-left: 1px;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.ProseMirror .is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Custom image styles */
|
||||
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #5abbf7;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
|
||||
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
position: relative;
|
||||
border: 2px solid rgb(var(--color-text-100));
|
||||
margin-right: 0.3rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(var(--color-background-80));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgb(var(--color-background-90));
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em;
|
||||
transform-origin: center;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: rgb(var(--color-text-200));
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Overwrite tippy-box original max-width */
|
||||
|
||||
.tippy-box {
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
@ -31,66 +116,13 @@
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: 0.8;
|
||||
vertical-align: -2px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
margin: 0 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 50ms ease-in-out;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
.fadeIn {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
.fadeOut {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
|
||||
.page-block-section .remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.remirror-section .remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.remirror-editor-wrapper {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root {
|
||||
border: none !important;
|
||||
border-radius: 0.25rem !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root svg {
|
||||
fill: rgb(var(--color-text-100)) !important;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root.Mui-selected,
|
||||
.MuiButtonBase-root:hover {
|
||||
background-color: rgb(var(--color-background-100)) !important;
|
||||
}
|
||||
|
@ -182,5 +182,8 @@ module.exports = {
|
||||
custom: ["Inter", "sans-serif"],
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography")
|
||||
],
|
||||
};
|
||||
|
11
apps/app/types/users.d.ts
vendored
11
apps/app/types/users.d.ts
vendored
@ -40,6 +40,17 @@ export interface IUser {
|
||||
[...rest: string]: any;
|
||||
}
|
||||
|
||||
export interface ICustomTheme {
|
||||
background: string;
|
||||
text: string;
|
||||
primary: string;
|
||||
sidebarBackground: string;
|
||||
sidebarText: string;
|
||||
darkPalette: boolean;
|
||||
palette: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface ICurrentUserResponse extends IUser {
|
||||
assigned_issues: number;
|
||||
last_workspace_id: string | null;
|
||||
|
30
apps/space/app/404/page.tsx
Normal file
30
apps/space/app/404/page.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
// next imports
|
||||
import Image from "next/image";
|
||||
|
||||
const Custom404Error = () => (
|
||||
<div className="relative w-screen min-h-screen h-full flex justify-center items-center py-5">
|
||||
<div className="max-w-[700px] space-y-5">
|
||||
<div className="flex items-center flex-col gap-3 text-center">
|
||||
<div className="relative w-[240px] h-[240px]">
|
||||
<Image src={`/404.svg`} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="text-xl font-medium">Oops! Something went wrong.</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center flex justify-center items-center">
|
||||
<a
|
||||
href={`https://app.plane.so/`}
|
||||
className="transition-all border border-gray-200 bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-800 cursor-pointer p-1.5 px-2.5 rounded-sm text-sm font-medium hover:scale-105 select-none"
|
||||
>
|
||||
Go to your Workspace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Custom404Error;
|
@ -1,11 +1,44 @@
|
||||
"use client";
|
||||
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
// components
|
||||
import IssueNavbar from "components/issues/navbar";
|
||||
import IssueFilter from "components/issues/filters-render";
|
||||
// service
|
||||
import ProjectService from "services/project.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type LayoutProps = {
|
||||
params: { workspace_slug: string; project_slug: string };
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
|
||||
// read route params
|
||||
const { workspace_slug, project_slug } = params;
|
||||
const projectServiceInstance = new ProjectService();
|
||||
|
||||
try {
|
||||
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||
|
||||
return {
|
||||
title: `${project?.project_details?.name} | ${workspace_slug}`,
|
||||
description: `${
|
||||
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
|
||||
}`,
|
||||
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
|
||||
typeof project?.project_details?.emoji != "object"
|
||||
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
|
||||
: "✈️"
|
||||
}</text></svg>`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.data?.error) {
|
||||
redirect(`/project-not-published`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
||||
|
@ -3,7 +3,7 @@
|
||||
import React from "react";
|
||||
|
||||
const HomePage = () => (
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Space</div>
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
|
||||
);
|
||||
|
||||
export default HomePage;
|
||||
|
31
apps/space/app/project-not-published/page.tsx
Normal file
31
apps/space/app/project-not-published/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
// next imports
|
||||
import Image from "next/image";
|
||||
|
||||
const CustomProjectNotPublishedError = () => (
|
||||
<div className="relative w-screen min-h-screen h-full flex justify-center items-center py-5">
|
||||
<div className="max-w-[700px] space-y-5">
|
||||
<div className="flex items-center flex-col gap-3 text-center">
|
||||
<div className="relative w-[240px] h-[240px]">
|
||||
<Image src={`/project-not-published.svg`} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
Oops! The page you{`'`}re looking for isn{`'`}t live at the moment.
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
If this is your project, login to your workspace to adjust its visibility settings and make it public.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center flex justify-center items-center">
|
||||
<a
|
||||
href={`https://app.plane.so/`}
|
||||
className="transition-all border border-gray-200 bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-800 cursor-pointer p-1.5 px-2.5 rounded-sm text-sm font-medium hover:scale-105 select-none"
|
||||
>
|
||||
Go to your Workspace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CustomProjectNotPublishedError;
|
@ -27,7 +27,7 @@ export const IssueListBlock = ({ issue }: { issue: IIssue }) => {
|
||||
<div className="font-medium text-gray-800 h-full line-clamp-2">{issue.name}</div>
|
||||
|
||||
{/* priority */}
|
||||
<div className="relative flex items-center gap-3 w-full">
|
||||
<div className="relative flex flex-wrap items-center gap-2 w-full">
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
|
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// next imports
|
||||
import Image from "next/image";
|
||||
// components
|
||||
import { NavbarSearch } from "./search";
|
||||
import { NavbarIssueBoardView } from "./issue-board-view";
|
||||
@ -12,6 +14,18 @@ import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { RootStore } from "store/root";
|
||||
|
||||
const renderEmoji = (emoji: string | { name: string; color: string }) => {
|
||||
if (!emoji) return;
|
||||
|
||||
if (typeof emoji === "object")
|
||||
return (
|
||||
<span style={{ color: emoji.color }} className="material-symbols-rounded text-lg">
|
||||
{emoji.name}
|
||||
</span>
|
||||
);
|
||||
else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji));
|
||||
};
|
||||
|
||||
const IssueNavbar = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
@ -20,7 +34,11 @@ const IssueNavbar = observer(() => {
|
||||
{/* project detail */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="w-[32px] h-[32px] rounded-sm flex justify-center items-center bg-gray-100 text-[24px]">
|
||||
{store?.project?.project && store?.project?.project?.icon ? store?.project?.project?.icon : "😊"}
|
||||
{store?.project?.project && store?.project?.project?.emoji ? (
|
||||
renderEmoji(store?.project?.project?.emoji)
|
||||
) : (
|
||||
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-lg max-w-[300px] line-clamp-1 overflow-hidden">
|
||||
{store?.project?.project?.name || `...`}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "plane-space",
|
||||
"name": "plane-deploy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -20,7 +20,7 @@
|
||||
"js-cookie": "^3.0.1",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"next": "^13.4.13",
|
||||
"next": "^13.4.16",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
17
apps/space/public/404.svg
Normal file
17
apps/space/public/404.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg width="596" height="568" viewBox="0 0 596 568" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="293" cy="284" r="284" fill="#F1F3F5"/>
|
||||
<path d="M191.016 181.117H178.242V174.008H190.293V169.477H178.242V162.68H191.016V157.816H172.344V186H191.016V181.117ZM230.172 162.426H235.191C238.121 162.426 239.957 164.184 239.957 166.918C239.957 169.711 238.219 171.41 235.25 171.41H230.172V162.426ZM230.172 175.688H234.898L240.152 186H246.832L240.895 174.809C244.137 173.539 246.012 170.414 246.012 166.801C246.012 161.234 242.301 157.816 235.816 157.816H224.273V186H230.172V175.688ZM284.797 162.426H289.816C292.746 162.426 294.582 164.184 294.582 166.918C294.582 169.711 292.844 171.41 289.875 171.41H284.797V162.426ZM284.797 175.688H289.523L294.777 186H301.457L295.52 174.809C298.762 173.539 300.637 170.414 300.637 166.801C300.637 161.234 296.926 157.816 290.441 157.816H278.898V186H284.797V175.688ZM346.238 157.328C337.879 157.328 332.645 162.934 332.645 171.918C332.645 180.883 337.879 186.488 346.238 186.488C354.578 186.488 359.832 180.883 359.832 171.918C359.832 162.934 354.578 157.328 346.238 157.328ZM346.238 162.25C350.848 162.25 353.797 166 353.797 171.918C353.797 177.816 350.848 181.547 346.238 181.547C341.609 181.547 338.66 177.816 338.66 171.918C338.66 166 341.629 162.25 346.238 162.25ZM398.539 162.426H403.559C406.488 162.426 408.324 164.184 408.324 166.918C408.324 169.711 406.586 171.41 403.617 171.41H398.539V162.426ZM398.539 175.688H403.266L408.52 186H415.199L409.262 174.809C412.504 173.539 414.379 170.414 414.379 166.801C414.379 161.234 410.668 157.816 404.184 157.816H392.641V186H398.539V175.688Z" fill="black"/>
|
||||
<path d="M191.016 181.117H178.242V174.008H190.293V169.477H178.242V162.68H191.016V157.816H172.344V186H191.016V181.117ZM230.172 162.426H235.191C238.121 162.426 239.957 164.184 239.957 166.918C239.957 169.711 238.219 171.41 235.25 171.41H230.172V162.426ZM230.172 175.688H234.898L240.152 186H246.832L240.895 174.809C244.137 173.539 246.012 170.414 246.012 166.801C246.012 161.234 242.301 157.816 235.816 157.816H224.273V186H230.172V175.688ZM284.797 162.426H289.816C292.746 162.426 294.582 164.184 294.582 166.918C294.582 169.711 292.844 171.41 289.875 171.41H284.797V162.426ZM284.797 175.688H289.523L294.777 186H301.457L295.52 174.809C298.762 173.539 300.637 170.414 300.637 166.801C300.637 161.234 296.926 157.816 290.441 157.816H278.898V186H284.797V175.688ZM346.238 157.328C337.879 157.328 332.645 162.934 332.645 171.918C332.645 180.883 337.879 186.488 346.238 186.488C354.578 186.488 359.832 180.883 359.832 171.918C359.832 162.934 354.578 157.328 346.238 157.328ZM346.238 162.25C350.848 162.25 353.797 166 353.797 171.918C353.797 177.816 350.848 181.547 346.238 181.547C341.609 181.547 338.66 177.816 338.66 171.918C338.66 166 341.629 162.25 346.238 162.25ZM398.539 162.426H403.559C406.488 162.426 408.324 164.184 408.324 166.918C408.324 169.711 406.586 171.41 403.617 171.41H398.539V162.426ZM398.539 175.688H403.266L408.52 186H415.199L409.262 174.809C412.504 173.539 414.379 170.414 414.379 166.801C414.379 161.234 410.668 157.816 404.184 157.816H392.641V186H398.539V175.688Z" fill="black"/>
|
||||
<mask id="mask0_468_2978" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="10" y="258" width="568" height="222">
|
||||
<path d="M115.723 475H157.764V437.354H185.303V401.904H157.764V263.623H95.3613C52.002 327.49 29.0039 364.551 10.5469 399.707V437.354H115.723V475ZM49.3652 402.051C66.5039 368.945 84.9609 340.088 115.723 294.971H116.602V403.223H49.3652V402.051ZM292.857 479.688C346.031 479.688 378.258 437.061 378.258 368.799C378.258 300.537 345.738 258.789 292.857 258.789C239.977 258.789 207.311 300.684 207.311 368.945C207.311 437.354 239.684 479.688 292.857 479.688ZM292.857 444.238C267.662 444.238 252.281 416.992 252.281 368.945C252.281 321.338 267.955 294.238 292.857 294.238C317.906 294.238 333.287 321.191 333.287 368.945C333.287 417.139 318.053 444.238 292.857 444.238ZM507.785 475H549.826V437.354H577.365V401.904H549.826V263.623H487.424C444.064 327.49 421.066 364.551 402.609 399.707V437.354H507.785V475ZM441.428 402.051C458.566 368.945 477.023 340.088 507.785 294.971H508.664V403.223H441.428V402.051Z" fill="#858E96"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_468_2978)">
|
||||
<path d="M369.5 527L243 223.5H-27.5V527H369.5Z" fill="#858E96"/>
|
||||
</g>
|
||||
<mask id="mask1_468_2978" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="19" y="277" width="568" height="222">
|
||||
<path d="M124.723 494H166.764V456.354H194.303V420.904H166.764V282.623H104.361C61.002 346.49 38.0039 383.551 19.5469 418.707V456.354H124.723V494ZM58.3652 421.051C75.5039 387.945 93.9609 359.088 124.723 313.971H125.602V422.223H58.3652V421.051ZM301.857 498.688C355.031 498.688 387.258 456.061 387.258 387.799C387.258 319.537 354.738 277.789 301.857 277.789C248.977 277.789 216.311 319.684 216.311 387.945C216.311 456.354 248.684 498.688 301.857 498.688ZM301.857 463.238C276.662 463.238 261.281 435.992 261.281 387.945C261.281 340.338 276.955 313.238 301.857 313.238C326.906 313.238 342.287 340.191 342.287 387.945C342.287 436.139 327.053 463.238 301.857 463.238ZM516.785 494H558.826V456.354H586.365V420.904H558.826V282.623H496.424C453.064 346.49 430.066 383.551 411.609 418.707V456.354H516.785V494ZM450.428 421.051C467.566 387.945 486.023 359.088 516.785 313.971H517.664V422.223H450.428V421.051Z" fill="#ACB5BD"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_468_2978)">
|
||||
<path d="M250 242.5L376.5 546H647V242.5H250Z" fill="#858E96"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user