Compare commits

...

3 Commits

Author SHA1 Message Date
gurusainath
f5129fc4c8 chore: Typo and description updates in project setting features 2024-07-24 14:37:19 +05:30
Akshita Goyal
d18979b673 [WEB-2007] fix: cycles loading optimization (#5194)
* fix: cycles loading optimization

* fix: ts error

* fix: types added along with apis

* fix: formatting

* fix: removed bottom border

* fix: fixed loading state for cycle-stats
2024-07-23 15:52:37 +05:30
Aaryan Khandelwal
2ea5077e6a [WEB-1116] feat: pages realtime sync (#5057)
* init: live server for editor realtime sync

* chore: authentication added

* chore: updated logic to convert html to binary for old pages

* chore: added description json on page update

* chore: made all functions generic

* chore: save description in json and html formats

* refactor: document editor components

* chore: uncomment ui package components

* fix: without props extensions refactor

* fix: merge conflicts resolved from preview

* chore: init docker compose

* chore: pages custom error codes

* chore: add health check endpoint to the live server

* chore: update without props extensions type

* chore: better error handling

* chore: update react-hook-form versions

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-07-22 18:43:04 +05:30
70 changed files with 3400 additions and 3577 deletions

View File

@@ -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"
}
}
}

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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(

View 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,
}

View File

@@ -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
View File

@@ -0,0 +1 @@
API_BASE_URL="http://api:8000"

28
live/Dockerfile.channel Normal file
View 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
View 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
View 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"
}
}

View 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
View 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
View 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;
}
};

View 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);
}
}

View 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;
});
}
}

View 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
View File

@@ -0,0 +1 @@
export type TDocumentTypes = "project_page";

25
live/tsconfig.json Normal file
View 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
View 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,
}));

View File

@@ -7,6 +7,7 @@
"web",
"space",
"admin",
"live",
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",

View File

@@ -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",

View File

@@ -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);
}
}

View File

@@ -1 +0,0 @@
export * from "./collaboration-provider";

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};
};

View File

@@ -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";

View File

@@ -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) => (

View 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,
});

View File

@@ -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()];

View File

@@ -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() || "";

View File

@@ -1,3 +0,0 @@
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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 [
{

View File

@@ -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 {

View File

@@ -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 };
};

View File

@@ -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) {

View File

@@ -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 };
};

View File

@@ -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;

View File

@@ -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;
};
};

View File

@@ -1 +0,0 @@
export * from "src/ce/providers";

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "@/extensions/core-without-props";

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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"
}
}

View 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,
}));

View File

@@ -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

View File

@@ -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": "*"
}
}
}

View File

@@ -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",

View File

@@ -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>
);
});

View File

@@ -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,

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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();
}}
/>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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}
/>
</>
);

View File

@@ -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,
};
};

View File

@@ -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)

View File

@@ -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

View File

@@ -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}/`);

4860
yarn.lock

File diff suppressed because it is too large Load Diff