mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
3 Commits
dev/pages-
...
chore/proj
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5129fc4c8 | ||
|
|
d18979b673 | ||
|
|
2ea5077e6a |
@@ -29,7 +29,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"react-hook-form": "7.51.5",
|
||||
"swr": "^2.2.4",
|
||||
"tailwindcss": "3.3.2",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -47,4 +47,4 @@
|
||||
"tsconfig": "*",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from plane.app.permissions import (
|
||||
from plane.app.serializers import (
|
||||
IssueFlatSerializer,
|
||||
IssueSerializer,
|
||||
IssueDetailSerializer
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
@@ -46,6 +46,7 @@ from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
@@ -341,8 +342,10 @@ class BulkArchiveIssuesEndpoint(BaseAPIView):
|
||||
if issue.state.group not in ["completed", "cancelled"]:
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4091,
|
||||
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
|
||||
"error_code": ERROR_CODES[
|
||||
"INVALID_ARCHIVE_STATE_GROUP"
|
||||
],
|
||||
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
|
||||
|
||||
class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
@@ -59,14 +60,20 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
|
||||
properties = request.data.get("properties", {})
|
||||
|
||||
if properties.get("start_date", False) and properties.get("target_date", False):
|
||||
if properties.get("start_date", False) and properties.get(
|
||||
"target_date", False
|
||||
):
|
||||
if (
|
||||
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
|
||||
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
|
||||
datetime.strptime(
|
||||
properties.get("start_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
> datetime.strptime(
|
||||
properties.get("target_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4100,
|
||||
"error_code": ERROR_CODES["INVALID_ISSUE_DATES"],
|
||||
"error_message": "INVALID_ISSUE_DATES",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -124,7 +131,9 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4101,
|
||||
"error_code": ERROR_CODES[
|
||||
"INVALID_ISSUE_START_DATE"
|
||||
],
|
||||
"error_message": "INVALID_ISSUE_START_DATE",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -158,7 +167,9 @@ class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4102,
|
||||
"error_code": ERROR_CODES[
|
||||
"INVALID_ISSUE_TARGET_DATE"
|
||||
],
|
||||
"error_message": "INVALID_ISSUE_TARGET_DATE",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -33,7 +33,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
)
|
||||
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
||||
@@ -500,14 +500,20 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
|
||||
if page.is_locked:
|
||||
return Response(
|
||||
{"error": "Page is locked"},
|
||||
status=471,
|
||||
{
|
||||
"error_code": ERROR_CODES["PAGE_LOCKED"],
|
||||
"error_message": "PAGE_LOCKED",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if page.archived_at:
|
||||
return Response(
|
||||
{"error": "Page is archived"},
|
||||
status=472,
|
||||
{
|
||||
"error_code": ERROR_CODES["PAGE_ARCHIVED"],
|
||||
"error_message": "PAGE_ARCHIVED",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Serialize the existing instance
|
||||
@@ -535,6 +541,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
# Store the updated binary data
|
||||
page.description_binary = new_binary_data
|
||||
page.description_html = request.data.get("description_html")
|
||||
page.description = request.data.get("description")
|
||||
page.save()
|
||||
# Return a success response
|
||||
page_version.delay(
|
||||
|
||||
10
apiserver/plane/utils/error_codes.py
Normal file
10
apiserver/plane/utils/error_codes.py
Normal file
@@ -0,0 +1,10 @@
|
||||
ERROR_CODES = {
|
||||
# issues
|
||||
"INVALID_ARCHIVE_STATE_GROUP": 4091,
|
||||
"INVALID_ISSUE_DATES": 4100,
|
||||
"INVALID_ISSUE_START_DATE": 4101,
|
||||
"INVALID_ISSUE_TARGET_DATE": 4102,
|
||||
# pages
|
||||
"PAGE_LOCKED": 4701,
|
||||
"PAGE_ARCHIVED": 4702,
|
||||
}
|
||||
@@ -86,6 +86,20 @@ services:
|
||||
- worker
|
||||
- web
|
||||
|
||||
live:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./live/Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./live:/app/live
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./apiserver
|
||||
|
||||
1
live/.env.example
Normal file
1
live/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
API_BASE_URL="http://api:8000"
|
||||
28
live/Dockerfile.channel
Normal file
28
live/Dockerfile.channel
Normal file
@@ -0,0 +1,28 @@
|
||||
# Use a Node.js base image
|
||||
FROM node:latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY ./channel/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install
|
||||
|
||||
# COPY ./plane/editor-core ./node_modules/@plane/editor-core
|
||||
|
||||
# Install TypeScript
|
||||
RUN yarn add typescript
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY ./channel .
|
||||
|
||||
# Compile TypeScript to JavaScript
|
||||
RUN npx tsc
|
||||
|
||||
# Expose port 3003
|
||||
EXPOSE 3003
|
||||
|
||||
# Start the Node.js server
|
||||
CMD ["node", "dist/index.js"]
|
||||
13
live/Dockerfile.dev
Normal file
13
live/Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3003
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/channel/node_modules"]
|
||||
CMD ["yarn","dev", "--filter=live"]
|
||||
42
live/package.json
Normal file
42
live/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.22.0",
|
||||
"description": "",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsup --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"type": "module",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "^2.11.3",
|
||||
"@hocuspocus/extension-logger": "^2.11.3",
|
||||
"@hocuspocus/server": "^2.11.3",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/html": "^2.3.0",
|
||||
"axios": "^1.7.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-ws": "^5.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"y-prosemirror": "^1.2.9",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-ws": "^3.0.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"tsup": "^7.2.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
61
live/src/authentication.ts
Normal file
61
live/src/authentication.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConnectionConfiguration } from "@hocuspocus/server";
|
||||
// services
|
||||
import { UserService } from "./services/user.service.js";
|
||||
// types
|
||||
import { TDocumentTypes } from "./types/common.js";
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
connection: ConnectionConfiguration;
|
||||
cookie: string;
|
||||
params: URLSearchParams;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const handleAuthentication = async (props: Props) => {
|
||||
const { connection, cookie, params, token } = props;
|
||||
// params
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
// fetch current user info
|
||||
let response;
|
||||
try {
|
||||
response = await userService.currentUser(cookie);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
if (response.id !== token) {
|
||||
throw Error("Authentication failed: Token doesn't match the current user.");
|
||||
}
|
||||
|
||||
if (documentType === "project_page") {
|
||||
if (!workspaceSlug || !projectId) {
|
||||
throw Error(
|
||||
"Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing.",
|
||||
);
|
||||
}
|
||||
// fetch current user's roles
|
||||
const workspaceRoles = await userService.getUserAllProjectsRole(
|
||||
workspaceSlug,
|
||||
cookie,
|
||||
);
|
||||
const currentProjectRole = workspaceRoles[projectId];
|
||||
// make the connection read only for roles lower than a member
|
||||
if (currentProjectRole < 15) {
|
||||
connection.readOnly = true;
|
||||
}
|
||||
} else {
|
||||
throw Error("Authentication failed: Invalid document type provided.");
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: response.id,
|
||||
name: response.display_name,
|
||||
},
|
||||
};
|
||||
};
|
||||
101
live/src/index.ts
Normal file
101
live/src/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import express from "express";
|
||||
import expressWs, { Application } from "express-ws";
|
||||
// page actions
|
||||
import { fetchPageDescriptionBinary, updatePageDescription } from "./page.js";
|
||||
// types
|
||||
import { TDocumentTypes } from "./types/common.js";
|
||||
// helpers
|
||||
import { handleAuthentication } from "./authentication.js";
|
||||
|
||||
const server = Server.configure({
|
||||
onAuthenticate: async ({
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connection,
|
||||
// user id used as token for authentication
|
||||
token,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// params
|
||||
const params = requestParameters;
|
||||
|
||||
if (!cookie) {
|
||||
throw Error("Credentials not provided");
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
connection,
|
||||
cookie,
|
||||
params,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
extensions: [
|
||||
new Logger(),
|
||||
new Database({
|
||||
fetch: async ({
|
||||
documentName: pageId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
if (documentType === "project_page") {
|
||||
const fetchedData = await fetchPageDescriptionBinary(
|
||||
params,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
resolve(fetchedData);
|
||||
}
|
||||
});
|
||||
},
|
||||
store: async ({
|
||||
state,
|
||||
documentName: pageId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
}) => {
|
||||
// request headers
|
||||
const cookie = requestHeaders.cookie?.toString();
|
||||
// query params
|
||||
const params = requestParameters;
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
|
||||
return new Promise(async () => {
|
||||
if (documentType === "project_page") {
|
||||
await updatePageDescription(params, pageId, state, cookie);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { app }: { app: Application } = expressWs(express());
|
||||
|
||||
app.get("/health", (_request, response) => {
|
||||
response.status(200);
|
||||
});
|
||||
|
||||
app.ws("/collaboration", (websocket, request) => {
|
||||
server.handleConnection(websocket, request);
|
||||
});
|
||||
|
||||
app.listen(3003);
|
||||
144
live/src/page.ts
Normal file
144
live/src/page.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
prosemirrorJSONToYDoc,
|
||||
yXmlFragmentToProseMirrorRootNode,
|
||||
} from "y-prosemirror";
|
||||
// editor
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@plane/editor/lib";
|
||||
// services
|
||||
import { PageService } from "./services/page.service.js";
|
||||
const pageService = new PageService();
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
...CoreEditorExtensionsWithoutProps,
|
||||
...DocumentEditorExtensionsWithoutProps,
|
||||
];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const updatePageDescription = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
updatedDescription: Uint8Array,
|
||||
cookie: string | undefined,
|
||||
) => {
|
||||
if (!(updatedDescription instanceof Uint8Array)) {
|
||||
throw new Error(
|
||||
"Invalid updatedDescription: must be an instance of Uint8Array",
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
// encode binary description data
|
||||
const base64Data = Buffer.from(updatedDescription).toString("base64");
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, updatedDescription);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema,
|
||||
).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
description_binary: base64Data,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
|
||||
await pageService.updateDescription(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
payload,
|
||||
cookie,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
) => {
|
||||
if (!workspaceSlug || !projectId || !cookie) return;
|
||||
|
||||
try {
|
||||
const pageDetails = await pageService.fetchDetails(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
// convert already existing html to json
|
||||
const contentJSON = generateJSON(
|
||||
pageDetails.description_html ?? "<p></p>",
|
||||
DOCUMENT_EDITOR_EXTENSIONS,
|
||||
);
|
||||
// get editor schema from the DOCUMENT_EDITOR_EXTENSIONS array
|
||||
const schema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert json to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(
|
||||
schema,
|
||||
contentJSON,
|
||||
"default",
|
||||
);
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return encodedData;
|
||||
} catch (error) {
|
||||
console.error("Error while transforming from HTML to Uint8Array", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPageDescriptionBinary = async (
|
||||
params: URLSearchParams,
|
||||
pageId: string,
|
||||
cookie: string | undefined,
|
||||
) => {
|
||||
const workspaceSlug = params.get("workspaceSlug")?.toString();
|
||||
const projectId = params.get("projectId")?.toString();
|
||||
if (!workspaceSlug || !projectId || !cookie) return null;
|
||||
|
||||
try {
|
||||
const response = await pageService.fetchDescriptionBinary(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
const binaryData = new Uint8Array(response);
|
||||
|
||||
if (binaryData.byteLength === 0) {
|
||||
const binary = await fetchDescriptionHTMLAndTransform(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
if (binary) {
|
||||
return binary;
|
||||
}
|
||||
}
|
||||
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
console.error("Fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
46
live/src/services/api.service.ts
Normal file
46
live/src/services/api.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
export const API_BASE_URL = process.env.API_BASE_URL ?? "";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
put(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
patch(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: any, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request(config = {}) {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
78
live/src/services/page.service.ts
Normal file
78
live/src/services/page.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "./api.service.js";
|
||||
|
||||
export class PageService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async fetchDetails(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
): Promise<TPage> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
},
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateDescription(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
data: {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
},
|
||||
cookie: string,
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
46
live/src/services/user.service.ts
Normal file
46
live/src/services/user.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// types
|
||||
import type { IUser, IUserProjectsRole } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "./api.service.js";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
currentUserConfig() {
|
||||
return {
|
||||
url: `${this.baseURL}/api/users/me/`,
|
||||
};
|
||||
}
|
||||
|
||||
async currentUser(cookie: string): Promise<IUser> {
|
||||
return this.get("/api/users/me/", {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async getUserAllProjectsRole(
|
||||
workspaceSlug: string,
|
||||
cookie: string,
|
||||
): Promise<IUserProjectsRole> {
|
||||
return this.get(
|
||||
`/api/users/me/workspaces/${workspaceSlug}/project-roles/`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
1
live/src/types/common.d.ts
vendored
Normal file
1
live/src/types/common.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export type TDocumentTypes = "project_page";
|
||||
25
live/tsconfig.json
Normal file
25
live/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": true,
|
||||
"outDir": "dist",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"removeComments": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
},
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
}
|
||||
11
live/tsup.config.ts
Normal file
11
live/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
@@ -7,6 +7,7 @@
|
||||
"web",
|
||||
"space",
|
||||
"admin",
|
||||
"live",
|
||||
"packages/editor",
|
||||
"packages/eslint-config-custom",
|
||||
"packages/tailwind-config-custom",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
},
|
||||
"./lib": {
|
||||
"require": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.mts",
|
||||
"import": "./dist/lib.mjs",
|
||||
"module": "./dist/lib.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -29,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array, source?: string) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
document: new Y.Doc(),
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
private initialSync = false;
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
|
||||
this.indexeddbProvider.on("synced", () => {
|
||||
this.configuration.hasIndexedDBSynced = true;
|
||||
});
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
public hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 0;
|
||||
}
|
||||
|
||||
private incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
}
|
||||
|
||||
public setSynced() {
|
||||
this.resetUnsyncedChanges();
|
||||
}
|
||||
|
||||
public async hasIndexedDBSynced() {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
return this.configuration.hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
async documentUpdateHandler(_update: Uint8Array, origin: any) {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
const stateVector = Y.encodeStateAsUpdate(this.document);
|
||||
|
||||
if (!this.initialSync) {
|
||||
this.configuration.onChange?.(stateVector, "initialSync");
|
||||
this.initialSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuration.onChange?.(stateVector);
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
getUpdateFromIndexedDB(): Uint8Array {
|
||||
const update = Y.encodeStateAsUpdate(this.document);
|
||||
return update;
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
this.document.off("update", this.documentUpdateHandler);
|
||||
this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./collaboration-provider";
|
||||
@@ -1,35 +1,16 @@
|
||||
import React, { useState } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useDocumentEditor } from "@/hooks/use-document-editor";
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
|
||||
interface IDocumentEditor {
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
}
|
||||
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
@@ -39,10 +20,10 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
tabIndex,
|
||||
value,
|
||||
user,
|
||||
} = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
@@ -52,20 +33,30 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// use document editor
|
||||
const { editor, isIndexedDbSynced } = useDocumentEditor({
|
||||
const { editor } = useCollaborativeEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
value,
|
||||
onChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
@@ -74,7 +65,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor || !isIndexedDbSynced) return null;
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
@@ -86,10 +77,12 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
);
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
|
||||
|
||||
export { DocumentEditorWithRef };
|
||||
export { CollaborativeDocumentEditorWithRef };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
user,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { editor } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
return <PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
};
|
||||
|
||||
const CollaborativeDocumentReadOnlyEditorWithRef = forwardRef<
|
||||
EditorReadOnlyRefApi,
|
||||
ICollaborativeDocumentReadOnlyEditor
|
||||
>((props, ref) => (
|
||||
<CollaborativeDocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
CollaborativeDocumentReadOnlyEditorWithRef.displayName = "CollaborativeDocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { CollaborativeDocumentReadOnlyEditorWithRef };
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
|
||||
|
||||
/**
|
||||
* @description return an object with contentJSON and editorSchema
|
||||
* @description contentJSON- ProseMirror JSON from HTML content
|
||||
* @description editorSchema- editor schema from extensions
|
||||
* @param {string} html
|
||||
* @returns {object} {contentJSON, editorSchema}
|
||||
*/
|
||||
export const generateJSONfromHTMLForDocumentEditor = (html: string) => {
|
||||
const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()];
|
||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||
const editorSchema = getSchema(extensions as Extensions);
|
||||
return {
|
||||
contentJSON,
|
||||
editorSchema,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./editor";
|
||||
export * from "./collaborative-editor";
|
||||
export * from "./collaborative-read-only-editor";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
export * from "./helpers";
|
||||
|
||||
@@ -7,58 +7,43 @@ import { IssueWidget } from "@/extensions";
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// plane web types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
editorClassName?: string;
|
||||
embedHandler: TEmbedConfig;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditor } from "@/types";
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
initialValue,
|
||||
editorClassName,
|
||||
mentionHandler,
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
extensions: [
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler?.issue.widgetCallback,
|
||||
}),
|
||||
],
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
if (!editor) return null;
|
||||
return <PageRenderer editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
|
||||
115
packages/editor/src/core/extensions/code/without-props.tsx
Normal file
115
packages/editor/src/core/extensions/code/without-props.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
// components
|
||||
import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use ProseMirror's insertText transaction to insert the tab character
|
||||
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtStart = $from.parentOffset === 0;
|
||||
|
||||
if (!isAtStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if codeBlock is the first node
|
||||
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
|
||||
|
||||
if (isFirstNode) {
|
||||
// Insert a new paragraph at the start of the document and move the cursor to it
|
||||
return editor.commands.command(({ tr }) => {
|
||||
const node = editor.schema.nodes.paragraph.create();
|
||||
tr.insert(0, node);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(1)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
lowlight,
|
||||
defaultLanguage: "plaintext",
|
||||
exitOnTripleEnter: false,
|
||||
});
|
||||
@@ -3,28 +3,20 @@ import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionWithoutProps,
|
||||
CustomQuoteExtension,
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = () => [
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
@@ -53,7 +45,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
class: "my-4 border-custom-border-400",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
@@ -65,7 +56,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
CustomTypographyExtension,
|
||||
ImageExtensionWithoutProps().configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
@@ -84,20 +74,13 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CustomCodeBlockExtensionWithoutProps,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
@@ -136,7 +136,7 @@ export const CustomLinkExtension = Mark.create<LinkOptions>({
|
||||
{
|
||||
tag: "a[href]",
|
||||
getAttrs: (node) => {
|
||||
if (typeof node === "string" || !(node instanceof HTMLElement)) {
|
||||
if (typeof node === "string") {
|
||||
return null;
|
||||
}
|
||||
const href = node.getAttribute("href")?.toLowerCase() || "";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];
|
||||
@@ -8,7 +8,6 @@ export * from "./mentions";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
export * from "./document-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./drag-drop";
|
||||
export * from "./drop";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MentionList, MentionNodeView } from "@/extensions";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomMentionOptions, MentionNodeView } from "@/extensions";
|
||||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMentionWithoutProps = () =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
@@ -31,9 +35,6 @@ export const CustomMentionWithoutProps = () =>
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNodeView);
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface EditorClassNames {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import * as Y from "yjs";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// extensions
|
||||
import { DragAndDrop, IssueWidget } from "@/extensions";
|
||||
import { DragAndDrop } from "@/extensions";
|
||||
// hooks
|
||||
import { TFileHandler, useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// plane editor provider
|
||||
import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TRealtimeConfig, TUserDetails } from "@/types";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
type CollaborativeEditorProps = {
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
embedHandler?: TEmbedConfig;
|
||||
extensions?: Extensions;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
@@ -27,61 +28,57 @@ type DocumentEditorProps = {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
tabIndex?: number;
|
||||
value: Uint8Array;
|
||||
user: TUserDetails;
|
||||
};
|
||||
|
||||
export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
export const useCollaborativeEditor = (props: CollaborativeEditorProps) => {
|
||||
const {
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
value,
|
||||
user,
|
||||
} = props;
|
||||
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new CollaborationProvider({
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
onChange,
|
||||
parameters: realtimeConfig.queryParams,
|
||||
// using user id as a token to verify the user on the server
|
||||
token: user.id,
|
||||
url: realtimeConfig.url,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id]
|
||||
[id, realtimeConfig, user.id]
|
||||
);
|
||||
|
||||
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
|
||||
|
||||
// update document on value change from server
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
if (value.length > 0) {
|
||||
Y.applyUpdate(provider.document, value);
|
||||
}
|
||||
}, [value, provider.document, id]);
|
||||
|
||||
// watch for indexedDb to complete syncing, only after which the editor is
|
||||
// rendered
|
||||
useLayoutEffect(() => {
|
||||
async function checkIndexDbSynced() {
|
||||
const hasSynced = await provider.hasIndexedDBSynced();
|
||||
setIndexedDbIsSynced(hasSynced);
|
||||
}
|
||||
checkIndexDbSynced();
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
setIndexedDbIsSynced(false);
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider]);
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
@@ -94,22 +91,18 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
DragAndDrop(setHideDragHandleFunction),
|
||||
embedHandler?.issue &&
|
||||
IssueWidget({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
...(extensions ?? []),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
}),
|
||||
],
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced };
|
||||
return { editor };
|
||||
};
|
||||
@@ -9,8 +9,6 @@ import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// plane editor providers
|
||||
import { CollaborationProvider } from "@/plane-editor/providers";
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
@@ -47,7 +45,6 @@ export interface CustomEditorProps {
|
||||
};
|
||||
onChange?: (json: object, html: string) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
provider?: CollaborationProvider;
|
||||
tabIndex?: number;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
@@ -67,10 +64,14 @@ export const useEditor = ({
|
||||
mentionHandler,
|
||||
onChange,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
}: CustomEditorProps) => {
|
||||
// states
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
const editor = useCustomEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps(editorClassName),
|
||||
@@ -108,14 +109,6 @@ export const useEditor = ({
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
});
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
|
||||
// Inside your component or hook
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
savedSelectionRef.current = savedSelection;
|
||||
@@ -202,18 +195,6 @@ export const useEditor = ({
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
setSynced: () => {
|
||||
if (provider) {
|
||||
provider.setSynced();
|
||||
}
|
||||
},
|
||||
hasUnsyncedChanges: () => {
|
||||
if (provider) {
|
||||
return provider.hasUnsyncedChanges();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TRealtimeConfig, TUserDetails } from "@/types";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "./use-read-only-editor";
|
||||
|
||||
type ReadOnlyCollaborativeEditorProps = {
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
user: TUserDetails;
|
||||
};
|
||||
|
||||
export const useReadOnlyCollaborativeEditor = (props: ReadOnlyCollaborativeEditorProps) => {
|
||||
const {
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
extensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
realtimeConfig,
|
||||
user,
|
||||
} = props;
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
url: realtimeConfig.url,
|
||||
name: id,
|
||||
token: user.id,
|
||||
parameters: realtimeConfig.queryParams,
|
||||
}),
|
||||
[id, realtimeConfig, user.id]
|
||||
);
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
editorProps,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
extensions: [
|
||||
...(extensions ?? []),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced: true };
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { CoreReadOnlyEditorProps } from "@/props";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
initialValue: string;
|
||||
initialValue?: string;
|
||||
editorClassName: string;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
|
||||
@@ -3,8 +3,9 @@ import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// hooks
|
||||
import { TFileHandler } from "@/hooks/use-editor";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TEditorCommands } from "@/types";
|
||||
import { IMentionHighlight, IMentionSuggestion, TEditorCommands, TEmbedConfig } from "@/types";
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
getHTML: () => string;
|
||||
@@ -20,10 +21,9 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
setSynced: () => void;
|
||||
hasUnsyncedChanges: () => boolean;
|
||||
}
|
||||
|
||||
// editor props
|
||||
export interface IEditorProps {
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
@@ -48,6 +48,16 @@ export interface IRichTextEditor extends IEditorProps {
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ICollaborativeDocumentEditor
|
||||
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
// read only editor props
|
||||
export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
@@ -56,9 +66,34 @@ export interface IReadOnlyEditorProps {
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export interface ILiteTextReadOnlyEditor extends IReadOnlyEditorProps {}
|
||||
|
||||
export interface IRichTextReadOnlyEditor extends IReadOnlyEditorProps {}
|
||||
|
||||
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export type TUserDetails = {
|
||||
color: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "src/ce/providers";
|
||||
@@ -7,7 +7,8 @@ import "src/styles/drag-drop.css";
|
||||
|
||||
// editors
|
||||
export {
|
||||
DocumentEditorWithRef,
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
CollaborativeDocumentReadOnlyEditorWithRef,
|
||||
DocumentReadOnlyEditorWithRef,
|
||||
LiteTextEditorWithRef,
|
||||
LiteTextReadOnlyEditorWithRef,
|
||||
@@ -19,7 +20,6 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/components/editors/document/helpers";
|
||||
export * from "@/helpers/editor-commands";
|
||||
export * from "@/helpers/yjs";
|
||||
export * from "@/extensions/table/table";
|
||||
|
||||
1
packages/editor/src/lib.ts
Normal file
1
packages/editor/src/lib.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@/extensions/core-without-props";
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
entry: ["src/index.ts", "src/lib.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
clean: true,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
|
||||
9
packages/types/src/cycle/cycle.d.ts
vendored
9
packages/types/src/cycle/cycle.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||
import type {TIssue, IIssueFilterOptions} from "@plane/types";
|
||||
|
||||
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
|
||||
|
||||
@@ -61,6 +61,10 @@ export type TProgressSnapshot = {
|
||||
estimate_distribution?: TCycleEstimateDistribution;
|
||||
};
|
||||
|
||||
export interface IProjectDetails {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ICycle extends TProgressSnapshot {
|
||||
progress_snapshot: TProgressSnapshot | undefined;
|
||||
|
||||
@@ -85,6 +89,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
filters: IIssueFilterOptions;
|
||||
};
|
||||
workspace_id: string;
|
||||
project_detail: IProjectDetails;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
@@ -102,7 +107,7 @@ export interface CycleIssueResponse {
|
||||
}
|
||||
|
||||
export type SelectCycleType =
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
|
||||
| undefined;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
|
||||
27
packages/types/src/workspace.d.ts
vendored
27
packages/types/src/workspace.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
import {EUserWorkspaceRoles} from "@/constants/workspace";
|
||||
import type {
|
||||
ICycle,
|
||||
IProjectMember,
|
||||
IUser,
|
||||
IUserLite,
|
||||
@@ -46,7 +47,7 @@ export interface IWorkspaceMemberInvitation {
|
||||
}
|
||||
|
||||
export interface IWorkspaceBulkInviteFormData {
|
||||
emails: { email: string; role: EUserWorkspaceRoles }[];
|
||||
emails: {email: string; role: EUserWorkspaceRoles}[];
|
||||
}
|
||||
|
||||
export type Properties = {
|
||||
@@ -197,3 +198,25 @@ export interface IProductUpdateResponse {
|
||||
eyes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IWorkspaceActiveCyclesResponse {
|
||||
count: number;
|
||||
extra_stats: null;
|
||||
next_cursor: string;
|
||||
next_page_results: boolean;
|
||||
prev_cursor: string;
|
||||
prev_page_results: boolean;
|
||||
results: ICycle[];
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceProgressResponse {
|
||||
completed_issues: number;
|
||||
total_issues: number;
|
||||
started_issues: number;
|
||||
cancelled_issues: number;
|
||||
unstarted_issues: number;
|
||||
}
|
||||
export interface IWorkspaceAnalyticsResponse {
|
||||
completion_chart: any;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
|
||||
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
@@ -49,6 +50,7 @@
|
||||
"@storybook/react": "^8.1.1",
|
||||
"@storybook/react-webpack5": "^8.1.1",
|
||||
"@storybook/test": "^8.1.1",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-color": "^3.0.9",
|
||||
@@ -63,7 +65,7 @@
|
||||
"tailwind-config-custom": "*",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^5.10.1",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/ui/tsup.config.ts
Normal file
11
packages/ui/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
1
setup.sh
1
setup.sh
@@ -9,6 +9,7 @@ cp ./web/.env.example ./web/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./admin/.env.example ./admin/.env
|
||||
cp ./live/.env.example ./live/.env
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
|
||||
@@ -40,7 +40,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-popper": "^2.3.0",
|
||||
"swr": "^2.2.2",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
@@ -63,4 +63,4 @@
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"NEXT_PUBLIC_SPACE_BASE_URL",
|
||||
"NEXT_PUBLIC_SPACE_BASE_PATH",
|
||||
"NEXT_PUBLIC_WEB_BASE_URL",
|
||||
"NEXT_PUBLIC_LIVE_BASE_URL",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FileText } from "lucide-react";
|
||||
// types
|
||||
import { TLogoProps } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// helpers
|
||||
@@ -29,11 +29,9 @@ export const PageDetailsHeader = observer(() => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
|
||||
const { name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
|
||||
// use platform
|
||||
const { isMobile, platform } = usePlatformOS();
|
||||
// derived values
|
||||
const isMac = platform === "MacOS";
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const handlePageLogoUpdate = async (data: TLogoProps) => {
|
||||
if (data) {
|
||||
@@ -157,25 +155,6 @@ export const PageDetailsHeader = observer(() => {
|
||||
</div>
|
||||
</div>
|
||||
<PageDetailsHeaderExtraActions />
|
||||
{isContentEditable && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// ctrl/cmd + s to save the changes
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "s",
|
||||
ctrlKey: !isMac,
|
||||
metaKey: isMac,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="flex-shrink-0 w-24"
|
||||
loading={isSubmitting === "submitting"}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
cycles: {
|
||||
property: "cycle_view",
|
||||
title: "Cycles",
|
||||
description: "Time-box issues and boost momentum, similar to sprints in scrum.",
|
||||
description: "Timebox work as you see fit per project and change frequency from one period to the next.",
|
||||
icon: <ContrastIcon className="h-5 w-5 flex-shrink-0 rotate-180 text-custom-text-300" />,
|
||||
isPro: false,
|
||||
isEnabled: true,
|
||||
@@ -35,7 +35,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
modules: {
|
||||
property: "module_view",
|
||||
title: "Modules",
|
||||
description: "Group multiple issues together and track the progress.",
|
||||
description: "Group work into sub-project-like set-ups with their own leads and assignees.",
|
||||
icon: <DiceIcon width={20} height={20} className="flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: false,
|
||||
isEnabled: true,
|
||||
@@ -43,7 +43,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
views: {
|
||||
property: "issue_views_view",
|
||||
title: "Views",
|
||||
description: "Apply filters to issues and save them to analyse and investigate work.",
|
||||
description: "Save sorts, filters, and display options for later or share them.",
|
||||
icon: <Layers className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: false,
|
||||
isEnabled: true,
|
||||
@@ -51,7 +51,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
pages: {
|
||||
property: "page_view",
|
||||
title: "Pages",
|
||||
description: "Document ideas, feature requirements, discussions within your project.",
|
||||
description: "Write anything like you write anything.",
|
||||
icon: <FileText className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: false,
|
||||
isEnabled: true,
|
||||
@@ -59,7 +59,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
inbox: {
|
||||
property: "inbox_view",
|
||||
title: "Intake",
|
||||
description: "Capture external inputs, move valid issues to workflow.",
|
||||
description: "Consider and discuss issues before you add them to your project.",
|
||||
icon: <Intake className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: false,
|
||||
isEnabled: true,
|
||||
@@ -67,12 +67,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
|
||||
},
|
||||
},
|
||||
project_others: {
|
||||
title: "Others",
|
||||
title: "Work Management",
|
||||
featureList: {
|
||||
is_time_tracking_enabled: {
|
||||
property: "is_time_tracking_enabled",
|
||||
title: "Time Tracking",
|
||||
description: "Keep the work logs of the users in track ",
|
||||
description: "Log time, see timesheets, and download full CSVs for your entire workspace.",
|
||||
icon: <Timer className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
|
||||
isPro: true,
|
||||
isEnabled: false,
|
||||
|
||||
@@ -30,11 +30,12 @@ import useLocalStorage from "@/hooks/use-local-storage";
|
||||
export type ActiveCycleStatsProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
cycle: ICycle | null;
|
||||
cycleId?: string | null;
|
||||
};
|
||||
|
||||
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycle } = props;
|
||||
const { workspaceSlug, projectId, cycle, cycleId } = props;
|
||||
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
|
||||
|
||||
@@ -63,22 +64,29 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
|
||||
workspaceSlug && projectId && cycle.id
|
||||
? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle.id)
|
||||
: null,
|
||||
workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId, { priority: "urgent,high" }) : null,
|
||||
workspaceSlug && projectId && cycleId ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycleId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const cycleIssueDetails = getActiveCycleById(cycle.id);
|
||||
const cycleIssueDetails = cycleId ? getActiveCycleById(cycleId) : { nextPageResults: false };
|
||||
|
||||
const loadMoreIssues = useCallback(() => {
|
||||
fetchNextActiveCycleIssues(workspaceSlug, projectId, cycle.id);
|
||||
}, [workspaceSlug, projectId, cycle.id, issuesLoaderElement, cycleIssueDetails?.nextPageResults]);
|
||||
if (!cycleId) return;
|
||||
fetchNextActiveCycleIssues(workspaceSlug, projectId, cycleId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceSlug, projectId, cycleId, issuesLoaderElement, cycleIssueDetails?.nextPageResults]);
|
||||
|
||||
useIntersectionObserver(issuesContainerRef, issuesLoaderElement, loadMoreIssues, `0% 0% 100% 0%`);
|
||||
|
||||
return (
|
||||
const loaders = (
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</Loader>
|
||||
);
|
||||
return cycleId ? (
|
||||
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
|
||||
<Tab.Group
|
||||
as={Fragment}
|
||||
@@ -154,7 +162,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
ref={issuesContainerRef}
|
||||
className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycleIssueDetails && cycleIssueDetails.issueIds ? (
|
||||
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
|
||||
cycleIssueDetails.issueCount > 0 ? (
|
||||
<>
|
||||
{cycleIssueDetails.issueIds.map((issueId: string) => {
|
||||
@@ -229,11 +237,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
loaders
|
||||
)}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
@@ -242,44 +246,52 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
|
||||
cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
{cycle ? (
|
||||
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
|
||||
cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
|
||||
<span>{assignee.display_name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
<span>{assignee.display_name}</span>
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE}
|
||||
layout="screen-simple"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||
</div>
|
||||
loaders
|
||||
)}
|
||||
</Tab.Panel>
|
||||
|
||||
@@ -287,33 +299,41 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
|
||||
cycle.distribution.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))
|
||||
{cycle ? (
|
||||
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
|
||||
cycle.distribution.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||
</div>
|
||||
loaders
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex flex-col gap-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1">
|
||||
<Loader.Item width="100%" height="17rem" />
|
||||
</Loader>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC, Fragment, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ICycle, TCyclePlotType } from "@plane/types";
|
||||
import { CustomSelect, Spinner } from "@plane/ui";
|
||||
import { CustomSelect, Loader, Spinner } from "@plane/ui";
|
||||
// components
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
@@ -15,7 +15,7 @@ import { EEstimateSystem } from "@/plane-web/constants/estimates";
|
||||
export type ActiveCycleProductivityProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
cycle: ICycle | null;
|
||||
};
|
||||
|
||||
const cycleBurnDownChartOptions = [
|
||||
@@ -51,10 +51,11 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
|
||||
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
|
||||
|
||||
const chartDistributionData = plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
|
||||
const chartDistributionData =
|
||||
cycle && plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
|
||||
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||
|
||||
return (
|
||||
return cycle ? (
|
||||
<div className="flex flex-col justify-center min-h-[17rem] gap-5 px-3.5 py-4 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||
<div className="relative flex items-center justify-between gap-4 -mt-7">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
|
||||
@@ -135,5 +136,9 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||
<Loader.Item width="100%" height="100%" />
|
||||
</Loader>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
// types
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { LinearProgressIndicator } from "@plane/ui";
|
||||
import { LinearProgressIndicator, Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
@@ -15,7 +15,7 @@ import { EmptyStateType } from "@/constants/empty-state";
|
||||
export type ActiveCycleProgressProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycle: ICycle;
|
||||
cycle: ICycle | null;
|
||||
};
|
||||
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
@@ -24,18 +24,20 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
name: group.title,
|
||||
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
|
||||
value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
|
||||
color: group.color,
|
||||
}));
|
||||
|
||||
const groupedIssues: any = {
|
||||
completed: cycle.completed_issues,
|
||||
started: cycle.started_issues,
|
||||
unstarted: cycle.unstarted_issues,
|
||||
backlog: cycle.backlog_issues,
|
||||
};
|
||||
const groupedIssues: any = cycle
|
||||
? {
|
||||
completed: cycle.completed_issues,
|
||||
started: cycle.started_issues,
|
||||
unstarted: cycle.unstarted_issues,
|
||||
backlog: cycle.backlog_issues,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
return cycle ? (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
|
||||
className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
|
||||
@@ -94,5 +96,9 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
|
||||
<Loader.Item width="100%" height="100%" />
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// store hooks
|
||||
const { fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
|
||||
const { currentProjectActiveCycle, fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
|
||||
// derived values
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
// fetch active cycle details
|
||||
@@ -38,7 +38,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
);
|
||||
|
||||
// show loader if active cycle is loading
|
||||
if (!activeCycle && isLoading)
|
||||
if (!currentProjectActiveCycle && isLoading)
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item height="250px" />
|
||||
@@ -54,10 +54,10 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
<CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
{!activeCycle ? (
|
||||
{!currentProjectActiveCycle ? (
|
||||
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
|
||||
) : (
|
||||
<div className="flex flex-col bg-custom-background-90 border-b">
|
||||
<div className="flex flex-col bg-custom-background-90">
|
||||
{currentProjectActiveCycleId && (
|
||||
<CyclesListItem
|
||||
key={currentProjectActiveCycleId}
|
||||
@@ -75,7 +75,12 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
<ActiveCycleStats workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
|
||||
<ActiveCycleStats
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
cycleId={currentProjectActiveCycleId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,10 +182,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.description_html) setValue("description_html", data?.description_html);
|
||||
if (data?.description_html) setValue("description_html" as const, data?.description_html);
|
||||
}, [data?.description_html]);
|
||||
|
||||
const issueName = watch("name");
|
||||
const issueName = watch("name" as const);
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
|
||||
// Check if the editor is ready to discard
|
||||
@@ -327,7 +327,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={(response) => {
|
||||
setValue("label_ids", [...watch("label_ids"), response.id]);
|
||||
setValue("label_ids" as const, [...watch("label_ids"), response.id]);
|
||||
handleFormChange();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// document-editor
|
||||
import {
|
||||
DocumentEditorWithRef,
|
||||
DocumentReadOnlyEditorWithRef,
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
CollaborativeDocumentReadOnlyEditorWithRef,
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMarking,
|
||||
TRealtimeConfig,
|
||||
} from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { cn, LIVE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { generateRandomColor } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
@@ -36,9 +38,6 @@ type Props = {
|
||||
handleEditorReady: (value: boolean) => void;
|
||||
handleReadOnlyEditorReady: (value: boolean) => void;
|
||||
updateMarkings: (description_html: string) => void;
|
||||
handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void;
|
||||
isDescriptionReady: boolean;
|
||||
pageDescriptionYJS: Uint8Array | undefined;
|
||||
};
|
||||
|
||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
@@ -51,9 +50,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
updateMarkings,
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
@@ -93,8 +89,19 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
updateMarkings(pageDescription ?? "<p></p>");
|
||||
}, [pageDescription, updateMarkings]);
|
||||
|
||||
if (pageId === undefined || pageDescription === undefined || !pageDescriptionYJS || !isDescriptionReady)
|
||||
return <PageContentLoader />;
|
||||
const realtimeConfig: TRealtimeConfig = useMemo(
|
||||
() => ({
|
||||
url: `${LIVE_BASE_URL}/collaboration`,
|
||||
queryParams: {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: projectId?.toString(),
|
||||
documentType: "project_page",
|
||||
},
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
|
||||
if (pageId === undefined) return <PageContentLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full w-full overflow-y-auto">
|
||||
@@ -128,7 +135,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
{isContentEditable ? (
|
||||
<DocumentEditorWithRef
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
id={pageId}
|
||||
fileHandler={{
|
||||
cancel: fileService.cancelUpload,
|
||||
@@ -137,11 +144,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
upload: fileService.getUploadFileFunction(workspaceSlug as string, setIsSubmitting),
|
||||
}}
|
||||
handleEditorReady={handleEditorReady}
|
||||
value={pageDescriptionYJS}
|
||||
ref={editorRef}
|
||||
containerClassName="p-0 pb-64"
|
||||
editorClassName="pl-10"
|
||||
onChange={handleDescriptionChange}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
@@ -149,11 +154,17 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
embedHandler={{
|
||||
issue: issueEmbedProps,
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
<CollaborativeDocumentReadOnlyEditorWithRef
|
||||
id={pageId}
|
||||
ref={readOnlyEditorRef}
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
handleEditorReady={handleReadOnlyEditorReady}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
editorClassName="pl-10"
|
||||
@@ -163,6 +174,12 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
embedHandler={{
|
||||
issue: issueEmbedReadOnlyProps,
|
||||
}}
|
||||
realtimeConfig={realtimeConfig}
|
||||
user={{
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: generateRandomColor(currentUser?.id ?? ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,10 @@ type Props = {
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef, handleSaveDescription } = props;
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
|
||||
// states
|
||||
const [gptModalOpen, setGptModal] = useState(false);
|
||||
// store hooks
|
||||
@@ -77,7 +76,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ type Props = {
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
@@ -31,7 +30,6 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -54,7 +52,6 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
|
||||
@@ -18,11 +18,10 @@ type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
// store values
|
||||
const {
|
||||
archived_at,
|
||||
@@ -76,11 +75,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
})
|
||||
);
|
||||
|
||||
const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => {
|
||||
await handleSaveDescription();
|
||||
action();
|
||||
};
|
||||
|
||||
// menu items list
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
@@ -122,21 +116,21 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: saveDescriptionYJSAndPerformAction(handleDuplicatePage),
|
||||
action: handleDuplicatePage,
|
||||
label: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "lock-unlock-page",
|
||||
action: is_locked ? handleUnlockPage : saveDescriptionYJSAndPerformAction(handleLockPage),
|
||||
action: is_locked ? handleUnlockPage : handleLockPage,
|
||||
label: is_locked ? "Unlock page" : "Lock page",
|
||||
icon: is_locked ? LockOpen : Lock,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore-page",
|
||||
action: archived_at ? handleRestorePage : saveDescriptionYJSAndPerformAction(handleArchivePage),
|
||||
action: archived_at ? handleRestorePage : handleArchivePage,
|
||||
label: archived_at ? "Restore page" : "Archive page",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
|
||||
@@ -19,7 +19,6 @@ type Props = {
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
@@ -33,7 +32,6 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -65,14 +63,12 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// editor
|
||||
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PageEditorHeaderRoot, PageEditorBody } from "@/components/pages";
|
||||
// hooks
|
||||
import { useProjectPages } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageDescription } from "@/hooks/use-page-description";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
type TPageRootProps = {
|
||||
@@ -16,35 +21,23 @@ type TPageRootProps = {
|
||||
};
|
||||
|
||||
export const PageRoot = observer((props: TPageRootProps) => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { projectId, workspaceSlug, page } = props;
|
||||
const { createPage } = useProjectPages();
|
||||
const { access, description_html, name } = page;
|
||||
|
||||
// states
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
|
||||
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const readOnlyEditorRef = useRef<EditorRefApi>(null);
|
||||
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { createPage } = useProjectPages();
|
||||
// derived values
|
||||
const { access, description_html, name } = page;
|
||||
// editor markings hook
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false);
|
||||
|
||||
// project-description
|
||||
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription(
|
||||
{
|
||||
editorRef,
|
||||
page,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}
|
||||
);
|
||||
|
||||
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
@@ -73,7 +66,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
markings={markings}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
@@ -88,9 +80,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
updateMarkings={updateMarkings}
|
||||
handleDescriptionChange={handleDescriptionChange}
|
||||
isDescriptionReady={isDescriptionReady}
|
||||
pageDescriptionYJS={pageDescriptionYJS}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import {
|
||||
EditorRefApi,
|
||||
proseMirrorJSONToBinaryString,
|
||||
applyUpdates,
|
||||
generateJSONfromHTMLForDocumentEditor,
|
||||
} from "@plane/editor";
|
||||
|
||||
// hooks
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import useAutoSave from "@/hooks/use-auto-save";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
const projectPageService = new ProjectPageService();
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
page: IPage;
|
||||
projectId: string | string[] | undefined;
|
||||
workspaceSlug: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export const usePageDescription = (props: Props) => {
|
||||
const { editorRef, page, projectId, workspaceSlug } = props;
|
||||
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
||||
const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
|
||||
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
|
||||
const [hasShownOfflineToast, setHasShownOfflineToast] = useState(false);
|
||||
|
||||
const pageDescription = page.description_html;
|
||||
const pageId = page.id;
|
||||
|
||||
const { data: pageDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
|
||||
workspaceSlug && projectId && pageId
|
||||
? async () => {
|
||||
const encodedDescription = await projectPageService.fetchDescriptionYJS(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId.toString()
|
||||
);
|
||||
const decodedDescription = new Uint8Array(encodedDescription);
|
||||
return decodedDescription;
|
||||
}
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
// set the merged local doc by the provider to the react local state
|
||||
const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => {
|
||||
setLocalDescriptionYJS(() => {
|
||||
// handle the initial sync case where indexeddb gives extra update, in
|
||||
// this case we need to save the update to the DB
|
||||
if (source && source === "initialSync") {
|
||||
handleSaveDescription(true, update);
|
||||
}
|
||||
|
||||
return update;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
||||
// TODO: this is a one-time operation, and needs to be removed once all the pages are updated
|
||||
useEffect(() => {
|
||||
const changeHTMLToBinary = async () => {
|
||||
if (!pageDescriptionYJS || !pageDescription) return;
|
||||
if (pageDescriptionYJS.length === 0) {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
}
|
||||
|
||||
await mutateDescriptionYJS();
|
||||
|
||||
setIsDescriptionReady(true);
|
||||
} else setIsDescriptionReady(true);
|
||||
};
|
||||
changeHTMLToBinary();
|
||||
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
} else {
|
||||
setShowAlert(false);
|
||||
}
|
||||
}, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]);
|
||||
|
||||
// merge the description from remote to local state and only save if there are local changes
|
||||
const handleSaveDescription = useCallback(
|
||||
async (forceSync?: boolean, initSyncVectorAsUpdate?: Uint8Array) => {
|
||||
const update = localDescriptionYJS ?? initSyncVectorAsUpdate;
|
||||
|
||||
if (update == null) return;
|
||||
|
||||
if (!isContentEditable) return;
|
||||
|
||||
const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => {
|
||||
if (!workspaceSlug || !projectId || !pageId || !latestDescription || !update) return;
|
||||
|
||||
if (!forceSync && !editorRef.current?.hasUnsyncedChanges()) {
|
||||
setIsSubmitting("saved");
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBinaryString = applyUpdates(latestDescription, update);
|
||||
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
await updateDescription(combinedBinaryString, descriptionHTML)
|
||||
.then(() => {
|
||||
editorRef.current?.setSynced();
|
||||
setHasShownOfflineToast(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.message === "Network Error" && !hasShownOfflineToast) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Info!",
|
||||
message: "You seem to be offline, your changes will remain saved on this device",
|
||||
});
|
||||
setHasShownOfflineToast(true);
|
||||
}
|
||||
if (e.response?.status === 471) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to save your changes, the page was locked, your changes will be lost",
|
||||
});
|
||||
}
|
||||
if (e.response?.status === 472) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to save your changes, the page was archived, your changes will be lost",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setShowAlert(false);
|
||||
setIsSubmitting("saved");
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSubmitting("submitting");
|
||||
const latestDescription = await mutateDescriptionYJS();
|
||||
if (latestDescription) {
|
||||
await applyUpdatesAndSave(latestDescription, update);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSubmitting("saved");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
localDescriptionYJS,
|
||||
setShowAlert,
|
||||
editorRef,
|
||||
hasShownOfflineToast,
|
||||
isContentEditable,
|
||||
mutateDescriptionYJS,
|
||||
pageId,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
updateDescription,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
useAutoSave(handleSaveDescription);
|
||||
|
||||
return {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleSaveDescription,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,64 @@
|
||||
// services
|
||||
import type { CycleDateCheckData, ICycle, TIssuesResponse } from "@plane/types";
|
||||
import type {
|
||||
CycleDateCheckData,
|
||||
ICycle,
|
||||
TIssuesResponse,
|
||||
IWorkspaceActiveCyclesResponse,
|
||||
IWorkspaceProgressResponse,
|
||||
IWorkspaceAnalyticsResponse,
|
||||
} from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
// helpers
|
||||
|
||||
export class CycleService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async workspaceActiveCyclesAnalytics(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string,
|
||||
analytic_type: string = "points"
|
||||
): Promise<IWorkspaceAnalyticsResponse> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/analytics?type=${analytic_type}`
|
||||
)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async workspaceActiveCyclesProgress(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
): Promise<IWorkspaceProgressResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/progress/`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async workspaceActiveCycles(
|
||||
workspaceSlug: string,
|
||||
cursor: string,
|
||||
per_page: number
|
||||
): Promise<IWorkspaceActiveCyclesResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, {
|
||||
params: {
|
||||
per_page,
|
||||
cursor,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceCycles(workspaceSlug: string): Promise<ICycle[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/cycles/`)
|
||||
.then((response) => response?.data)
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ICycleStore {
|
||||
currentProjectDraftCycleIds: string[] | null;
|
||||
currentProjectActiveCycleId: string | null;
|
||||
currentProjectArchivedCycleIds: string[] | null;
|
||||
currentProjectActiveCycle: ICycle | null;
|
||||
|
||||
// computed actions
|
||||
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
|
||||
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
|
||||
@@ -100,6 +102,8 @@ export class CycleStore implements ICycleStore {
|
||||
currentProjectDraftCycleIds: computed,
|
||||
currentProjectActiveCycleId: computed,
|
||||
currentProjectArchivedCycleIds: computed,
|
||||
currentProjectActiveCycle: computed,
|
||||
|
||||
// actions
|
||||
setPlotType: action,
|
||||
fetchWorkspaceCycles: action,
|
||||
@@ -208,7 +212,7 @@ export class CycleStore implements ICycleStore {
|
||||
get currentProjectActiveCycleId() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId) return null;
|
||||
const activeCycle = Object.keys(this.activeCycleIdMap ?? {}).find(
|
||||
const activeCycle = Object.keys(this.cycleMap ?? {}).find(
|
||||
(cycleId) => this.cycleMap?.[cycleId]?.project_id === projectId
|
||||
);
|
||||
return activeCycle || null;
|
||||
@@ -228,6 +232,12 @@ export class CycleStore implements ICycleStore {
|
||||
return archivedCycleIds;
|
||||
}
|
||||
|
||||
get currentProjectActiveCycle() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
if (!projectId && !this.currentProjectActiveCycleId) return null;
|
||||
return this.cycleMap?.[this.currentProjectActiveCycleId!] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns filtered cycle ids based on display filters and filters
|
||||
* @param {TCycleDisplayFilters} displayFilters
|
||||
|
||||
@@ -9,6 +9,8 @@ export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
|
||||
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
|
||||
|
||||
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
|
||||
|
||||
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "";
|
||||
|
||||
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
|
||||
|
||||
Reference in New Issue
Block a user