Compare commits

..

15 Commits

Author SHA1 Message Date
Aaryan Khandelwal d52b7475be chore: add sync for page access cahnge 2024-12-16 16:35:44 +05:30
Aaryan Khandelwal 383fb56361 fix: merge conflicts resolved from preview 2024-12-16 13:29:18 +05:30
Aaryan Khandelwal 3b6892d42a fix: merge conflicts resolved from preview 2024-11-22 14:23:14 +05:30
Aaryan Khandelwal 8806a67d08 fix: auth for access change and delete 2024-11-21 15:10:44 +05:30
Aaryan Khandelwal 9bcbf2466d chore: add type assertion 2024-11-21 15:04:19 +05:30
Aaryan Khandelwal f0a41bdd14 chore: use enum for page access 2024-11-21 14:18:51 +05:30
Aaryan Khandelwal 84acc608cc Merge branch 'preview' of https://github.com/makeplane/plane into dev/page-options 2024-11-21 14:16:55 +05:30
Aaryan Khandelwal c060024919 chore: memoize arranged options 2024-11-21 14:16:48 +05:30
Aaryan Khandelwal 436e4aca73 chore: add permisssions to duplicate page endpoint 2024-11-20 13:47:05 +05:30
Aaryan Khandelwal 8416b48daf refactor: remove unnecessary props 2024-11-20 13:28:54 +05:30
Aaryan Khandelwal 1314d3dd9c dev: hook to get page operations 2024-11-20 13:22:36 +05:30
Aaryan Khandelwal 835440f3e0 fix: type errors 2024-11-20 13:07:10 +05:30
Aaryan Khandelwal bb00042bff chore: add customizable page actions 2024-11-20 13:03:27 +05:30
Aaryan Khandelwal 9ceb91c207 refactor: page quick actions 2024-11-18 17:33:15 +05:30
Aaryan Khandelwal d238bac387 dev: support for edition specific options in pages 2024-11-14 12:55:27 +05:30
32 changed files with 793 additions and 631 deletions
+4
View File
@@ -54,6 +54,8 @@ 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
@@ -62,6 +64,8 @@ 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,
+6
View File
@@ -8,6 +8,7 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@@ -78,4 +79,9 @@ 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",
),
]
+1
View File
@@ -155,6 +155,7 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint
+32
View File
@@ -121,6 +121,8 @@ 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>"),
},
)
@@ -553,3 +555,33 @@ 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 -1
View File
@@ -62,7 +62,7 @@ mkdir plane-selfhost
cd plane-selfhost
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
curl -fsSL -o setup.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/install.sh
chmod +x setup.sh
```
+59 -73
View File
@@ -1,63 +1,54 @@
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}
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/}
# 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
@@ -70,6 +61,7 @@ services:
- worker
space:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -83,6 +75,7 @@ services:
- web
admin:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -95,13 +88,12 @@ 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:
@@ -109,6 +101,7 @@ services:
- web
api:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -118,14 +111,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
@@ -133,8 +126,6 @@ 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
@@ -142,6 +133,7 @@ services:
- plane-mq
beat-worker:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -149,8 +141,6 @@ 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
@@ -158,6 +148,7 @@ services:
- plane-mq
migrator:
<<: *app-env
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
@@ -165,23 +156,21 @@ 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
@@ -189,33 +178,30 @@ 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
+13 -97
View File
@@ -4,12 +4,9 @@ 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)
@@ -19,6 +16,13 @@ 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
@@ -55,17 +59,6 @@ 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
@@ -137,12 +130,8 @@ function updateEnvFile() {
echo "$key=$value" >> "$file"
return
else
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
# if key exists, update the value
sed "${SED_PREFIX[@]}" "s/^$key=.*/$key=$value/g" "$file"
fi
else
echo "File not found: $file"
@@ -193,7 +182,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/$GH_REPO.git
REPO=https://github.com/makeplane/plane.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"
@@ -215,10 +204,6 @@ 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
@@ -247,49 +232,8 @@ function download() {
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
fi
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
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)
if [ -f "$DOCKER_ENV_PATH" ];
then
@@ -391,34 +335,6 @@ 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
+3
View File
@@ -47,6 +47,9 @@ 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,4 +3,6 @@ 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;
+17 -13
View File
@@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{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.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>
</>
)}
</button>
);
};
@@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
export type TContextMenuItem = {
key: string;
title: string;
customContent?: React.ReactNode;
title?: string;
description?: string;
icon?: React.FC<any>;
action: () => void;
+2 -2
View File
@@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => {
isOpen && onMenuClose && onMenuClose();
if (isOpen) onMenuClose?.();
setIsOpen(false);
};
@@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
)}
onClick={(e) => {
close();
onClick && onClick(e);
onClick?.(e);
}}
disabled={disabled}
>
+1
View File
@@ -1,2 +1,3 @@
export * from "./editor";
export * from "./modals";
export * from "./extra-actions";
+1
View File
@@ -0,0 +1 @@
export * from "./move-page-modal";
@@ -0,0 +1,10 @@
// store types
import { IPage } from "@/store/pages/page";
export type TMovePageModalProps = {
isOpen: boolean;
onClose: () => void;
page: IPage;
};
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;
@@ -0,0 +1,195 @@
"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 -1
View File
@@ -1,2 +1,2 @@
export * from "./actions";
export * from "./edit-information-popover";
export * from "./quick-actions";
@@ -1,131 +0,0 @@
"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,14 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status";
import { IPage } from "@/store/pages/page";
type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
const { editorRef, page } = props;
// derived values
const {
archived_at,
@@ -85,12 +83,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}
page={page}
/>
<PageInfoPopover editorRef={editorRef} />
<PageOptionsDropdown editorRef={editorRef} page={page} />
</div>
);
});
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
@@ -9,56 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters";
import { IPage } from "@/store/pages/page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorRef, page, 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={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
editorRef={editorRef}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
/>
<PageExtraOptions editorRef={editorRef} page={page} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef as EditorRefApi} />}
</Header>
</>
);
@@ -1,29 +1,18 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import {
ArchiveRestoreIcon,
ArrowUpToLine,
Clipboard,
Copy,
History,
Link,
Lock,
LockOpen,
LucideIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal } from "@/components/pages";
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
// helpers
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
import { copyTextToClipboard } 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
@@ -31,123 +20,74 @@ 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, handleDuplicatePage, page } = props;
const { editorRef, page } = props;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// router
const router = useRouter();
// store values
const {
name,
archived_at,
is_locked,
id,
canCurrentUserArchivePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// store hooks
const { workspaceSlug, projectId } = useParams();
const { name } = page;
// page filters
const { isFullWidth, handleFullWidth } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
// collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
// menu items list
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.",
})
);
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",
},
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: "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 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: "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: "Version history",
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
label: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
];
{
key: "export",
action: () => setIsExportModalOpen(true),
title: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
],
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
);
return (
<>
@@ -157,24 +97,23 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
onClose={() => setIsExportModalOpen(false)}
pageTitle={name ?? ""}
/>
<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>
<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}
/>
</>
);
});
@@ -13,7 +13,6 @@ import { IPage } from "@/store/pages/page";
type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
@@ -22,22 +21,16 @@ type Props = {
};
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorReady, editorRef, 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 (!editorRef.current && !readOnlyEditorRef.current) return null;
if (!resolvedEditorRef) return null;
return (
<>
@@ -62,20 +55,11 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
<PageToolbar editorRef={editorRef?.current} />
)}
</Header.LeftItem>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
/>
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
</Header>
<div className="md:hidden">
<PageEditorMobileHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
editorRef={resolvedEditorRef}
page={page}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
+1 -29
View File
@@ -3,14 +3,9 @@ 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";
@@ -42,10 +37,8 @@ export const PageRoot = observer((props: TPageRootProps) => {
const router = useAppRouter();
// search params
const searchParams = useSearchParams();
// store hooks
const { createPage } = useProjectPages();
// derived values
const { access, description_html, name, isContentEditable, updateDescription } = page;
const { isContentEditable, updateDescription } = page;
// page fallback
usePageFallback({
editorRef,
@@ -59,26 +52,6 @@ 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) {
@@ -135,7 +108,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
<PageEditorHeaderRoot
editorReady={editorReady}
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorReady={readOnlyEditorReady}
readOnlyEditorRef={readOnlyEditorRef}
@@ -4,60 +4,34 @@ import React, { FC } from "react";
import { observer } from "mobx-react";
import { Earth, Info, Lock, Minus } from "lucide-react";
// ui
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
// components
import { PageQuickActions } from "@/components/pages/dropdowns";
import { PageActions } from "@/components/pages";
// 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 { workspaceSlug, projectId, pageId, parentRef } = props;
const { 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,
addToFavorites,
removePageFromFavorites,
} = page;
const { access, created_at, is_favorite, owned_by, canCurrentUserFavoritePage } = 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 */}
@@ -87,17 +61,25 @@ export const BlockItemAction: FC<Props> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleFavorites();
pageOperations.toggleFavorite();
}}
selected={is_favorite}
/>
)}
{/* quick actions dropdown */}
<PageQuickActions
parentRef={parentRef}
<PageActions
optionsOrder={[
"toggle-lock",
"toggle-access",
"open-in-new-tab",
"copy-link",
"make-a-copy",
"archive-restore",
"delete",
]}
page={page}
pageLink={`${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
parentRef={parentRef}
/>
</>
);
+1 -3
View File
@@ -40,9 +40,7 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => {
}
title={getPageName(name)}
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
actionableItems={
<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />
}
actionableItems={<BlockItemAction pageId={pageId} parentRef={parentRef} />}
isMobile={isMobile}
parentRef={parentRef}
/>
@@ -14,7 +14,13 @@ type CollaborativeActionEvent =
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => {
type Props = {
editorRef?: EditorRefApi | EditorReadOnlyRefApi | null;
page: IPage;
};
export const useCollaborativePageActions = (props: Props) => {
const { editorRef, page } = props;
// 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);
@@ -37,6 +43,14 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
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]
);
@@ -64,6 +78,7 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
);
useEffect(() => {
if (!editorRef) return;
if (currentActionBeingProcessed) {
const serverEventName = getServerEventName(currentActionBeingProcessed);
if (serverEventName) {
@@ -73,9 +88,12 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
}, [currentActionBeingProcessed, editorRef]);
useEffect(() => {
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
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;
+207
View File
@@ -0,0 +1,207 @@
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,
};
};
+5 -2
View File
@@ -225,11 +225,14 @@ export class Storage {
const response = await issueService.getIssuesForSync(this.workspaceSlug, projectId, queryParams);
await addIssuesBulk(response.results, BATCH_SIZE);
if (response.total_pages > 1) {
const promiseArray = [];
for (let i = 1; i < response.total_pages; i++) {
queryParams.cursor = `${PAGE_SIZE}:${i}:0`;
const page = await issueService.getIssuesForSync(this.workspaceSlug, projectId, queryParams);
promiseArray.push(issueService.getIssuesForSync(this.workspaceSlug, projectId, queryParams));
}
const pages = await Promise.all(promiseArray);
for (const page of pages) {
await addIssuesBulk(page.results, BATCH_SIZE);
}
}
+21 -16
View File
@@ -13,12 +13,16 @@ export const createIssueIndexes = async () => {
"sequence_id",
];
await persistence.db.exec({ sql: `CREATE UNIQUE INDEX issues_issue_id_idx ON issues (id)` });
const promises: Promise<any>[] = [];
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
await persistence.db.exec({ sql: `CREATE INDEX issues_issue_${column}_idx ON issues (project_id, ${column})` });
}
promises.push(persistence.db.exec({ sql: `CREATE UNIQUE INDEX issues_issue_id_idx ON issues (id)` }));
columns.forEach((column) => {
promises.push(
persistence.db.exec({ sql: `CREATE INDEX issues_issue_${column}_idx ON issues (project_id, ${column})` })
);
});
await Promise.all(promises);
};
export const createIssueMetaIndexes = async () => {
@@ -27,32 +31,33 @@ export const createIssueMetaIndexes = async () => {
};
export const createWorkSpaceIndexes = async () => {
const promises: Promise<any>[] = [];
// Labels
await persistence.db.exec({ sql: `CREATE INDEX labels_name_idx ON labels (id,name,project_id)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX labels_name_idx ON labels (id,name,project_id)` }));
// Modules
await persistence.db.exec({ sql: `CREATE INDEX modules_name_idx ON modules (id,name,project_id)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX modules_name_idx ON modules (id,name,project_id)` }));
// States
await persistence.db.exec({ sql: `CREATE INDEX states_name_idx ON states (id,name,project_id)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX states_name_idx ON states (id,name,project_id)` }));
// Cycles
await persistence.db.exec({ sql: `CREATE INDEX cycles_name_idx ON cycles (id,name,project_id)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX cycles_name_idx ON cycles (id,name,project_id)` }));
// Members
await persistence.db.exec({ sql: `CREATE INDEX members_name_idx ON members (id,first_name)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX members_name_idx ON members (id,first_name)` }));
// Estimate Points @todo
await persistence.db.exec({ sql: `CREATE INDEX estimate_points_name_idx ON estimate_points (id,value)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX estimate_points_name_idx ON estimate_points (id,value)` }));
// Options
await persistence.db.exec({ sql: `CREATE INDEX options_key_idx ON options (key)` });
promises.push(persistence.db.exec({ sql: `CREATE INDEX options_key_idx ON options (key)` }));
await Promise.all(promises);
};
const createIndexes = async () => {
log("### Creating indexes");
const start = performance.now();
const promises = [createIssueIndexes(), createIssueMetaIndexes(), createWorkSpaceIndexes()];
try {
await createIssueIndexes();
await createIssueMetaIndexes();
await createWorkSpaceIndexes();
await Promise.all(promises);
} catch (e) {
console.log((e as Error).message);
}
@@ -158,4 +158,12 @@ 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;
});
}
}
+52 -22
View File
@@ -23,6 +23,7 @@ export interface IPage extends TPage {
canCurrentUserArchivePage: boolean;
canCurrentUserDeletePage: boolean;
canCurrentUserFavoritePage: boolean;
canCurrentUserMovePage: boolean;
isContentEditable: boolean;
// helpers
oldName: string;
@@ -32,8 +33,8 @@ export interface IPage extends TPage {
update: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
updateTitle: (title: string) => void;
updateDescription: (document: TDocumentPayload) => Promise<void>;
makePublic: () => Promise<void>;
makePrivate: () => Promise<void>;
makePublic: (shouldSync?: boolean) => Promise<void>;
makePrivate: (shouldSync?: boolean) => Promise<void>;
lock: (shouldSync?: boolean) => Promise<void>;
unlock: (shouldSync?: boolean) => Promise<void>;
archive: (shouldSync?: boolean) => Promise<void>;
@@ -41,6 +42,7 @@ 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 {
@@ -133,6 +135,7 @@ export class Page implements IPage {
canCurrentUserArchivePage: computed,
canCurrentUserDeletePage: computed,
canCurrentUserFavoritePage: computed,
canCurrentUserMovePage: computed,
isContentEditable: computed,
// actions
update: action,
@@ -147,6 +150,7 @@ export class Page implements IPage {
updatePageLogo: action,
addToFavorites: action,
removePageFromFavorites: action,
duplicate: action,
});
this.pageService = new ProjectPageService();
@@ -296,6 +300,19 @@ 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
*/
@@ -398,44 +415,48 @@ export class Page implements IPage {
/**
* @description make the page public
*/
makePublic = async () => {
makePublic = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PUBLIC));
try {
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
access: EPageAccess.PUBLIC,
});
} catch (error) {
runInAction(() => {
this.access = pageAccess;
});
throw error;
if (shouldSync) {
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 () => {
makePrivate = async (shouldSync: boolean = true) => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;
const pageAccess = this.access;
runInAction(() => (this.access = EPageAccess.PRIVATE));
try {
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
access: EPageAccess.PRIVATE,
});
} catch (error) {
runInAction(() => {
this.access = pageAccess;
});
throw error;
if (shouldSync) {
try {
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
access: EPageAccess.PRIVATE,
});
} catch (error) {
runInAction(() => {
this.access = pageAccess;
});
throw error;
}
}
};
@@ -588,4 +609,13 @@ 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);
};
}
+14 -1
View File
@@ -18,6 +18,8 @@ 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;
@@ -42,6 +44,7 @@ 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 {
@@ -76,6 +79,7 @@ export class ProjectPageStore implements IProjectPageStore {
getPageById: action,
createPage: action,
removePage: action,
movePage: action,
});
this.rootStore = store;
// service
@@ -107,7 +111,7 @@ export class ProjectPageStore implements IProjectPageStore {
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
}
/**
@@ -292,4 +296,13 @@ 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) => {};
}