Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 024a870e50 | |||
| 5ba921edd8 | |||
| 08ba25a8f8 | |||
| bc54bed157 | |||
| 47a76f48b4 | |||
| a0f03d07f3 |
+1
-1
@@ -1,3 +1,3 @@
|
||||
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
|
||||
worker: celery -A plane worker -l info
|
||||
worker: celery -A plane worker -O fair --prefetch-multiplier=1 --max-tasks-per-child=100 --max-memory-per-child=150000 -l INFO
|
||||
beat: celery -A plane beat -l INFO
|
||||
@@ -54,8 +54,6 @@ class PageSerializer(BaseSerializer):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
description = self.context["description"]
|
||||
description_binary = self.context["description_binary"]
|
||||
description_html = self.context["description_html"]
|
||||
|
||||
# Get the workspace id from the project
|
||||
@@ -64,8 +62,6 @@ class PageSerializer(BaseSerializer):
|
||||
# Create the page
|
||||
page = Page.objects.create(
|
||||
**validated_data,
|
||||
description=description,
|
||||
description_binary=description_binary,
|
||||
description_html=description_html,
|
||||
owned_by_id=owned_by_id,
|
||||
workspace_id=project.workspace_id,
|
||||
|
||||
@@ -8,7 +8,6 @@ from plane.app.views import (
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,9 +78,4 @@ urlpatterns = [
|
||||
PageVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
|
||||
PageDuplicateEndpoint.as_view(),
|
||||
name="page-duplicate",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -155,7 +155,6 @@ from .page.base import (
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
from .page.version import PageVersionEndpoint
|
||||
|
||||
|
||||
@@ -121,8 +121,6 @@ class PageViewSet(BaseViewSet):
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"owned_by_id": request.user.id,
|
||||
"description": request.data.get("description", {}),
|
||||
"description_binary": request.data.get("description_binary", None),
|
||||
"description_html": request.data.get("description_html", "<p></p>"),
|
||||
},
|
||||
)
|
||||
@@ -555,33 +553,3 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
|
||||
class PageDuplicateEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
).values()
|
||||
new_page_data = list(page)[0]
|
||||
new_page_data.name = f"{new_page_data.name} (Copy)"
|
||||
|
||||
serializer = PageSerializer(
|
||||
data=new_page_data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"owned_by_id": request.user.id,
|
||||
"description": new_page_data.description,
|
||||
"description_binary": new_page_data.description_binary,
|
||||
"description_html": new_page_data.description_html,
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page = Page.objects.get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from django.utils import timezone
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
@shared_task
|
||||
|
||||
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
|
||||
def delete_api_logs():
|
||||
# Get the logs older than 30 days to delete
|
||||
logs_to_delete = APIActivityLog.objects.filter(
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.db.models.fields.related import OneToOneRel
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
"""
|
||||
Soft delete related objects for a given model instance
|
||||
@@ -111,7 +111,7 @@ def restore_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
|
||||
def hard_delete():
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -531,7 +532,7 @@ def create_module_issues(workspace, project, user_id, issue_count):
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def create_dummy_data(
|
||||
slug,
|
||||
email,
|
||||
|
||||
@@ -2,16 +2,16 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from bs4 import BeautifulSoup
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
@@ -168,7 +168,7 @@ def process_html_content(content):
|
||||
return processed_content_list
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
|
||||
def send_email_notification(
|
||||
issue_id, notification_data, receiver_id, email_notification_ids
|
||||
):
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Python imports
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# third party imports
|
||||
from celery import shared_task
|
||||
from posthog import Posthog
|
||||
@@ -49,7 +53,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
|
||||
try:
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
|
||||
|
||||
@@ -7,12 +7,13 @@ import zipfile
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from openpyxl import Workbook
|
||||
|
||||
# Module imports
|
||||
@@ -299,7 +300,7 @@ def generate_xlsx(header, project_id, issues, files):
|
||||
files.append((f"{project_id}.xlsx", xlsx_file))
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
|
||||
try:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
||||
@@ -15,7 +15,7 @@ from botocore.client import Config
|
||||
from plane.db.models import ExporterHistory
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
|
||||
def delete_old_s3_link():
|
||||
# Get a list of keys and IDs to process
|
||||
expired_exporter_history = ExporterHistory.objects.filter(
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -13,7 +14,7 @@ from celery import shared_task
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_HIGH_QUEUE)
|
||||
def delete_unuploaded_file_asset():
|
||||
"""This task deletes unuploaded file assets older than a certain number of days."""
|
||||
FileAsset.objects.filter(
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_HIGH_QUEUE)
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
try:
|
||||
relative_link = (
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
import json
|
||||
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -1548,7 +1548,7 @@ def create_intake_activity(
|
||||
|
||||
|
||||
# Receive message from room group
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_HIGH_QUEUE)
|
||||
def issue_activity(
|
||||
type,
|
||||
requested_data,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from django.db.models import Q
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
@@ -15,7 +16,7 @@ from plane.db.models import Issue, Project, State
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_SCHEDULER_QUEUE)
|
||||
def archive_and_close_old_issues():
|
||||
archive_old_issues()
|
||||
close_old_issues()
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
# Python imports
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, IssueDescriptionVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
@@ -39,7 +46,7 @@ def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_HIGH_QUEUE)
|
||||
def issue_description_version_task(
|
||||
updated_issue, issue_id, user_id, is_creating=False
|
||||
) -> Optional[bool]:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
|
||||
def magic_link(email, key, token, current_site):
|
||||
try:
|
||||
(
|
||||
|
||||
@@ -3,6 +3,9 @@ import json
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
@@ -204,7 +207,7 @@ def create_mention_notification(
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_NOTIFICATION_QUEUE)
|
||||
def notifications(
|
||||
type,
|
||||
issue_id,
|
||||
|
||||
@@ -3,13 +3,14 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third-party imports
|
||||
from bs4 import BeautifulSoup
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageLog
|
||||
from celery import shared_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -33,7 +34,7 @@ def extract_components(value, tag):
|
||||
return []
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def page_transaction(new_value, old_value, page_id):
|
||||
try:
|
||||
page = Page.objects.get(pk=page_id)
|
||||
|
||||
@@ -4,12 +4,15 @@ import json
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def page_version(page_id, existing_instance, user_id):
|
||||
try:
|
||||
# Get the page
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Third party imports
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
# Module imports
|
||||
@@ -17,7 +19,7 @@ from plane.db.models import ProjectMember
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def project_add_user_email(current_site, project_member_id, invitor_id):
|
||||
try:
|
||||
# Get the invitor
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectMemberInvite, User
|
||||
@@ -16,7 +17,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def project_invitation(email, project_id, token, current_site, invitor):
|
||||
try:
|
||||
user = User.objects.get(email=invitor)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -7,11 +8,18 @@ from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import UserRecentVisit, Workspace
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slug):
|
||||
try:
|
||||
ri = redis_instance()
|
||||
# Check if the same entity is set in redis for the user
|
||||
if ri.exists(f"recent_visited:{user_id}:{entity_name}:{entity_identifier}"):
|
||||
return
|
||||
|
||||
# Check if the same entity is set in redis for the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
recent_visited = UserRecentVisit.objects.filter(
|
||||
entity_name=entity_name,
|
||||
@@ -50,6 +58,10 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
|
||||
recent_activity.updated_by_id = user_id
|
||||
recent_activity.save(update_fields=["created_by_id", "updated_by_id"])
|
||||
|
||||
# Set in redis
|
||||
ri.set(
|
||||
f"recent_visited:{user_id}:{entity_name}:{entity_identifier}", 1, ex=60 * 10
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
@@ -7,7 +10,7 @@ from plane.settings.storage import S3Storage
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def get_asset_object_metadata(asset_id):
|
||||
try:
|
||||
# Get the asset
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -15,7 +16,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_HIGH_QUEUE)
|
||||
def user_activation_email(current_site, user_id):
|
||||
try:
|
||||
# Send email to user when account is activated
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -15,7 +16,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def user_deactivation_email(current_site, user_id):
|
||||
try:
|
||||
# Send email to user when account is deactivated
|
||||
|
||||
@@ -86,6 +86,7 @@ def get_model_data(event, event_id, many=False):
|
||||
retry_backoff=600,
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
queue=settings.TASK_NOTIFICATION_QUEUE,
|
||||
)
|
||||
def webhook_task(self, webhook, slug, event, event_data, action, current_site):
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,7 @@ from celery import shared_task
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
@@ -15,7 +16,7 @@ from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_DEFAULT_QUEUE)
|
||||
def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
try:
|
||||
user = User.objects.get(email=invitor)
|
||||
|
||||
@@ -14,6 +14,39 @@ app = Celery("plane")
|
||||
# pickle the object when using Windows.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Add additional configurations
|
||||
CELERY_CONFIGURATIONS = {
|
||||
"worker_prefetch_multiplier": int(os.environ.get("WORKER_PREFETCH_MULTIPLIER", 1)),
|
||||
"worker_max_tasks_per_child": int(
|
||||
os.environ.get("WORKER_MAX_TASKS_PER_CHILD", 100)
|
||||
),
|
||||
"worker_max_memory_per_child": int(
|
||||
os.environ.get("WORKER_MAX_MEMORY_PER_CHILD", 150000)
|
||||
),
|
||||
"task_time_limit": int(os.environ.get("TASK_TIME_LIMIT", 3600)), # Hard time limit
|
||||
"task_soft_time_limit": int(
|
||||
os.environ.get("TASK_SOFT_TIME_LIMIT", 1800)
|
||||
), # Soft time limit (30 minutes)
|
||||
"worker_send_task_events": bool(
|
||||
os.environ.get("WORKER_SEND_TASK_EVENTS", "0") == "1"
|
||||
),
|
||||
"task_ignore_result": bool(
|
||||
os.environ.get("TASK_IGNORE_RESULT", "1") == "1"
|
||||
), # Ignore results unless explicitly needed
|
||||
"task_store_errors_even_if_ignored": bool(
|
||||
os.environ.get("TASK_STORE_ERRORS_EVEN_IF_IGNORED", "1") == "1"
|
||||
), # Store errors even if results are ignored
|
||||
"task_acks_late": bool(
|
||||
os.environ.get("TASK_ACKS_LATE", "1") == "1"
|
||||
), # Acknowledge tasks after completion
|
||||
"task_reject_on_worker_lost": bool(
|
||||
os.environ.get("TASK_REJECT_ON_WORKER_LOST", "1") == "1"
|
||||
), # Reject tasks if worker is lost
|
||||
}
|
||||
|
||||
app.conf.update(**CELERY_CONFIGURATIONS)
|
||||
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
# Executes every day at 12 AM
|
||||
"check-every-day-to-archive-and-close": {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from opentelemetry import trace
|
||||
@@ -19,7 +22,7 @@ from plane.db.models import (
|
||||
from plane.utils.telemetry import init_tracer, shutdown_tracer
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(queue=settings.TASK_LOW_QUEUE)
|
||||
def instance_traces():
|
||||
try:
|
||||
init_tracer()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Python imports
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from kombu import Queue, Exchange
|
||||
|
||||
# Third party imports
|
||||
import dj_database_url
|
||||
@@ -146,8 +146,20 @@ else:
|
||||
}
|
||||
}
|
||||
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST")
|
||||
REDIS_PORT = os.environ.get("REDIS_PORT", "6379")
|
||||
REDIS_USER = os.environ.get("REDIS_USER", "")
|
||||
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
|
||||
|
||||
# Redis Config
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
if os.environ.get("REDIS_URL"):
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
else:
|
||||
if not REDIS_HOST:
|
||||
raise Exception("REDIS_HOST is not set")
|
||||
REDIS_URL = f"redis://{REDIS_USER}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}" # noqa
|
||||
|
||||
# Check if redis url is ssl
|
||||
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
|
||||
|
||||
if REDIS_SSL:
|
||||
@@ -233,25 +245,79 @@ if AWS_S3_ENDPOINT_URL and USE_MINIO:
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
|
||||
# RabbitMQ connection settings
|
||||
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost")
|
||||
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST")
|
||||
RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT", "5672")
|
||||
RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest")
|
||||
RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD", "guest")
|
||||
RABBITMQ_VHOST = os.environ.get("RABBITMQ_VHOST", "/")
|
||||
AMQP_URL = os.environ.get("AMQP_URL")
|
||||
|
||||
if os.environ.get("AMQP_URL"):
|
||||
AMQP_URL = os.environ.get("AMQP_URL")
|
||||
else:
|
||||
if not RABBITMQ_HOST:
|
||||
raise Exception("RABBITMQ_HOST is not set")
|
||||
AMQP_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
|
||||
|
||||
# Celery Configuration
|
||||
if AMQP_URL:
|
||||
CELERY_BROKER_URL = AMQP_URL
|
||||
else:
|
||||
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
|
||||
|
||||
CELERY_BROKER_URL = AMQP_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_ACCEPT_CONTENT = ["application/json"]
|
||||
CELERY_RESULT_EXPIRES = int(
|
||||
os.environ.get("CELERY_RESULT_EXPIRES", 60 * 60 * 24)
|
||||
) # Expire results after 24 hours
|
||||
|
||||
|
||||
QUEUE_SUFFIX = os.environ.get("QUEUE_SUFFIX", "plane")
|
||||
|
||||
TASK_HIGH_QUEUE = f"{QUEUE_SUFFIX}_high"
|
||||
TASK_DEFAULT_QUEUE = f"{QUEUE_SUFFIX}_default"
|
||||
TASK_LOW_QUEUE = f"{QUEUE_SUFFIX}_low"
|
||||
TASK_SCHEDULER_QUEUE = f"{QUEUE_SUFFIX}_schedule"
|
||||
TASK_NOTIFICATION_QUEUE = f"{QUEUE_SUFFIX}_notification"
|
||||
|
||||
# Define queues with their exchanges
|
||||
CELERY_TASK_QUEUES = (
|
||||
Queue(
|
||||
TASK_HIGH_QUEUE,
|
||||
Exchange("high_exchange", type="direct"),
|
||||
routing_key="high.#",
|
||||
queue_arguments={"x-max-priority": 10, "x-queue-mode": "lazy"},
|
||||
),
|
||||
Queue(
|
||||
TASK_DEFAULT_QUEUE,
|
||||
Exchange("default_exchange", type="direct"),
|
||||
routing_key="default.#",
|
||||
queue_arguments={"x-max-priority": 5, "x-queue-mode": "lazy"},
|
||||
),
|
||||
Queue(
|
||||
TASK_LOW_QUEUE,
|
||||
Exchange("low_exchange", type="direct"),
|
||||
routing_key="low.#",
|
||||
queue_arguments={"x-max-priority": 1, "x-queue-mode": "lazy"},
|
||||
),
|
||||
Queue(
|
||||
TASK_SCHEDULER_QUEUE,
|
||||
Exchange("scheduled_exchange", type="direct"),
|
||||
routing_key="scheduled.#",
|
||||
queue_arguments={"x-max-priority": 3, "x-queue-mode": "lazy"},
|
||||
),
|
||||
Queue(
|
||||
TASK_NOTIFICATION_QUEUE,
|
||||
Exchange("notifications_exchange", type="direct"),
|
||||
routing_key="notifications.#",
|
||||
queue_arguments={"x-max-priority": 4, "x-queue-mode": "lazy"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Default queue settings
|
||||
CELERY_TASK_DEFAULT_QUEUE = "default"
|
||||
CELERY_TASK_DEFAULT_EXCHANGE = "default_exchange"
|
||||
CELERY_TASK_DEFAULT_ROUTING_KEY = "default.#"
|
||||
|
||||
CELERY_IMPORTS = (
|
||||
# scheduled tasks
|
||||
"plane.bgtasks.issue_automation_task",
|
||||
@@ -297,16 +363,10 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
|
||||
# Posthog settings
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
|
||||
|
||||
# instance key
|
||||
INSTANCE_KEY = os.environ.get(
|
||||
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
|
||||
)
|
||||
|
||||
# Skip environment variable configuration
|
||||
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
|
||||
curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
@@ -1,54 +1,63 @@
|
||||
x-app-env: &app-env
|
||||
environment:
|
||||
- NGINX_PORT=${NGINX_PORT:-80}
|
||||
- WEB_URL=${WEB_URL:-http://localhost}
|
||||
- DEBUG=${DEBUG:-0}
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
|
||||
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
|
||||
#DB SETTINGS
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-plane}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-plane}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
# REDIS SETTINGS
|
||||
- REDIS_HOST=${REDIS_HOST:-plane-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/}
|
||||
x-db-env: &db-env
|
||||
PGHOST: ${PGHOST:-plane-db}
|
||||
PGDATABASE: ${PGDATABASE:-plane}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-plane}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-plane}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
PGDATA: ${PGDATA:-/var/lib/postgresql/data}
|
||||
|
||||
x-redis-env: &redis-env
|
||||
REDIS_HOST: ${REDIS_HOST:-plane-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
|
||||
|
||||
x-minio-env: &minio-env
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
|
||||
x-aws-s3-env: &aws-s3-env
|
||||
AWS_REGION: ${AWS_REGION:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
x-proxy-env: &proxy-env
|
||||
NGINX_PORT: ${NGINX_PORT:-80}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
x-mq-env: &mq-env
|
||||
# RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
|
||||
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
|
||||
RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
|
||||
|
||||
x-live-env: &live-env
|
||||
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
||||
|
||||
x-app-env: &app-env
|
||||
WEB_URL: ${WEB_URL:-http://localhost}
|
||||
DEBUG: ${DEBUG:-0}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
GUNICORN_WORKERS: 1
|
||||
USE_MINIO: ${USE_MINIO:-1}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
|
||||
SPACE_BASE_URL: ${SPACE_BASE_URL}
|
||||
APP_BASE_URL: ${APP_BASE_URL}
|
||||
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
|
||||
# RabbitMQ Settings
|
||||
- RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq}
|
||||
- RABBITMQ_PORT=${RABBITMQ_PORT:-5672}
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane}
|
||||
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane}
|
||||
- RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane}
|
||||
- AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
# Application secret
|
||||
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
# DATA STORE SETTINGS
|
||||
- USE_MINIO=${USE_MINIO:-1}
|
||||
- AWS_REGION=${AWS_REGION:-}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
|
||||
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
|
||||
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
|
||||
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
|
||||
- BUCKET_NAME=${BUCKET_NAME:-uploads}
|
||||
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
|
||||
# Live server env
|
||||
- API_BASE_URL=${API_BASE_URL:-http://api:8000}
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -61,7 +70,6 @@ services:
|
||||
- worker
|
||||
|
||||
space:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -75,7 +83,6 @@ services:
|
||||
- web
|
||||
|
||||
admin:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -88,12 +95,13 @@ services:
|
||||
- web
|
||||
|
||||
live:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node live/dist/server.js live
|
||||
environment:
|
||||
<<: [ *live-env ]
|
||||
deploy:
|
||||
replicas: ${LIVE_REPLICAS:-1}
|
||||
depends_on:
|
||||
@@ -101,7 +109,6 @@ services:
|
||||
- web
|
||||
|
||||
api:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -111,14 +118,14 @@ services:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
volumes:
|
||||
- logs_api:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
- plane-mq
|
||||
|
||||
|
||||
worker:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -126,6 +133,8 @@ services:
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
volumes:
|
||||
- logs_worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -133,7 +142,6 @@ services:
|
||||
- plane-mq
|
||||
|
||||
beat-worker:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -141,6 +149,8 @@ services:
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
volumes:
|
||||
- logs_beat-worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -148,7 +158,6 @@ services:
|
||||
- plane-mq
|
||||
|
||||
migrator:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
@@ -156,21 +165,23 @@ services:
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
volumes:
|
||||
- logs_migrator:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-db:
|
||||
<<: *app-env
|
||||
image: postgres:15.7-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: postgres -c 'max_connections=1000'
|
||||
environment:
|
||||
<<: *db-env
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
plane-redis:
|
||||
<<: *app-env
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
@@ -178,30 +189,33 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
plane-mq:
|
||||
<<: *app-env
|
||||
image: rabbitmq:3.13.6-management-alpine
|
||||
restart: always
|
||||
environment:
|
||||
<<: *mq-env
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
|
||||
plane-minio:
|
||||
<<: *app-env
|
||||
image: minio/minio:latest
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: server /export --console-address ":9090"
|
||||
environment:
|
||||
<<: *minio-env
|
||||
volumes:
|
||||
- uploads:/export
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
<<: *app-env
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
|
||||
+97
-13
@@ -4,9 +4,12 @@ BRANCH=${BRANCH:-master}
|
||||
SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE="stable"
|
||||
export APP_RELEASE=stable
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export PULL_POLICY=${PULL_POLICY:-if_not_present}
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
|
||||
|
||||
CPU_ARCH=$(uname -m)
|
||||
OS_NAME=$(uname)
|
||||
@@ -16,13 +19,6 @@ mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
SED_PREFIX=()
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
SED_PREFIX=("-i" "")
|
||||
else
|
||||
SED_PREFIX=("-i")
|
||||
fi
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
@@ -59,6 +55,17 @@ function spinner() {
|
||||
printf " \b\b\b\b" >&2
|
||||
}
|
||||
|
||||
function checkLatestRelease(){
|
||||
echo "Checking for the latest release..." >&2
|
||||
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
|
||||
if [ -z "$latest_release" ]; then
|
||||
echo "Failed to check for the latest release. Exiting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo $latest_release
|
||||
}
|
||||
|
||||
function initialize(){
|
||||
printf "Please wait while we check the availability of Docker images for the selected release ($APP_RELEASE) with ${UPPER_CPU_ARCH} support." >&2
|
||||
|
||||
@@ -130,8 +137,12 @@ function updateEnvFile() {
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
# if key exists, update the value
|
||||
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
else
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
@@ -182,7 +193,7 @@ function buildYourOwnImage(){
|
||||
local PLANE_TEMP_CODE_DIR=~/tmp/plane
|
||||
rm -rf $PLANE_TEMP_CODE_DIR
|
||||
mkdir -p $PLANE_TEMP_CODE_DIR
|
||||
REPO=https://github.com/makeplane/plane.git
|
||||
REPO=https://github.com/$GH_REPO.git
|
||||
git clone "$REPO" "$PLANE_TEMP_CODE_DIR" --branch "$BRANCH" --single-branch --depth 1
|
||||
|
||||
cp "$PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml" "$PLANE_TEMP_CODE_DIR/build.yml"
|
||||
@@ -204,6 +215,10 @@ function install() {
|
||||
echo "Begin Installing Plane"
|
||||
echo ""
|
||||
|
||||
if [ "$APP_RELEASE" == "stable" ]; then
|
||||
export APP_RELEASE=$(checkLatestRelease)
|
||||
fi
|
||||
|
||||
local build_image=$(initialize)
|
||||
|
||||
if [ "$build_image" == "build" ]; then
|
||||
@@ -232,8 +247,49 @@ function download() {
|
||||
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
|
||||
fi
|
||||
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
|
||||
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
else
|
||||
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
echo "Failed to download variables.env. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml $PLANE_INSTALL_DIR/docker-compose.yaml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DOCKER_ENV_PATH" ];
|
||||
then
|
||||
@@ -335,6 +391,34 @@ function restartServices() {
|
||||
startServices
|
||||
}
|
||||
function upgrade() {
|
||||
local latest_release=$(checkLatestRelease)
|
||||
|
||||
echo ""
|
||||
echo "Current release: $APP_RELEASE"
|
||||
|
||||
if [ "$latest_release" == "$APP_RELEASE" ]; then
|
||||
echo ""
|
||||
echo "You are already using the latest release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Latest release: $latest_release"
|
||||
echo ""
|
||||
|
||||
# Check for confirmation to upgrade
|
||||
echo "Do you want to upgrade to the latest release ($latest_release)?"
|
||||
read -p "Continue? [y/N]: " confirm
|
||||
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export APP_RELEASE=$latest_release
|
||||
|
||||
echo "Upgrading Plane to the latest release..."
|
||||
echo ""
|
||||
|
||||
echo "***** STOPPING SERVICES ****"
|
||||
stopServices
|
||||
|
||||
|
||||
@@ -47,9 +47,6 @@ AWS_ACCESS_KEY_ID=access-key
|
||||
AWS_SECRET_ACCESS_KEY=secret-key
|
||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||
AWS_S3_BUCKET_NAME=uploads
|
||||
MINIO_ROOT_USER=access-key
|
||||
MINIO_ROOT_PASSWORD=secret-key
|
||||
BUCKET_NAME=uploads
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# Gunicorn Workers
|
||||
|
||||
@@ -3,6 +3,4 @@ export const DocumentCollaborativeEvents = {
|
||||
unlock: { client: "unlocked", server: "unlock" },
|
||||
archive: { client: "archived", server: "archive" },
|
||||
unarchive: { client: "unarchived", server: "unarchive" },
|
||||
"make-public": { client: "made-public", server: "make-public" },
|
||||
"make-private": { client: "made-private", server: "make-private" },
|
||||
} as const;
|
||||
|
||||
@@ -36,23 +36,19 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
onMouseEnter={handleActiveItem}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,8 +11,7 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
customContent?: React.ReactNode;
|
||||
title?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
|
||||
@@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
const closeDropdown = () => {
|
||||
if (isOpen) onMenuClose?.();
|
||||
isOpen && onMenuClose && onMenuClose();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick?.(e);
|
||||
onClick && onClick(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./editor";
|
||||
export * from "./modals";
|
||||
export * from "./extra-actions";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./move-page-modal";
|
||||
@@ -1,10 +0,0 @@
|
||||
// store types
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
export type TMovePageModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
page: IPage;
|
||||
};
|
||||
|
||||
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileOutput,
|
||||
Globe2,
|
||||
Link,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// components
|
||||
import { DeletePageModal } from "@/components/pages";
|
||||
// constants
|
||||
import { EPageAccess } from "@/constants/page";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// plane web components
|
||||
import { MovePageModal } from "@/plane-web/components/pages";
|
||||
// store types
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
export type TPageActions =
|
||||
| "full-screen"
|
||||
| "copy-markdown"
|
||||
| "toggle-lock"
|
||||
| "toggle-access"
|
||||
| "open-in-new-tab"
|
||||
| "copy-link"
|
||||
| "make-a-copy"
|
||||
| "archive-restore"
|
||||
| "delete"
|
||||
| "version-history"
|
||||
| "export"
|
||||
| "move";
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
|
||||
optionsOrder: TPageActions[];
|
||||
page: IPage;
|
||||
parentRef?: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const PageActions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, extraOptions, optionsOrder, page, parentRef } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [movePageModal, setMovePageModal] = useState(false);
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
editorRef,
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const {
|
||||
access,
|
||||
archived_at,
|
||||
is_locked,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserMovePage,
|
||||
} = page;
|
||||
// menu items
|
||||
const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "toggle-lock",
|
||||
action: pageOperations.toggleLock,
|
||||
title: is_locked ? "Unlock" : "Lock",
|
||||
icon: is_locked ? LockKeyholeOpen : LockKeyhole,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "toggle-access",
|
||||
action: pageOperations.toggleAccess,
|
||||
title: access === EPageAccess.PUBLIC ? "Make private" : "Make public",
|
||||
icon: access === EPageAccess.PUBLIC ? Lock : Globe2,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
action: pageOperations.openInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: pageOperations.copyLink,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: pageOperations.duplicate,
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: pageOperations.toggleArchive,
|
||||
title: !!archived_at ? "Restore" : "Archive",
|
||||
icon: !!archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeletePageModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
|
||||
{
|
||||
key: "move",
|
||||
action: () => setMovePageModal(true),
|
||||
title: "Move",
|
||||
icon: FileOutput,
|
||||
shouldRender: canCurrentUserMovePage,
|
||||
},
|
||||
],
|
||||
[
|
||||
access,
|
||||
archived_at,
|
||||
is_locked,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserMovePage,
|
||||
pageOperations,
|
||||
]
|
||||
);
|
||||
if (extraOptions) {
|
||||
MENU_ITEMS.push(...extraOptions);
|
||||
}
|
||||
// arrange options
|
||||
const arrangedOptions = useMemo(
|
||||
() =>
|
||||
optionsOrder
|
||||
.map((key) => MENU_ITEMS.find((item) => item.key === key))
|
||||
.filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[],
|
||||
[optionsOrder, MENU_ITEMS]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MovePageModal isOpen={movePageModal} onClose={() => setMovePageModal(false)} page={page} />
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={page.id ?? ""} />
|
||||
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
|
||||
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
|
||||
{arrangedOptions.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action?.();
|
||||
}}
|
||||
className={cn("flex items-center gap-2", item.className)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className="size-3" />}
|
||||
{item.title}
|
||||
</>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./actions";
|
||||
export * from "./edit-information-popover";
|
||||
export * from "./quick-actions";
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeletePageModal } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
page: IPage;
|
||||
pageLink: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { page, pageLink, parentRef } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
access,
|
||||
archive,
|
||||
archived_at,
|
||||
makePublic,
|
||||
makePrivate,
|
||||
restore,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
} = page;
|
||||
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(pageLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "make-public-private",
|
||||
action: async () => {
|
||||
const changedPageType = access === 0 ? "private" : "public";
|
||||
|
||||
try {
|
||||
if (access === 0) await makePrivate();
|
||||
else await makePublic();
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
title: access === 0 ? "Make private" : "Make public",
|
||||
icon: access === 0 ? Lock : UsersRound,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: archived_at ? restore : archive,
|
||||
title: archived_at ? "Restore" : "Archive",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeletePageModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={page.id ?? ""} />
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
{item.title}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -16,12 +16,14 @@ import useOnlineStatus from "@/hooks/use-online-status";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, page } = props;
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
|
||||
// derived values
|
||||
const {
|
||||
archived_at,
|
||||
@@ -83,8 +85,12 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
iconClassName="text-custom-text-100"
|
||||
/>
|
||||
)}
|
||||
<PageInfoPopover editorRef={editorRef} />
|
||||
<PageOptionsDropdown editorRef={editorRef} page={page} />
|
||||
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
|
||||
<PageOptionsDropdown
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
|
||||
@@ -9,34 +9,56 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorReady: boolean;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
|
||||
const {
|
||||
editorReady,
|
||||
editorRef,
|
||||
handleDuplicatePage,
|
||||
page,
|
||||
readOnlyEditorReady,
|
||||
readOnlyEditorRef,
|
||||
setSidePeekVisible,
|
||||
sidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
|
||||
if (!editorRef.current && !readOnlyEditorRef.current) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<div className="flex-shrink-0 my-auto">
|
||||
<PageSummaryPopover
|
||||
editorRef={editorRef}
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
isFullWidth={isFullWidth}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
/>
|
||||
</div>
|
||||
<PageExtraOptions editorRef={editorRef} page={page} />
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</Header>
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef as EditorRefApi} />}
|
||||
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
|
||||
<PageToolbar editorRef={editorRef?.current} />
|
||||
)}
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
ArrowUpToLine,
|
||||
Clipboard,
|
||||
Copy,
|
||||
History,
|
||||
Link,
|
||||
Lock,
|
||||
LockOpen,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
// document editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
|
||||
import { ExportPageModal } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// store
|
||||
@@ -20,74 +31,123 @@ import { IPage } from "@/store/pages/page";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, page } = props;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store values
|
||||
const { name } = page;
|
||||
const {
|
||||
name,
|
||||
archived_at,
|
||||
is_locked,
|
||||
id,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
} = page;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// page filters
|
||||
const { isFullWidth, handleFullWidth } = usePageFilters();
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// collaborative actions
|
||||
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
||||
|
||||
// menu items list
|
||||
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "full-screen",
|
||||
action: () => handleFullWidth(!isFullWidth),
|
||||
customContent: (
|
||||
<>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
action: () => void;
|
||||
label: string;
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
shouldRender: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
})
|
||||
);
|
||||
},
|
||||
title: "Copy markdown",
|
||||
icon: Clipboard,
|
||||
shouldRender: true,
|
||||
label: "Copy markdown",
|
||||
icon: Clipboard,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-page-link",
|
||||
action: () => {
|
||||
const pageLink = projectId
|
||||
? `${workspaceSlug?.toString()}/projects/${projectId?.toString()}/pages/${id}`
|
||||
: `${workspaceSlug?.toString()}/pages/${id}`;
|
||||
copyUrlToClipboard(pageLink).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page link copied to clipboard.",
|
||||
})
|
||||
);
|
||||
},
|
||||
{
|
||||
key: "version-history",
|
||||
action: () => {
|
||||
// add query param, version=current to the route
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { version: "current" },
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
},
|
||||
title: "Version history",
|
||||
icon: History,
|
||||
shouldRender: true,
|
||||
label: "Copy page link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: handleDuplicatePage,
|
||||
label: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "lock-unlock-page",
|
||||
action: is_locked
|
||||
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" })
|
||||
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }),
|
||||
label: is_locked ? "Unlock page" : "Lock page",
|
||||
icon: is_locked ? LockOpen : Lock,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore-page",
|
||||
action: archived_at
|
||||
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" })
|
||||
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }),
|
||||
label: archived_at ? "Restore page" : "Archive page",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "version-history",
|
||||
action: () => {
|
||||
// add query param, version=current to the route
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { version: "current" },
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
title: "Export",
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
],
|
||||
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
|
||||
);
|
||||
label: "Version history",
|
||||
icon: History,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
label: "Export",
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,23 +157,24 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<PageActions
|
||||
editorRef={editorRef}
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
"copy-markdown",
|
||||
"copy-link",
|
||||
"toggle-lock",
|
||||
"make-a-copy",
|
||||
"move",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
"version-history",
|
||||
"export",
|
||||
]}
|
||||
page={page}
|
||||
/>
|
||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
||||
<CustomMenu.MenuItem
|
||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
||||
onClick={() => handleFullWidth(!isFullWidth)}
|
||||
>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</CustomMenu.MenuItem>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IPage } from "@/store/pages/page";
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorReady: boolean;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
@@ -21,16 +22,22 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const { editorReady, editorRef, page, readOnlyEditorReady, readOnlyEditorRef, setSidePeekVisible, sidePeekVisible } =
|
||||
props;
|
||||
const {
|
||||
editorReady,
|
||||
editorRef,
|
||||
handleDuplicatePage,
|
||||
page,
|
||||
readOnlyEditorReady,
|
||||
readOnlyEditorRef,
|
||||
setSidePeekVisible,
|
||||
sidePeekVisible,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
// derived values
|
||||
const resolvedEditorRef = isContentEditable ? editorRef.current : readOnlyEditorRef.current;
|
||||
|
||||
if (!resolvedEditorRef) return null;
|
||||
if (!editorRef.current && !readOnlyEditorRef.current) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,11 +62,20 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageToolbar editorRef={editorRef?.current} />
|
||||
)}
|
||||
</Header.LeftItem>
|
||||
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</Header>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
editorRef={resolvedEditorRef}
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
|
||||
@@ -3,9 +3,14 @@ import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages";
|
||||
// hooks
|
||||
import { useProjectPages } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageFallback } from "@/hooks/use-page-fallback";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
@@ -37,8 +42,10 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
const router = useAppRouter();
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
// store hooks
|
||||
const { createPage } = useProjectPages();
|
||||
// derived values
|
||||
const { isContentEditable, updateDescription } = page;
|
||||
const { access, description_html, name, isContentEditable, updateDescription } = page;
|
||||
// page fallback
|
||||
usePageFallback({
|
||||
editorRef,
|
||||
@@ -52,6 +59,26 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
|
||||
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
const formData: Partial<TPage> = {
|
||||
name: "Copy of " + name,
|
||||
description_html: editorRef.current?.getDocument().html ?? description_html ?? "<p></p>",
|
||||
access,
|
||||
};
|
||||
|
||||
await handleCreatePage(formData)
|
||||
.then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`))
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be duplicated. Please try again later.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const version = searchParams.get("version");
|
||||
useEffect(() => {
|
||||
if (!version) {
|
||||
@@ -108,6 +135,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
<PageEditorHeaderRoot
|
||||
editorReady={editorReady}
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
|
||||
@@ -4,34 +4,60 @@ import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Earth, Info, Lock, Minus } from "lucide-react";
|
||||
// ui
|
||||
import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
|
||||
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageActions } from "@/components/pages";
|
||||
import { PageQuickActions } from "@/components/pages/dropdowns";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember, usePage } from "@/hooks/store";
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
const { pageId, parentRef } = props;
|
||||
const { workspaceSlug, projectId, pageId, parentRef } = props;
|
||||
// store hooks
|
||||
const page = usePage(pageId);
|
||||
const { getUserDetails } = useMember();
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const { access, created_at, is_favorite, owned_by, canCurrentUserFavoritePage } = page;
|
||||
const {
|
||||
access,
|
||||
created_at,
|
||||
is_favorite,
|
||||
owned_by,
|
||||
canCurrentUserFavoritePage,
|
||||
addToFavorites,
|
||||
removePageFromFavorites,
|
||||
} = page;
|
||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
|
||||
// handlers
|
||||
const handleFavorites = () => {
|
||||
if (is_favorite) {
|
||||
removePageFromFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page removed from favorites.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addToFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page added to favorites.",
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* page details */}
|
||||
@@ -61,25 +87,17 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pageOperations.toggleFavorite();
|
||||
handleFavorites();
|
||||
}}
|
||||
selected={is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageActions
|
||||
optionsOrder={[
|
||||
"toggle-lock",
|
||||
"toggle-access",
|
||||
"open-in-new-tab",
|
||||
"copy-link",
|
||||
"make-a-copy",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
]}
|
||||
page={page}
|
||||
<PageQuickActions
|
||||
parentRef={parentRef}
|
||||
page={page}
|
||||
pageLink={`${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,9 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => {
|
||||
}
|
||||
title={getPageName(name)}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
|
||||
actionableItems={<BlockItemAction pageId={pageId} parentRef={parentRef} />}
|
||||
actionableItems={
|
||||
<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />
|
||||
}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
|
||||
@@ -14,13 +14,7 @@ type CollaborativeActionEvent =
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
page: IPage;
|
||||
};
|
||||
|
||||
export const useCollaborativePageActions = (props: Props) => {
|
||||
const { editorRef, page } = props;
|
||||
export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => {
|
||||
// currentUserAction local state to track if the current action is being processed, a
|
||||
// local action is basically the action performed by the current user to avoid double operations
|
||||
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||
@@ -43,14 +37,6 @@ export const useCollaborativePageActions = (props: Props) => {
|
||||
execute: (shouldSync) => page.restore(shouldSync),
|
||||
errorMessage: "Page could not be restored. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-public"].client]: {
|
||||
execute: (shouldSync) => page.makePublic(shouldSync),
|
||||
errorMessage: "Page could not be made public. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-private"].client]: {
|
||||
execute: (shouldSync) => page.makePrivate(shouldSync),
|
||||
errorMessage: "Page could not be made private. Please try again later.",
|
||||
},
|
||||
}),
|
||||
[page]
|
||||
);
|
||||
@@ -78,7 +64,6 @@ export const useCollaborativePageActions = (props: Props) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef) return;
|
||||
if (currentActionBeingProcessed) {
|
||||
const serverEventName = getServerEventName(currentActionBeingProcessed);
|
||||
if (serverEventName) {
|
||||
@@ -88,12 +73,9 @@ export const useCollaborativePageActions = (props: Props) => {
|
||||
}, [currentActionBeingProcessed, editorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef) return;
|
||||
|
||||
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
|
||||
console.log(realTimeStatelessMessageListener);
|
||||
|
||||
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
|
||||
console.log("aaa", message);
|
||||
if (currentActionBeingProcessed === message.payload) {
|
||||
setCurrentActionBeingProcessed(null);
|
||||
return;
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||
// store types
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
export type TPageOperations = {
|
||||
toggleLock: () => void;
|
||||
toggleAccess: () => void;
|
||||
toggleFavorite: () => void;
|
||||
openInNewTab: () => void;
|
||||
copyLink: () => void;
|
||||
duplicate: () => void;
|
||||
toggleArchive: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
page: IPage;
|
||||
};
|
||||
|
||||
export const usePageOperations = (
|
||||
props: Props
|
||||
): {
|
||||
pageOperations: TPageOperations;
|
||||
} => {
|
||||
const { page } = props;
|
||||
// params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// derived values
|
||||
const {
|
||||
access,
|
||||
addToFavorites,
|
||||
archived_at,
|
||||
duplicate,
|
||||
id,
|
||||
is_favorite,
|
||||
is_locked,
|
||||
makePrivate,
|
||||
makePublic,
|
||||
removePageFromFavorites,
|
||||
} = page;
|
||||
// collaborative actions
|
||||
const { executeCollaborativeAction } = useCollaborativePageActions(props);
|
||||
// page operations
|
||||
const pageOperations: TPageOperations = useMemo(() => {
|
||||
const pageLink = projectId ? `${workspaceSlug}/projects/${projectId}/pages/${id}` : `${workspaceSlug}/pages/${id}`;
|
||||
|
||||
return {
|
||||
copyLink: () => {
|
||||
copyUrlToClipboard(pageLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
duplicate: async () => {
|
||||
try {
|
||||
await duplicate();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page duplicated successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be duplicated. Please try again later.",
|
||||
});
|
||||
}
|
||||
},
|
||||
move: async () => {},
|
||||
openInNewTab: () => window.open(`/${pageLink}`, "_blank"),
|
||||
toggleAccess: async () => {
|
||||
const changedPageType = access === 0 ? "private" : "public";
|
||||
try {
|
||||
if (access === 0) await makePrivate();
|
||||
else await makePublic();
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
toggleArchive: async () => {
|
||||
if (archived_at) {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page restored successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be restored. Please try again later.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page archived successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be archived. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleFavorite: () => {
|
||||
if (is_favorite) {
|
||||
removePageFromFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page removed from favorites.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addToFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page added to favorites.",
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleLock: async () => {
|
||||
if (is_locked) {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page unlocked successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be unlocked. Please try again later.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page locked successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be locked. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [
|
||||
access,
|
||||
addToFavorites,
|
||||
archived_at,
|
||||
duplicate,
|
||||
executeCollaborativeAction,
|
||||
id,
|
||||
is_favorite,
|
||||
is_locked,
|
||||
makePrivate,
|
||||
makePublic,
|
||||
projectId,
|
||||
removePageFromFavorites,
|
||||
workspaceSlug,
|
||||
]);
|
||||
return {
|
||||
pageOperations,
|
||||
};
|
||||
};
|
||||
@@ -158,12 +158,4 @@ export class ProjectPageService extends APIService {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async duplicate(workspaceSlug: string, projectId: string, pageId: string): Promise<TPage> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/duplicate/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface IPage extends TPage {
|
||||
canCurrentUserArchivePage: boolean;
|
||||
canCurrentUserDeletePage: boolean;
|
||||
canCurrentUserFavoritePage: boolean;
|
||||
canCurrentUserMovePage: boolean;
|
||||
isContentEditable: boolean;
|
||||
// helpers
|
||||
oldName: string;
|
||||
@@ -33,8 +32,8 @@ export interface IPage extends TPage {
|
||||
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
makePublic: (shouldSync?: boolean) => Promise<void>;
|
||||
makePrivate: (shouldSync?: boolean) => Promise<void>;
|
||||
makePublic: () => Promise<void>;
|
||||
makePrivate: () => Promise<void>;
|
||||
lock: (shouldSync?: boolean) => Promise<void>;
|
||||
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||
archive: (shouldSync?: boolean) => Promise<void>;
|
||||
@@ -42,7 +41,6 @@ export interface IPage extends TPage {
|
||||
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
||||
addToFavorites: () => Promise<void>;
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
duplicate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class Page implements IPage {
|
||||
@@ -135,7 +133,6 @@ export class Page implements IPage {
|
||||
canCurrentUserArchivePage: computed,
|
||||
canCurrentUserDeletePage: computed,
|
||||
canCurrentUserFavoritePage: computed,
|
||||
canCurrentUserMovePage: computed,
|
||||
isContentEditable: computed,
|
||||
// actions
|
||||
update: action,
|
||||
@@ -150,7 +147,6 @@ export class Page implements IPage {
|
||||
updatePageLogo: action,
|
||||
addToFavorites: action,
|
||||
removePageFromFavorites: action,
|
||||
duplicate: action,
|
||||
});
|
||||
|
||||
this.pageService = new ProjectPageService();
|
||||
@@ -300,19 +296,6 @@ export class Page implements IPage {
|
||||
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the current logged in user can move the page
|
||||
*/
|
||||
get canCurrentUserMovePage() {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
|
||||
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
|
||||
workspaceSlug?.toString() || "",
|
||||
projectId?.toString() || ""
|
||||
);
|
||||
return this.isCurrentUserOwner || currentUserProjectRole === EUserPermissions.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the page can be edited
|
||||
*/
|
||||
@@ -415,48 +398,44 @@ export class Page implements IPage {
|
||||
/**
|
||||
* @description make the page public
|
||||
*/
|
||||
makePublic = async (shouldSync: boolean = true) => {
|
||||
makePublic = async () => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => (this.access = EPageAccess.PUBLIC));
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
|
||||
access: EPageAccess.PUBLIC,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
|
||||
access: EPageAccess.PUBLIC,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description make the page private
|
||||
*/
|
||||
makePrivate = async (shouldSync: boolean = true) => {
|
||||
makePrivate = async () => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => (this.access = EPageAccess.PRIVATE));
|
||||
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,13 +588,4 @@ export class Page implements IPage {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description duplicate the page
|
||||
*/
|
||||
duplicate = async () => {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !this.id) return undefined;
|
||||
await this.pageService.duplicate(workspaceSlug, projectId, this.id);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ type TLoader = "init-loader" | "mutation-loader" | undefined;
|
||||
|
||||
type TError = { title: string; description: string };
|
||||
|
||||
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [EUserPermissions.ADMIN, EUserPermissions.MEMBER];
|
||||
|
||||
export interface IProjectPageStore {
|
||||
// observables
|
||||
loader: TLoader;
|
||||
@@ -44,7 +42,6 @@ export interface IProjectPageStore {
|
||||
getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (pageId: string) => Promise<void>;
|
||||
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectPageStore implements IProjectPageStore {
|
||||
@@ -79,7 +76,6 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
getPageById: action,
|
||||
createPage: action,
|
||||
removePage: action,
|
||||
movePage: action,
|
||||
});
|
||||
this.rootStore = store;
|
||||
// service
|
||||
@@ -111,7 +107,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
workspaceSlug?.toString() || "",
|
||||
projectId?.toString() || ""
|
||||
);
|
||||
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
|
||||
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,13 +292,4 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page to a new project
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} projectId
|
||||
* @param {string} pageId
|
||||
* @param {string} newProjectId
|
||||
*/
|
||||
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user