Compare commits

...
Author SHA1 Message Date
M. PalanikannanandGitHub a71ff8f0d9 Merge branch 'preview' into refactor/core-without-props 2025-09-01 20:59:35 +05:30
f42eeec2c0 [WEB-4686] feat: propel tabs (#7620)
* chore: global css file added to tailwind config package

* chore: tailwind config updated

* chore: cn utility function added to propel package

* chore: storybook init

* fix: format error

* feat: added base ui tabs

* fix: add missing newline at end of package.json in propel package

* fix: reorder import statement for Tabs component in propel package

* feat: refactor Tabs component to support compound structure with forward refs

* fix: lint

* chore: code refactor

* chore: code refactor

* fix: lock file

* chore: added stories for tabs

* refactor: clean up

* fix: lint

* fix: lint

* fix: Remove duplicate storybook ESLint config

* fix: lint

* fix: update classname import path in Tabs component

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-09-01 19:58:40 +05:30
Vamsi KrishnaandGitHub e679dc3d12 [WEB-4814] chore: enabled reordering in list when group is none #7684 2025-09-01 19:46:17 +05:30
Jayash TripathyandGitHub f2edf637de [WEB-4809] chore: added common libs to pnpm workspace catalog (#7611)
* chore: added common libs to pnpm workspace catalog

* chore: update pnpm lockfile to use exact versions for React and TypeScript dependencies

* refactor: removed string from the versions

* fix: lint

* refactor: cleanup

* fix: lint

* chore: updated lock file
2025-09-01 19:42:56 +05:30
Vamsi KrishnaandGitHub 7437deaa86 [WEB-4689]chore: added accordion to propel (#7641)
* chore: added accordion to propel

* fix: lint errors

* fix: updated export path

* fix: lint errors

* chore: made accordion into compound component

* fix: coderabbit suggestions
2025-09-01 19:42:12 +05:30
64b95daff4 [WEB-4740] feat: add propel seperator (#7637)
* chore: global css file added to tailwind config package

* chore: tailwind config updated

* chore: cn utility function added to propel package

* chore: storybook init

* fix: format error

* chore: code refactor

* chore: code refactor

* fix: format error

* feat: add propel seperator component

* 🔒 chore: updated lock file

* ✏️ fix: typo in separator filename and some linting issues

* ♻️ refactor: replace clsx with cn utility in Separator component for class name management

* 🐛 fix: re-added twMerge

* 🧹 cleanup: remove unnecessary blank line in Separator component

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2025-09-01 19:40:01 +05:30
sriramveeraghanta 34181fba80 chore: version bump 2025-09-01 19:38:21 +05:30
Vipin ChaudharyandGitHub 0358e9b965 [WIKI-589] fix: update project page creation flow (#7685)
* fix : update project page creation flow

* chore: update translations

* chore: remove unused changes
2025-09-01 16:43:07 +05:30
Vipin ChaudharyandGitHub ee471c772a [WIKI-622] fix: update space work item description styles #7686 2025-09-01 15:13:52 +05:30
e2c0d0f23c [WEB-4813] fix: ensure all identifiers in log transformations are converted to strings (#7682)
* fix: ensure all identifiers in log transformations are converted to strings

* Update apps/api/plane/bgtasks/cleanup_task.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-01 14:12:34 +05:30
Anmol Singh BhatiaandGitHub 3ecebc02ae [WEB-4811] chore: plane logo asset updated #7683 2025-09-01 13:52:05 +05:30
Anmol Singh BhatiaandGitHub 16d531cc7a [WEB-4808] fix: joinUrlPath utility fn #7678 2025-09-01 13:51:26 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ab283c7c78 chore(deps): bump the npm_and_yarn group across 4 directories with 1 update (#7681)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 1 update in the /apps/admin directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 1 update in the /apps/space directory: [next](https://github.com/vercel/next.js).
Bumps the npm_and_yarn group with 1 update in the /apps/web directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.30 to 14.2.32
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

Updates `next` from 14.2.30 to 14.2.32
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

Updates `next` from 14.2.30 to 14.2.32
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

Updates `next` from 14.2.30 to 14.2.32
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.30...v14.2.32)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: next
  dependency-version: 14.2.32
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-01 13:05:18 +05:30
Anmol Singh BhatiaandGitHub 7c1bbf4a6f [WEB-4807] fix: workspace menu item highlight #7677 2025-08-29 20:23:31 +05:30
sriram veeraghantaandGitHub 4293892178 [WEB-4803] fix: eslint errors in code editors (#7675)
* fix: eslint errors in code editors

* fix: removed workspaces from package.json
2025-08-29 19:44:46 +05:30
Bavisetti NarayanandGitHub ebd517bb7d [WIKI-619] chore: added sort order migration for page model #7673 2025-08-29 19:44:29 +05:30
Henit ChobisaandGitHub 4042af9f32 feat: added support for expanding updated_by in work item (#7667)
* feat: added support for expanding `updated_by` and `type` in work item

* fix: moved type to dictionary for expansion

* fix: refactored unnecessary fields
2025-08-29 16:41:54 +05:30
258d24bf06 [SILO-454] chore: refactor decorator, logger packages (#7618)
* [SILO-454] chore: refactor decorator, logger packages

- add registerControllers function abstracting both rest, ws controllers
- update logger to a simple json based logger

* fix: logger instance and middleware

* fix: type and module resolutions

* fix: lodash type package update

* fix: bypass lint errors in decorators

* chore: format changes

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-08-29 14:29:16 +05:30
NikhilandGitHub 489a6e1e94 [WEB-4796] fix: update MongoDB collection check to use 'is not None' for better clarity #7671 2025-08-29 14:25:46 +05:30
sriram veeraghantaandGitHub 4f349807be chore: docker image builds by removing node version in workspace.yml file (#7672)
* fix: docker image builts by removing nodeversion in workspace.yml file

* chore: remve .nvmrc file
2025-08-29 14:25:07 +05:30
Vipin ChaudharyandGitHub 7a43137620 [WIKI-556] fix : invert tracking logic #7668 2025-08-28 20:37:20 +05:30
e144ce8cf2 [WIKI-556] chore: disable tracking of page hover (#7650)
* chore: disable tracking of page hover

* chore: add track check for page feth

* chore: make track check mandatory

* chore: update track format

---------

Co-authored-by: VipinDevelops <vipinchaudhary1809@gmail.com>
2025-08-28 20:02:44 +05:30
Anmol Singh BhatiaandGitHub ba7303b7af [WEB-4559] fix: workspace menu item active state (#7666)
* fix: highlight active workspace menu item correctly

* chore: code refactor
2025-08-28 20:01:28 +05:30
Prateek ShouryaandGitHub e0912ccefc [WEB-4040] fix: minor changes in base plan names (#7656) 2025-08-28 18:51:10 +05:30
Anmol Singh BhatiaandGitHub aef465415b [WEB-4748] chore: propel utils (#7662)
* chore: tailwind merge added to propel

* chore: classname utils updated

* chore: utils import alias added

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-08-28 18:39:27 +05:30
Vamsi KrishnaandGitHub 3b3bd3e54e [WEB-4722] fix: fixed draft state update #7663 2025-08-28 18:38:30 +05:30
SangeethaandGitHub f2fabff10a [WEB-4521] fix: attachments #7665 2025-08-28 18:36:50 +05:30
NikhilandGitHub ddeabeeeb1 [WEB-4720] fix: mongo connection class to initialize mongo db #7652 2025-08-28 13:57:16 +05:30
Aaryan Khandelwal a89bee8975 refactor: parser functions 2025-08-07 14:32:59 +05:30
Aaryan Khandelwal a986068e9f refactor: parser functions 2025-08-07 14:32:48 +05:30
Aaryan Khandelwal d8ae355314 refactor: core without props 2025-08-07 14:22:36 +05:30
104 changed files with 1537 additions and 1435 deletions
-2
View File
@@ -17,8 +17,6 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
@@ -36,8 +36,6 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
+1 -1
View File
@@ -14,7 +14,7 @@ strict-peer-dependencies=false
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
public-hoist-pattern[]=eslint
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
-1
View File
@@ -1 +0,0 @@
lts/jod
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -31,11 +31,11 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next": "14.2.32",
"next-themes": "^0.2.1",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
"sharp": "^0.33.5",
"swr": "^2.2.4",
@@ -45,11 +45,11 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "^4.17.6",
"@types/lodash": "4.17.20",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/uuid": "^9.0.8",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1
View File
@@ -91,6 +91,7 @@ class BaseSerializer(serializers.ModelSerializer):
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"updated_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
+10 -2
View File
@@ -441,7 +441,11 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
@@ -641,7 +645,11 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
+9 -7
View File
@@ -198,6 +198,7 @@ class PageViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
project = Project.objects.get(pk=project_id)
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
"""
if the role is guest and guest_view_all_features is false and owned by is not
@@ -230,13 +231,14 @@ class PageViewSet(BaseViewSet):
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
if track_visit:
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
+3 -1
View File
@@ -172,12 +172,14 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
{"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND
)
project_id = request.data.get("project_id", issue.project_id)
serializer = DraftIssueCreateSerializer(
issue,
data=request.data,
partial=True,
context={
"project_id": request.data.get("project_id", None),
"project_id": project_id,
"cycle_id": request.data.get("cycle_id", "not_provided"),
},
)
+24 -24
View File
@@ -67,7 +67,7 @@ def flush_to_mongo_and_delete(
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection and mongo_available:
if mongo_collection is not None and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
@@ -166,9 +166,9 @@ def process_cleanup_task(
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": record["id"],
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": record["token_identifier"],
"token_identifier": str(record["token_identifier"]),
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
@@ -178,18 +178,18 @@ def transform_api_log(record: Dict) -> Dict:
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": record["created_by_id"],
"created_by_id": str(record["created_by_id"]),
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": record["id"],
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"receiver_id": record["receiver_id"],
"triggered_by_id": record["triggered_by_id"],
"entity_identifier": record["entity_identifier"],
"receiver_id": str(record["receiver_id"]),
"triggered_by_id": str(record["triggered_by_id"]),
"entity_identifier": str(record["entity_identifier"]),
"entity_name": record["entity_name"],
"data": record["data"],
"processed_at": (
@@ -197,27 +197,27 @@ def transform_email_log(record: Dict) -> Dict:
),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"old_value": record["old_value"],
"new_value": record["new_value"],
"created_by_id": record["created_by_id"],
"old_value": str(record["old_value"]),
"new_value": str(record["new_value"]),
"created_by_id": str(record["created_by_id"]),
}
def transform_page_version(record: Dict) -> Dict:
"""Transform page version record."""
return {
"id": record["id"],
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"page_id": record["page_id"],
"workspace_id": record["workspace_id"],
"owned_by_id": record["owned_by_id"],
"page_id": str(record["page_id"]),
"workspace_id": str(record["workspace_id"]),
"owned_by_id": str(record["owned_by_id"]),
"description_html": record["description_html"],
"description_binary": record["description_binary"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"sub_pages_data": record["sub_pages_data"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
@@ -228,14 +228,14 @@ def transform_page_version(record: Dict) -> Dict:
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": record["id"],
"id": str(record["id"]),
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"issue_id": record["issue_id"],
"workspace_id": record["workspace_id"],
"project_id": record["project_id"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"owned_by_id": record["owned_by_id"],
"issue_id": str(record["issue_id"]),
"workspace_id": str(record["workspace_id"]),
"project_id": str(record["project_id"]),
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"owned_by_id": str(record["owned_by_id"]),
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
),
@@ -0,0 +1,30 @@
# Generated by Django 4.2.22 on 2025-08-29 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0101_description_descriptionversion"),
]
operations = [
migrations.AddField(
model_name="page",
name="sort_order",
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name="pagelog",
name="entity_type",
field=models.CharField(
blank=True, max_length=30, null=True, verbose_name="Entity Type"
),
),
migrations.AlterField(
model_name="pagelog",
name="entity_identifier",
field=models.UUIDField(blank=True, null=True),
),
]
+3 -1
View File
@@ -57,6 +57,7 @@ class Page(BaseModel):
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
sort_order = models.FloatField(default=65535)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
@@ -98,8 +99,9 @@ class PageLog(BaseModel):
)
transaction = models.UUIDField(default=uuid.uuid4)
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
entity_identifier = models.UUIDField(null=True)
entity_identifier = models.UUIDField(null=True, blank=True)
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
+4
View File
@@ -465,3 +465,7 @@ if ENABLE_DRF_SPECTACULAR:
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
INSTALLED_APPS.append("drf_spectacular")
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
# MongoDB Settings
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
+3
View File
@@ -118,4 +118,7 @@ class MongoConnection:
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
if cls._client is None:
cls._instance = cls()
return cls._client is not None and cls._db is not None
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
@@ -58,8 +58,8 @@
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"typescript": "5.8.3",
"tsup": "catalog:",
"typescript": "catalog:",
"ws": "^8.18.3"
}
}
@@ -1,44 +0,0 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
-50
View File
@@ -1,50 +0,0 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
export const getBinaryDataFromHTMLString = (
descriptionHTML: string
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData,
};
};
+7 -3
View File
@@ -1,5 +1,5 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@plane/editor/lib";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
@@ -19,7 +19,9 @@ export const updatePageDescription = async (
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData({
descriptionBinary: updatedDescription,
});
try {
const payload = {
description_binary: contentBinaryEncoded,
@@ -44,7 +46,9 @@ const fetchDescriptionHTMLAndTransform = async (
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
const contentBinary = getBinaryDataFromHTMLString({
descriptionHTML: pageDetails.description_html ?? "<p></p>",
});
return contentBinary;
} catch (error) {
manualLogger.error("Error while transforming from HTML to Uint8Array", error);
+3 -4
View File
@@ -5,11 +5,11 @@ import expressWs from "express-ws";
import helmet from "helmet";
// hocuspocus server
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
import { getAllDocumentFormatsFromHTMLString } from "@plane/editor/lib";
export class Server {
private app: any;
@@ -72,9 +72,8 @@ export class Server {
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
const { description, description_binary } = getAllDocumentFormatsFromHTMLString({
descriptionHTML: description_html,
});
res.status(200).json({
description,
@@ -58,7 +58,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
flaggedExtensions={richTextEditorExtensions.flagged}
{...rest}
containerClassName={containerClassName}
editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden"
editorClassName="min-h-[100px] py-2 overflow-hidden"
displayConfig={{ fontSize: "large-font" }}
/>
);
+8 -9
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.28.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -41,11 +41,11 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "14.2.30",
"next": "14.2.32",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-popper": "^2.3.0",
@@ -58,13 +58,12 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "^4.17.6",
"@types/lodash": "4.17.20",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
@@ -240,7 +240,7 @@ export const ListGroup = observer((props: Props) => {
isWorkflowDropDisabled,
]);
const isDragAllowed = !!group_by && DRAG_ALLOWED_GROUPS.includes(group_by);
const isDragAllowed = group_by ? DRAG_ALLOWED_GROUPS.includes(group_by) : true;
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
const isDropDisabled = isWorkflowDropDisabled || !!group.isDropDisabled;
@@ -521,7 +521,7 @@ export const handleGroupDragDrop = async (
subGroupBy: TIssueGroupByOptions | undefined,
shouldAddIssueAtTop = false
) => {
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
if (!source.id || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
let updatedIssue: Partial<TIssue> = {};
const issueUpdates: IssueUpdates = {};
@@ -549,7 +549,7 @@ export const handleGroupDragDrop = async (
};
// update updatedIssue values based on the source and destination groupIds
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
if (source.groupId && destination.groupId && source.groupId !== destination.groupId && groupBy) {
const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy];
let groupValue: any = clone(sourceIssue[groupKey]);
@@ -19,7 +19,6 @@ export type TBasePaidPlanCardProps = {
extraFeatures?: string | React.ReactNode;
renderPriceContent: (price: TSubscriptionPrice) => React.ReactNode;
renderActionButton: (price: TSubscriptionPrice) => React.ReactNode;
isSelfHosted: boolean;
};
export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) => {
@@ -31,11 +30,10 @@ export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) =>
extraFeatures,
renderPriceContent,
renderActionButton,
isSelfHosted,
} = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TBillingFrequency>("month");
const basePlan = getBaseSubscriptionName(planVariant, isSelfHosted);
const basePlan = getBaseSubscriptionName(planVariant);
const upgradeCardVariantStyle = getUpgradeCardVariantStyle(planVariant);
// Plane details
const planeName = getSubscriptionName(planVariant);
@@ -107,7 +107,6 @@ export const PlanUpgradeCard: FC<PlanUpgradeCardProps> = observer((props) => {
isTrialAllowed={isTrialAllowed}
/>
)}
isSelfHosted={isSelfHosted}
/>
);
});
@@ -107,7 +107,6 @@ export const TalkToSalesCard: FC<TalkToSalesCardProps> = observer((props) => {
extraFeatures={extraFeatures}
renderPriceContent={renderPriceContent}
renderActionButton={renderActionButton}
isSelfHosted={isSelfHosted}
/>
);
});
@@ -1,14 +1,23 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// plane imports
import { EUserPermissionsLevel, EPageAccess, PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants";
import { useParams, useRouter } from "next/navigation";
import {
EUserPermissionsLevel,
EPageAccess,
PROJECT_PAGE_TRACKER_ELEMENTS,
PROJECT_PAGE_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types";
import { EUserProjectRoles, TPage, TPageNavigationTabs } from "@plane/types";
// components
import { setToast, TOAST_TYPE } from "@plane/ui";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { PageLoader } from "@/components/pages/loaders/page-loader";
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// plane web hooks
@@ -25,10 +34,16 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
// plane hooks
const { t } = useTranslation();
// store hooks
const { loader, isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters } =
const { currentProjectDetails, loader } = useProject();
const { isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters } =
usePageStore(storeType);
const { toggleCreatePageModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { createPage } = usePageStore(EPageStoreType.PROJECT);
// states
const [isCreatingPage, setIsCreatingPage] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = useParams();
// derived values
const pageIds = getCurrentProjectPageIdsByTab(pageType);
const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType);
@@ -54,6 +69,42 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
extension: "svg",
});
// handle page create
const handleCreatePage = async () => {
setIsCreatingPage(true);
const payload: Partial<TPage> = {
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
};
await createPage(payload)
.then((res) => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
id: res?.id,
state: "SUCCESS",
},
});
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
router.push(pageId);
})
.catch((err) => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
state: "ERROR",
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.data?.error || "Page could not be created. Please try again.",
});
})
.finally(() => setIsCreatingPage(false));
};
if (loader === "init-loader") return <PageLoader />;
// if no pages exist in the active page type
if (!isAnyPageAvailable || pageIds?.length === 0) {
@@ -64,12 +115,12 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
description={t("project_page.empty_state.general.description")}
assetPath={generalPageResolvedPath}
primaryButton={{
text: t("project_page.empty_state.general.primary_button.text"),
text: isCreatingPage ? t("creating") : t("project_page.empty_state.general.primary_button.text"),
onClick: () => {
toggleCreatePageModal({ isOpen: true });
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions,
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
@@ -81,12 +132,12 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
description={t("project_page.empty_state.public.description")}
assetPath={publicPageResolvedPath}
primaryButton={{
text: t("project_page.empty_state.public.primary_button.text"),
text: isCreatingPage ? t("creating") : t("project_page.empty_state.public.primary_button.text"),
onClick: () => {
toggleCreatePageModal({ isOpen: true, pageAccess: EPageAccess.PUBLIC });
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions,
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
@@ -97,12 +148,12 @@ export const PagesListMainContent: React.FC<Props> = observer((props) => {
description={t("project_page.empty_state.private.description")}
assetPath={privatePageResolvedPath}
primaryButton={{
text: t("project_page.empty_state.private.primary_button.text"),
text: isCreatingPage ? t("creating") : t("project_page.empty_state.private.primary_button.text"),
onClick: () => {
toggleCreatePageModal({ isOpen: true, pageAccess: EPageAccess.PRIVATE });
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions,
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
@@ -7,6 +7,7 @@ import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { joinUrlPath } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
@@ -47,13 +48,13 @@ export const SidebarItemBase: FC<Props> = observer(({ item, additionalRender, ad
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
if (!isPinned && !staticItems.includes(item.key)) return null;
const itemHref = item.key === "your_work" ? `/${slug}${item.href}${data?.id}/` : `/${slug}${item.href}`;
const isActive = itemHref === pathname;
const itemHref =
item.key === "your_work" && data?.id ? joinUrlPath(slug, item.href, data?.id) : joinUrlPath(slug, item.href);
const icon = getSidebarNavigationItemIcon(item.key);
return (
<Link href={itemHref} onClick={handleLinkClick}>
<SidebarNavItem isActive={isActive}>
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
+4 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react";
// plane editor
import { type EditorRefApi, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
import { type EditorRefApi, getBinaryDataFromHTMLString } from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";
// hooks
@@ -27,7 +27,9 @@ export const usePageFallback = (args: TArgs) => {
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
latestDecodedDescription = new Uint8Array(latestEncodedDescription);
} else {
latestDecodedDescription = getBinaryDataFromDocumentEditorHTMLString("<p></p>");
latestDecodedDescription = getBinaryDataFromHTMLString({
descriptionHTML: "<p></p>",
});
}
editor.setProviderDocument(latestDecodedDescription);
@@ -23,8 +23,12 @@ export class ProjectPageService extends APIService {
});
}
async fetchById(workspaceSlug: string, projectId: string, pageId: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`)
async fetchById(workspaceSlug: string, projectId: string, pageId: string, trackVisit: boolean): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
params: {
track_visit: trackVisit,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -49,7 +49,12 @@ export interface IProjectPageStore {
projectId: string,
pageType?: TPageNavigationTabs
) => Promise<TPage[] | undefined>;
fetchPageDetails: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
fetchPageDetails: (
workspaceSlug: string,
projectId: string,
pageId: string,
options?: { trackVisit?: boolean }
) => Promise<TPage | undefined>;
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
removePage: (pageId: string) => Promise<void>;
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
@@ -239,7 +244,9 @@ export class ProjectPageStore implements IProjectPageStore {
* @description fetch the details of a page
* @param {string} pageId
*/
fetchPageDetails = async (workspaceSlug: string, projectId: string, pageId: string) => {
fetchPageDetails = async (...args: Parameters<IProjectPageStore["fetchPageDetails"]>) => {
const [workspaceSlug, projectId, pageId, options] = args;
const { trackVisit } = options || {};
try {
if (!workspaceSlug || !projectId || !pageId) return undefined;
@@ -249,7 +256,7 @@ export class ProjectPageStore implements IProjectPageStore {
this.error = undefined;
});
const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
const page = await this.service.fetchById(workspaceSlug, projectId, pageId, trackVisit ?? true);
runInAction(() => {
if (page?.id) {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.28.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -47,13 +47,13 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "14.2.30",
"next": "14.2.32",
"next-themes": "^0.2.1",
"posthog-js": "^1.131.3",
"react": "^18.3.1",
"react": "catalog:",
"react-color": "^2.19.3",
"react-day-picker": "9.5.0",
"react-dom": "^18.3.1",
"react-dom": "catalog:",
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-markdown": "^8.0.7",
@@ -72,13 +72,13 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "^4.17.6",
"@types/lodash": "4.17.20",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react": "catalog:",
"@types/react-color": "^3.0.6",
"@types/react-dom": "^18.2.18",
"@types/react-dom": "catalog:",
"@types/uuid": "^8.3.4",
"prettier": "^3.2.5",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

+3 -7
View File
@@ -2,13 +2,9 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency=18",
@@ -36,7 +32,7 @@
"tar-fs": "3.0.9",
"prosemirror-view": "1.40.0",
"@types/express": "4.17.23",
"typescript": "5.8.3",
"typescript": "catalog:",
"sharp": "0.33.5"
}
},
@@ -44,4 +40,4 @@
"engines": {
"node": ">=22.18.0"
}
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.28.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"files": [
@@ -33,8 +33,8 @@
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "8.4.0",
"typescript": "5.8.3"
"@types/react": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+9
View File
@@ -244,6 +244,7 @@ export interface IWorkspaceSidebarNavigationItem {
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
highlight: (pathname: string, url: string) => boolean;
}
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
@@ -252,24 +253,28 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
labelTranslationKey: "views",
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
analytics: {
key: "analytics",
labelTranslationKey: "analytics",
href: `/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
archives: {
key: "archives",
labelTranslationKey: "archives",
href: `/projects/archives/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
};
@@ -286,24 +291,28 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
labelTranslationKey: "home.title",
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
inbox: {
key: "inbox",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
"your-work": {
key: "your_work",
labelTranslationKey: "your_work",
href: `/profile/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
projects: {
key: "projects",
labelTranslationKey: "projects",
href: `/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
};
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+1 -5
View File
@@ -52,11 +52,7 @@ userController.registerRoutes(router);
### WebSocket Controller
```typescript
import {
Controller,
WebSocket,
BaseWebSocketController,
} from "@plane/decorators";
import { Controller, WebSocket, BaseWebSocketController } from "@plane/decorators";
import { Request } from "express";
import { WebSocket as WS } from "ws";
+6 -18
View File
@@ -11,35 +11,23 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external express,ws",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external express,ws",
"check:lint": "eslint . --max-warnings 0",
"build": "tsc --noEmit && tsup --minify",
"dev": "tsup --watch",
"check:lint": "eslint . --max-warnings 1",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"express": "^4.21.2",
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/express": "^4.17.21",
"@types/node": "^20.14.9",
"@types/ws": "^8.5.10",
"tsup": "8.4.0",
"typescript": "5.8.3"
},
"peerDependencies": {
"express": ">=4.21.2",
"ws": ">=8.0.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
}
"reflect-metadata": "^0.2.2",
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+67 -28
View File
@@ -1,15 +1,9 @@
import { RequestHandler, Router } from "express";
import type { RequestHandler, Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
type HttpMethod =
| "get"
| "post"
| "put"
| "delete"
| "patch"
| "options"
| "head"
| "ws";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
interface ControllerInstance {
[key: string]: unknown;
@@ -22,40 +16,85 @@ interface ControllerConstructor {
export function registerControllers(
router: Router,
Controller: ControllerConstructor,
controllers: ControllerConstructor[],
dependencies: any[] = []
): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestController(router, Controller);
}
});
}
function registerRestController(router: Router, Controller: ControllerConstructor): void {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as HttpMethod;
const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares =
(Reflect.getMetadata(
"middlewares",
instance,
methodName,
) as RequestHandler[]) || [];
const middlewares = (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName] as unknown;
if (typeof handler === "function") {
if (method !== "ws") {
(
router[method] as (
path: string,
...handlers: RequestHandler[]
) => void
)(`${baseRoute}${route}`, ...middlewares, handler.bind(instance));
(router[method] as (path: string, ...handlers: RequestHandler[]) => void)(
`${baseRoute}${route}`,
...middlewares,
handler.bind(instance)
);
}
}
}
});
}
function registerWebSocketController(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
if (typeof handler === "function" && "ws" in router && typeof router.ws === "function") {
router.ws(`${baseRoute}${route}`, (ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(`WebSocket error in ${Controller.name}.${methodName}`, error);
ws.close(1011, error instanceof Error ? error.message : "Internal server error");
}
});
}
}
});
}
-1
View File
@@ -3,7 +3,6 @@ export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
export { registerWebSocketControllers } from "./websocket-controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
+2 -5
View File
@@ -21,9 +21,7 @@ export function Controller(baseRoute: string = ""): ClassDecorator {
* @param method HTTP method to handle
* @returns Method decorator
*/
function createHttpMethodDecorator(
method: RestMethod,
): (route: string) => MethodDecorator {
function createHttpMethodDecorator(method: RestMethod): (route: string) => MethodDecorator {
return function (route: string): MethodDecorator {
return function (target: object, propertyKey: string | symbol) {
Reflect.defineMetadata("method", method, target, propertyKey);
@@ -46,8 +44,7 @@ export const Delete = createHttpMethodDecorator("delete");
*/
export function Middleware(middleware: RequestHandler): MethodDecorator {
return function (target: object, propertyKey: string | symbol) {
const middlewares =
Reflect.getMetadata("middlewares", target, propertyKey) || [];
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
@@ -1,81 +0,0 @@
import { Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
interface ControllerInstance {
[key: string]: unknown;
}
interface ControllerConstructor {
new (...args: unknown[]): ControllerInstance;
prototype: ControllerInstance;
}
export function registerWebSocketControllers(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance,
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
if (
typeof handler === "function" &&
"ws" in router &&
typeof router.ws === "function"
) {
router.ws(`${baseRoute}${route}`, (ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error,
);
ws.close(
1011,
error instanceof Error ? error.message : "Internal server error",
);
}
});
}
}
});
}
/**
* Base controller class for WebSocket endpoints
*/
export abstract class BaseWebSocketController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Abstract method to handle WebSocket connections
* Implement this in your derived class
*/
abstract handleConnection(ws: WebSocket, req: Request): void;
}
-1
View File
@@ -6,7 +6,6 @@
"lib": ["ES2020"],
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.28.0",
"version": "1.0.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -32,8 +32,8 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "18.3.1"
"react": "catalog:",
"react-dom": "catalog:"
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
@@ -85,11 +85,11 @@
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "18.15.3",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"postcss": "^8.4.38",
"tsup": "8.4.0",
"typescript": "5.8.3"
"tsup": "catalog:",
"typescript": "catalog:"
},
"keywords": [
"editor",
@@ -1,3 +0,0 @@
import { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const PARSER_KIT_ADDITIONAL_EXTENSIONS: Extensions = [];
@@ -1,13 +1,10 @@
import type { Extensions } from "@tiptap/core";
import React, { useMemo } from "react";
import React from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { WorkItemEmbedExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -21,7 +18,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
bubbleMenuEnabled = true,
containerClassName,
documentLoaderClassName,
extensions: externalExtensions = [],
extensions,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
@@ -47,20 +44,6 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
user,
} = props;
const extensions: Extensions = useMemo(() => {
const allExtensions = [...externalExtensions];
if (embedHandler?.issue) {
allExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
return allExtensions;
}, [externalExtensions, embedHandler.issue]);
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -39,13 +39,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
} = props;
const extensions: Extensions = useMemo(() => {
const additionalExtensions: Extensions = [];
if (embedHandler?.issue) {
additionalExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
additionalExtensions.push(
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
@@ -8,8 +8,6 @@ export * from "./mentions";
export * from "./slash-commands";
export * from "./table";
export * from "./typography";
export * from "./work-item-embed";
export * from "./core-without-props";
export * from "./custom-color";
export * from "./enter-key";
export * from "./extensions";
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// plane editor imports
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
import { PARSER_KIT_ADDITIONAL_EXTENSIONS } from "@/plane-editor/extensions/parser-kit";
// extensions
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
@@ -21,9 +21,8 @@ import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
export const CoreEditorExtensionsWithoutProps = [
export const PARSER_KIT = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -99,7 +98,5 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
...PARSER_KIT_ADDITIONAL_EXTENSIONS,
];
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
@@ -1,43 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export const WorkItemEmbedExtensionConfig = Node.create({
name: CORE_EXTENSIONS.WORK_ITEM_EMBED,
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
entity_identifier: {
default: undefined,
},
project_identifier: {
default: undefined,
},
workspace_identifier: {
default: undefined,
},
id: {
default: undefined,
},
entity_name: {
default: undefined,
},
};
},
parseHTML() {
return [
{
tag: "issue-embed-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -1,30 +0,0 @@
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
// local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config";
type Props = {
widgetCallback: ({
issueId,
projectId,
workspaceSlug,
}: {
issueId: string;
projectId: string | undefined;
workspaceSlug: string | undefined;
}) => React.ReactNode;
};
export const WorkItemEmbedExtension = (props: Props) =>
WorkItemEmbedExtensionConfig.extend({
addNodeView() {
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
<NodeViewWrapper>
{props.widgetCallback({
issueId: issueProps.node.attrs.entity_identifier,
projectId: issueProps.node.attrs.project_identifier,
workspaceSlug: issueProps.node.attrs.workspace_identifier,
})}
</NodeViewWrapper>
));
},
});
@@ -1 +0,0 @@
export * from "./extension";
@@ -1,4 +1,5 @@
import type { Editor, Range } from "@tiptap/core";
import type { Level } from "@tiptap/extension-heading";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
@@ -12,7 +13,7 @@ export const setText = (editor: Editor, range?: Range) => {
else editor.chain().focus().setNode(CORE_EXTENSIONS.PARAGRAPH).run();
};
export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => {
export const toggleHeading = (editor: Editor, level: Level, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).run();
else editor.chain().focus().toggleHeading({ level }).run();
};
+4 -6
View File
@@ -6,7 +6,7 @@ import {
replaceAdditionalAssetsInHTMLContent,
} from "@/plane-editor/helpers/parser";
// local imports
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
import { getAllDocumentFormatsFromHTMLString } from "./yjs-utils";
/**
* @description function to extract all assets from HTML content
@@ -62,10 +62,9 @@ export const getEditorContentWithReplacedAssets = async (props: {
entityId: string;
entityType: TEditorAssetType;
projectId: string | undefined;
variant: "rich" | "document";
duplicateAssetService: (params: TDuplicateAssetData) => Promise<TDuplicateAssetResponse>;
}): Promise<TDocumentPayload> => {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
const { descriptionHTML, entityId, entityType, projectId, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const assetIds = extractAssetsFromHTMLContent(descriptionHTML);
@@ -86,9 +85,8 @@ export const getEditorContentWithReplacedAssets = async (props: {
}
}
// step 4: convert the description to the document payload
const documentPayload = convertHTMLDocumentToAllFormats({
document_html: replacedDescription,
variant,
const documentPayload = getAllDocumentFormatsFromHTMLString({
descriptionHTML: replacedDescription,
});
return documentPayload;
};
+34 -109
View File
@@ -4,17 +4,10 @@ import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-pros
import * as Y from "yjs";
// extensions
import { TDocumentPayload } from "@plane/types";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
import { PARSER_KIT } from "@/extensions/parser-kit";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
// parser schema
const parserSchema = getSchema(PARSER_KIT);
/**
* @description apply updates to a doc and return the updated doc in binary format
@@ -49,56 +42,41 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
* @param {string} descriptionHTML
* @description Converts HTML string to binary data
* @returns {Uint8Array}
*/
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
export const getBinaryDataFromHTMLString = ({ descriptionHTML }: { descriptionHTML: string }): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", PARSER_KIT);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
const transformedData = prosemirrorJSONToYDoc(parserSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates the binary equivalent of html content for the document editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates all document formats for the provided binary data for the rich text editor
* @param {Uint8Array} description
* @description Converts binary data to all supported document formats (JSON, HTML, and binary)
* @returns
*/
export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
description: Uint8Array
): {
export const getAllDocumentFormatsFromBinaryData = ({
descriptionBinary,
}: {
descriptionBinary: Uint8Array;
}): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const base64Data = convertBinaryDataToBase64String(descriptionBinary);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
Y.applyUpdate(yDoc, descriptionBinary);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, parserSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);
const contentHTML = generateHTML(contentJSON, PARSER_KIT);
return {
contentBinaryEncoded: base64Data,
@@ -108,77 +86,24 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
};
/**
* @description this function generates all document formats for the provided binary data for the document editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
document_html: string;
variant: "rich" | "document";
};
/**
* @description Converts HTML content to all supported document formats (JSON, HTML, and binary)
* @param {TConvertHTMLDocumentToAllFormatsArgs} args - Arguments containing HTML content and variant type
* @param {string} args.document_html - The HTML content to convert
* @param {"rich" | "document"} args.variant - The type of editor variant to use for conversion
* @description Converts HTML string to all supported document formats (JSON, HTML, and binary)
* @returns {TDocumentPayload} Object containing the document in all supported formats
* @throws {Error} If an invalid variant is provided
*/
export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllFormatsArgs): TDocumentPayload => {
const { document_html, variant } = args;
export const getAllDocumentFormatsFromHTMLString = ({
descriptionHTML,
}: {
descriptionHTML: string;
}): TDocumentPayload => {
// Convert HTML to binary format for rich text editor
const contentBinary = getBinaryDataFromHTMLString({ descriptionHTML });
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData({
descriptionBinary: contentBinary,
});
let allFormats: TDocumentPayload;
if (variant === "rich") {
// Convert HTML to binary format for rich text editor
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
};
-1
View File
@@ -1,4 +1,3 @@
export * from "@/extensions/core-without-props";
export * from "@/constants/document-collaborative-events";
export * from "@/helpers/get-document-server-event";
export * from "@/helpers/yjs-utils";
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"files": [
"library.js",
@@ -18,6 +18,6 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^5.2.0",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
@@ -21,14 +21,14 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"react": "^18.3.1"
"react": "catalog:"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "8.4.0",
"typescript": "5.8.3"
"@types/react": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
@@ -25,8 +25,8 @@
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"@types/lodash": "^4.17.6",
"@types/react": "^18.3.11",
"typescript": "5.8.3"
"@types/lodash": "4.17.20",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
@@ -28,16 +28,15 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"express": "^4.21.2",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
"express-winston": "^4.2.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/express": "^4.17.21",
"@types/node": "^22.5.4",
"tsup": "8.4.0",
"typescript": "5.8.3"
"@types/node": "^20.14.9",
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+12 -64
View File
@@ -1,66 +1,14 @@
import path from "path";
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import { createLogger, format, LoggerOptions, transports } from "winston";
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define colors for each level
const colors = {
error: "red",
warn: "yellow",
info: "green",
http: "magenta",
debug: "white",
};
// Tell winston about our colors
winston.addColors(colors);
// Custom format for logging
const format = winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info: winston.Logform.TransformableInfo) => `[${info?.timestamp}] ${info.level}: ${info.message}`
)
);
// Define which transports to use
const transports = [
// Console transport
new winston.transports.Console(),
// Rotating file transport for errors
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "error-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
level: "error",
}),
// Rotating file transport for all logs
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "combined-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
}),
];
// Create the logger
export const logger = winston.createLogger({
export const loggerConfig: LoggerOptions = {
level: process.env.LOG_LEVEL || "info",
levels,
format,
transports,
});
format: format.combine(
format.timestamp({
format: "YYYY-MM-DD HH:mm:ss:ms",
}),
format.json()
),
transports: [new transports.Console()],
};
export const logger = createLogger(loggerConfig);
+10 -22
View File
@@ -1,23 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "./config";
import type { RequestHandler } from "express";
import expressWinston from "express-winston";
import { transports } from "winston";
import { loggerConfig } from "./config";
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
// Log when the request starts
const startTime = Date.now();
// Log request details
logger.http(`Incoming ${req.method} request to ${req.url} from ${req.ip}`);
// Log request body if present
if (Object.keys(req.body).length > 0) {
logger.debug("Request body:", req.body);
}
// Capture response
res.on("finish", () => {
const duration = Date.now() - startTime;
logger.http(`Completed ${req.method} ${req.url} with status ${res.statusCode} in ${duration}ms`);
});
next();
};
export const loggerMiddleware: RequestHandler = expressWinston.logger({
...loggerConfig,
transports: [new transports.Console()],
msg: "{{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
expressFormat: true,
});
+11 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "0.28.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -25,32 +25,33 @@
"./combobox": "./src/combobox/index.ts",
"./tooltip": "./src/tooltip/index.ts",
"./styles/fonts": "./src/styles/fonts/index.css",
"./card": "./src/card/index.ts",
"./switch": "./src/switch/index.ts"
"./switch": "./src/switch/index.ts",
"./accordion": "./src/accordion/index.ts",
"./card": "./src/card/index.ts"
},
"dependencies": {
"@base-ui-components/react": "^1.0.0-beta.2",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1"
"react": "catalog:",
"react-dom": "catalog:",
"recharts": "^2.15.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@storybook/react-vite": "^9.1.2",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"eslint-plugin-storybook": "^9.1.2",
"storybook": "^9.1.2",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
@@ -0,0 +1,88 @@
import { Accordion as BaseAccordion } from "@base-ui-components/react";
import { PlusIcon } from "lucide-react";
import * as React from "react";
interface AccordionRootProps {
defaultValue?: string[];
allowMultiple?: boolean;
className?: string;
children: React.ReactNode;
}
interface AccordionItemProps {
value: string;
disabled?: boolean;
className?: string;
children: React.ReactNode;
}
interface AccordionTriggerProps {
className?: string;
icon?: React.ReactNode;
children: React.ReactNode;
asChild?: boolean;
iconClassName?: string;
}
interface AccordionContentProps {
className?: string;
contentWrapperClassName?: string;
children: React.ReactNode;
}
const AccordionRoot: React.FC<AccordionRootProps> = ({
defaultValue = [],
allowMultiple = false,
className = "",
children,
}) => (
<BaseAccordion.Root defaultValue={defaultValue} openMultiple={allowMultiple} className={`text-base ${className}`}>
{children}
</BaseAccordion.Root>
);
const AccordionItem: React.FC<AccordionItemProps> = ({ value, disabled, className = "", children }) => (
<BaseAccordion.Item value={value} disabled={disabled} className={`relative ${className}`}>
{children}
</BaseAccordion.Item>
);
const AccordionTrigger: React.FC<AccordionTriggerProps> = ({
className = "",
icon = <PlusIcon aria-hidden="true" className="transition-all ease-out group-data-[panel-open]:rotate-45" />,
iconClassName = "",
children,
asChild = false,
}) => (
<BaseAccordion.Header>
{asChild ? (
<BaseAccordion.Trigger className={`w-full py-2 ${className}`}>{children}</BaseAccordion.Trigger>
) : (
<BaseAccordion.Trigger className={`flex w-full items-center justify-between gap-2 py-2 ${className}`}>
{children}
<span aria-hidden="true" className={`flex-shrink-0 ${iconClassName}`}>
{icon}
</span>
</BaseAccordion.Trigger>
)}
</BaseAccordion.Header>
);
const AccordionContent: React.FC<AccordionContentProps> = ({
className = "",
contentWrapperClassName = "",
children,
}) => (
<BaseAccordion.Panel
className={`h-[var(--accordion-panel-height)] overflow-hidden transition-[height] ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 ${className}`}
>
<div className={`py-2 ${contentWrapperClassName}`}>{children}</div>
</BaseAccordion.Panel>
);
export const Accordion = {
Root: AccordionRoot,
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
};
+1
View File
@@ -0,0 +1 @@
export * from "./accordion";
+1 -2
View File
@@ -1,7 +1,6 @@
import React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui-components/react/avatar";
// utils
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
import {
ECardDirection,
ECardSpacing,
+1 -1
View File
@@ -2,7 +2,7 @@
import React from "react";
// plane imports
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
import { cn } from "@plane/utils";
import { cn } from "../../utils/classname";
// Constants
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
@@ -2,7 +2,7 @@ import React from "react";
import { LegendProps } from "recharts";
// plane imports
import { TChartLegend } from "@plane/types";
import { cn } from "@plane/utils";
import { cn } from "../../utils/classname";
export const getLegendProps = (args: TChartLegend): LegendProps => {
const { align, layout, verticalAlign } = args;
@@ -2,8 +2,7 @@ import React from "react";
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
// plane imports
import { Card, ECardSpacing } from "../../card";
import { cn } from "@plane/utils";
import { cn } from "../../utils/classname";
type Props = {
active: boolean | undefined;
@@ -1,7 +1,7 @@
import React, { useMemo } from "react";
// plane imports
import { TBottomSectionConfig, TContentVisibility, TTopSectionConfig } from "@plane/types";
import { cn } from "@plane/utils";
import { cn } from "../../utils/classname";
const LAYOUT = {
PADDING: 2,
+1 -1
View File
@@ -2,8 +2,8 @@ import React from "react";
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TreeMapChartProps } from "@plane/types";
import { cn } from "@plane/utils";
// local imports
import { cn } from "../../utils/classname";
import { CustomTreeMapContent } from "./map-content";
import { TreeMapTooltip } from "./tooltip";
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { cn } from "@plane/utils";
import { Command } from "../command/command";
import { Popover } from "../popover/root";
import { cn } from "../utils/classname";
export interface ComboboxOption {
value: unknown;
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
function CommandComponent({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return <CommandPrimitive data-slot="command" className={cn("", className)} {...props} />;
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Dialog as BaseDialog } from "@base-ui-components/react";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
// enums
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { Menu as BaseMenu } from "@base-ui-components/react/menu";
import { ChevronDown, ChevronRight, MoreHorizontal } from "lucide-react";
// plane imports
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
import { TMenuProps, TSubMenuProps, TMenuItemProps } from "./types";
// Context for main menu to communicate with submenus
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Separator } from "./separator";
const meta: Meta<typeof Separator> = {
title: "Components/Separator",
component: Separator,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Separator>;
export const Default: Story = {
render: () => (
<div className="w-[300px] space-y-4">
<div>Content Above</div>
<Separator />
<div>Content Below</div>
</div>
),
};
export const Vertical: Story = {
render: () => (
<div className="flex h-[100px] items-center space-x-4">
<div>Left Content</div>
<Separator orientation="vertical" />
<div>Right Content</div>
</div>
),
};
export const WithinContainer: Story = {
render: () => (
<div className="w-[300px] rounded-lg border p-6 space-y-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Section 1</h4>
<p className="text-sm text-muted-foreground">Description for section 1</p>
</div>
<Separator />
<div className="space-y-2">
<h4 className="font-medium leading-none">Section 2</h4>
<p className="text-sm text-muted-foreground">Description for section 2</p>
</div>
</div>
),
};
@@ -0,0 +1,29 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "@base-ui-components/react/separator";
import { cn } from "../utils";
interface SeparatorProps extends React.ComponentProps<typeof SeparatorPrimitive> {
/**
* The orientation of the separator
* @default "horizontal"
*/
orientation?: "horizontal" | "vertical";
}
const Separator = React.forwardRef<React.ElementRef<typeof SeparatorPrimitive>, SeparatorProps>(
({ orientation = "horizontal", ...props }, ref) => (
<SeparatorPrimitive
ref={ref}
orientation={orientation}
data-slot="separator"
data-orientation={orientation}
{...props}
className={cn("bg-custom-border-200", "shrink-0", orientation === "horizontal" ? "h-px w-full" : "h-full w-px")}
/>
)
);
Separator.displayName = "Separator";
export { Separator };
export type { SeparatorProps };
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Switch as BaseSwitch } from "@base-ui-components/react/switch";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
interface IToggleSwitchProps {
value: boolean;
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
const Table = React.forwardRef<React.ComponentRef<"table">, React.ComponentPropsWithoutRef<"table">>(
({ className, ...props }, ref) => (
+1
View File
@@ -0,0 +1 @@
export * from "./tabs";
-58
View File
@@ -1,58 +0,0 @@
import React, { FC } from "react";
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
import { LucideProps } from "lucide-react";
// helpers
import { cn } from "@plane/utils";
export type TabListItem = {
key: string;
icon?: FC<LucideProps>;
label?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
};
type TTabListProps = {
tabs: TabListItem[];
tabListClassName?: string;
tabClassName?: string;
size?: "sm" | "md" | "lg";
selectedTab?: string;
};
export const TabList: FC<TTabListProps> = ({ tabs, tabListClassName, tabClassName, size = "md", selectedTab }) => (
<BaseTabs.List
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative",
tabListClassName
)}
>
{tabs.map((tab) => (
<BaseTabs.Tab
className={({ selected }) =>
cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all rounded",
(selectedTab ? selectedTab === tab.key : selected)
? "bg-custom-background-100 text-custom-text-100 shadow-sm"
: tab.disabled
? "text-custom-text-400 cursor-not-allowed"
: "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60",
{
"text-xs": size === "sm",
"text-sm": size === "md",
"text-base": size === "lg",
},
tabClassName
)
}
key={tab.key}
disabled={tab.disabled}
>
{tab.icon && <tab.icon className="size-4" />}
{tab.label}
</BaseTabs.Tab>
))}
<BaseTabs.Indicator className="absolute left-0 top-[50%] z-[-1] h-6 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] -translate-y-[50%] rounded-sm bg-custom-background-100 shadow-sm transition-[width,transform] duration-200 ease-in-out" />
</BaseTabs.List>
);
+66
View File
@@ -0,0 +1,66 @@
import { Fragment } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Tabs } from "./tabs";
const meta: Meta<typeof Tabs> = {
title: "Components/Tabs",
component: Tabs,
parameters: {
layout: "centered",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
render: () => (
<div className="w-[400px]">
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Overview</Tabs.Trigger>
<Tabs.Trigger value="password">Settings</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="account" className="p-4">
Overview settings go here
</Tabs.Content>
<Tabs.Content value="password" className="p-4">
Settings settings go here
</Tabs.Content>
</Tabs>
</div>
),
};
export const Sizes: Story = {
render: () => {
const sizes = ["sm", "md", "lg"] as const;
const labels = {
sm: "Small",
md: "Medium",
lg: "Large",
};
return (
<div className="w-[400px]">
{sizes.map((size, index) => (
<Fragment key={size}>
{index > 0 && <div className="h-4" />}
<div className="text-lg">{labels[size]}</div>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview" size={size}>
Overview
</Tabs.Trigger>
<Tabs.Trigger value="settings" size={size}>
Settings
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Fragment>
))}
</div>
);
},
};
+88 -83
View File
@@ -1,91 +1,96 @@
import React, { FC, useEffect, useState } from "react";
import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";
// helpers
import { useLocalStorage } from "@plane/hooks";
import { cn } from "@plane/utils";
// types
import { TabList, TabListItem } from "./list";
import * as React from "react";
import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs";
import { cn } from "../utils/classname";
export type TabContent = {
content: React.ReactNode;
type TabsCompound = React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Root> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.Root>>
> & {
List: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.List> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.List>>
>;
Trigger: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Tab> & { size?: "sm" | "md" | "lg" } & React.RefAttributes<
React.ElementRef<typeof TabsPrimitive.Tab>
>
>;
Content: React.ForwardRefExoticComponent<
React.ComponentProps<typeof TabsPrimitive.Panel> & React.RefAttributes<React.ElementRef<typeof TabsPrimitive.Panel>>
>;
Indicator: React.ForwardRefExoticComponent<React.ComponentProps<"div"> & React.RefAttributes<HTMLDivElement>>;
};
export type TabItem = TabListItem & TabContent;
const TabsRoot = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentProps<typeof TabsPrimitive.Root>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col w-full h-full", className)} {...props} ref={ref} />
));
type TTabsProps = {
tabs: TabItem[];
storageKey?: string;
actions?: React.ReactNode;
defaultTab?: string;
containerClassName?: string;
tabListContainerClassName?: string;
tabListClassName?: string;
tabClassName?: string;
tabPanelClassName?: string;
size?: "sm" | "md" | "lg";
storeInLocalStorage?: boolean;
};
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentProps<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
className
)}
{...props}
ref={ref}
/>
));
export const Tabs: FC<TTabsProps> = (props: TTabsProps) => {
const {
tabs,
storageKey,
actions,
defaultTab = tabs[0]?.key,
containerClassName = "",
tabListContainerClassName = "",
tabListClassName = "",
tabClassName = "",
tabPanelClassName = "",
size = "md",
storeInLocalStorage = true,
} = props;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Tab>,
React.ComponentProps<typeof TabsPrimitive.Tab> & { size?: "sm" | "md" | "lg" }
>(({ className, size = "md", ...props }, ref) => (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all duration-200 ease-in-out rounded",
"data-[selected]:bg-custom-background-100 data-[selected]:text-custom-text-100 data-[selected]:shadow-sm",
"text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60",
"disabled:text-custom-text-400 disabled:cursor-not-allowed",
{
"text-xs": size === "sm",
"text-sm": size === "md",
"text-base": size === "lg",
},
className
)}
{...props}
ref={ref}
/>
));
const { storedValue, setValue } = useLocalStorage(
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
defaultTab
);
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Panel>,
React.ComponentProps<typeof TabsPrimitive.Panel>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("relative outline-none", className)}
{...props}
ref={ref}
/>
));
const TabsIndicator = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => (
<div
className={cn(
"absolute left-0 top-[50%] z-[-1] h-6 w-[var(--active-tab-width)] translate-x-[var(--active-tab-left)] -translate-y-[50%] rounded-sm bg-custom-background-100 shadow-sm transition-[width,transform] duration-200 ease-in-out",
className
)}
{...props}
ref={ref}
/>
));
const [activeIndex, setActiveIndex] = useState(() => {
const initialTab = storedValue ?? defaultTab;
return tabs.findIndex((tab) => tab.key === initialTab);
});
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
Indicator: TabsIndicator,
}) satisfies TabsCompound;
useEffect(() => {
if (storeInLocalStorage && tabs[activeIndex]) {
setValue(tabs[activeIndex].key);
}
}, [activeIndex, setValue, storeInLocalStorage, tabs]);
const handleTabChange = (index: number) => {
setActiveIndex(index);
if (!tabs[index].disabled) {
tabs[index].onClick?.();
}
};
return (
<BaseTabs.Root
value={activeIndex}
onValueChange={handleTabChange}
className={cn("flex flex-col w-full h-full overflow-hidden", containerClassName)}
>
<div className={cn("flex w-full items-center gap-4", tabListContainerClassName)}>
<TabList
tabs={tabs}
tabListClassName={tabListClassName}
tabClassName={tabClassName}
size={size}
selectedTab={tabs[activeIndex]?.key}
/>
{actions && <div className="flex-grow">{actions}</div>}
</div>
{tabs.map((tab) => (
<BaseTabs.Panel key={tab.key} className={cn("relative h-full overflow-auto", tabPanelClassName)}>
{tab.content}
</BaseTabs.Panel>
))}
</BaseTabs.Root>
);
};
export { TabsList, TabsTrigger, TabsContent, TabsIndicator };
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Tooltip as BaseTooltip } from "@base-ui-components/react/tooltip";
import { cn } from "@plane/utils";
import { cn } from "../utils/classname";
import { TPlacement, TSide, TAlign, convertPlacementToSideAndAlign } from "../utils/placement";
type ITooltipProps = {
+3 -2
View File
@@ -1,3 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import clsx, { type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => clsx(inputs);
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"main": "./src/index.ts",
@@ -22,7 +22,7 @@
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"tsup": "8.4.0",
"typescript": "5.8.3"
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
@@ -22,6 +22,6 @@
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"typescript": "5.8.3"
"typescript": "catalog:"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"files": [
@@ -27,15 +27,15 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"tsup": "8.4.0",
"typescript": "5.8.3"
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.28.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"files": [
+8 -8
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.28.0",
"version": "1.0.0",
"sideEffects": false,
"license": "AGPL-3.0",
"files": [
@@ -25,8 +25,8 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react": "catalog:",
"react-dom": "catalog:"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
@@ -66,16 +66,16 @@
"@storybook/react": "^8.1.1",
"@storybook/react-webpack5": "^8.1.1",
"@storybook/test": "^8.1.1",
"@types/lodash": "^4.17.6",
"@types/lodash": "4.17.20",
"@types/node": "^20.5.2",
"@types/react": "^18.3.11",
"@types/react": "catalog:",
"@types/react-color": "^3.0.9",
"@types/react-dom": "^18.2.18",
"@types/react-dom": "catalog:",
"autoprefixer": "^10.4.19",
"postcss-cli": "^11.0.0",
"postcss-nested": "^6.0.1",
"storybook": "^8.1.1",
"tsup": "8.4.0",
"typescript": "5.8.3"
"tsup": "catalog:",
"typescript": "catalog:"
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.28.0",
"version": "1.0.0",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,
@@ -28,7 +28,7 @@
"isomorphic-dompurify": "^2.16.0",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react": "catalog:",
"tailwind-merge": "^2.5.5",
"tlds": "1.259.0",
"uuid": "^10.0.0"
@@ -36,11 +36,11 @@
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "^4.17.6",
"@types/lodash": "4.17.20",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"@types/react": "catalog:",
"@types/uuid": "^9.0.8",
"tsup": "8.4.0",
"typescript": "5.8.3"
"tsup": "catalog:",
"typescript": "catalog:"
}
}

Some files were not shown because too many files have changed in this diff Show More