diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index acd9348d2e..087a012d40 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -35,6 +35,10 @@ on: - preview - canary +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: TARGET_BRANCH: ${{ github.ref_name }} ARM64_BUILD: ${{ github.event.inputs.arm64 }} @@ -268,15 +272,14 @@ jobs: if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }} name: Build-Push AIO Docker Image runs-on: ubuntu-22.04 - needs: [ - branch_build_setup, - branch_build_push_admin, - branch_build_push_web, - branch_build_push_space, - branch_build_push_live, - branch_build_push_api, - branch_build_push_proxy - ] + needs: + - branch_build_setup + - branch_build_push_admin + - branch_build_push_web + - branch_build_push_space + - branch_build_push_live + - branch_build_push_api + - branch_build_push_proxy steps: - name: Checkout Files uses: actions/checkout@v4 @@ -285,7 +288,7 @@ jobs: id: prepare_aio_assets run: | cd deployments/aio/community - + if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then aio_version=${{ needs.branch_build_setup.outputs.release_version }} else @@ -324,7 +327,14 @@ jobs: upload_build_assets: name: Upload Build Assets runs-on: ubuntu-22.04 - needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy] + needs: + - branch_build_setup + - branch_build_push_admin + - branch_build_push_web + - branch_build_push_space + - branch_build_push_live + - branch_build_push_api + - branch_build_push_proxy steps: - name: Checkout Files uses: actions/checkout@v4 @@ -397,4 +407,3 @@ jobs: ${{ github.workspace }}/deployments/cli/community/docker-compose.yml ${{ github.workspace }}/deployments/cli/community/variables.env ${{ github.workspace }}/deployments/swarm/community/swarm.sh - diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml index ca8b6f8b3e..855ee359fe 100644 --- a/.github/workflows/check-version.yml +++ b/.github/workflows/check-version.yml @@ -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 diff --git a/.github/workflows/pull-request-build-lint-api.yml b/.github/workflows/pull-request-build-lint-api.yml index fdeb492f08..50d105ef56 100644 --- a/.github/workflows/pull-request-build-lint-api.yml +++ b/.github/workflows/pull-request-build-lint-api.yml @@ -3,11 +3,21 @@ name: Build and lint API on: workflow_dispatch: pull_request: - branches: ["preview"] - types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"] + branches: + - "preview" + types: + - "opened" + - "synchronize" + - "ready_for_review" + - "review_requested" + - "reopened" paths: - "apps/api/**" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint-api: name: Lint API diff --git a/.github/workflows/pull-request-build-lint-web-apps.yml b/.github/workflows/pull-request-build-lint-web-apps.yml index 830307822e..435ec2093b 100644 --- a/.github/workflows/pull-request-build-lint-web-apps.yml +++ b/.github/workflows/pull-request-build-lint-web-apps.yml @@ -3,21 +3,18 @@ name: Build and lint web apps on: workflow_dispatch: pull_request: - branches: ["preview"] + branches: + - "preview" types: - [ - "opened", - "synchronize", - "ready_for_review", - "review_requested", - "reopened", - ] - paths: - - "**.tsx?" - - "**.jsx?" - - "**.css" - - "**.json" - - "!apps/api/**" + - "opened" + - "synchronize" + - "ready_for_review" + - "review_requested" + - "reopened" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build-and-lint: @@ -27,16 +24,18 @@ jobs: if: | github.event.pull_request.draft == false && github.event.pull_request.requested_reviewers != null + env: + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} + TURBO_SCM_HEAD: ${{ github.sha }} steps: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 50 + filter: blob:none - name: Set up Node.js uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - name: Enable Corepack and pnpm run: corepack enable pnpm @@ -44,11 +43,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint web apps - run: pnpm run check:lint + - name: Lint Affected + run: pnpm turbo run check:lint --affected - - name: Check format - run: pnpm run check:format + - name: Check Affected format + run: pnpm turbo run check:format --affected - - name: Build apps - run: pnpm run build + - name: Build Affected + run: pnpm turbo run build --affected diff --git a/.gitignore b/.gitignore index a4917dedfe..4baa3495a9 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,5 @@ dev-editor # Redis *.rdb *.rdb.gz + +storybook-static diff --git a/.npmrc b/.npmrc index 85ec104c95..d652acc3b5 100644 --- a/.npmrc +++ b/.npmrc @@ -14,13 +14,10 @@ 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 -# Enforce Node version for consistent installs -use-node-version=22.18.0 - # Reproducible installs across CI and dev prefer-frozen-lockfile=true diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index deed13c016..0000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -lts/jod diff --git a/apps/admin/.eslintignore b/apps/admin/.eslintignore new file mode 100644 index 0000000000..27e50ad7c6 --- /dev/null +++ b/apps/admin/.eslintignore @@ -0,0 +1,12 @@ +.next/* +out/* +public/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/admin/.eslintrc.js b/apps/admin/.eslintrc.js index 666f5ab50c..1662fabf75 100644 --- a/apps/admin/.eslintrc.js +++ b/apps/admin/.eslintrc.js @@ -1,5 +1,4 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], - parser: "@typescript-eslint/parser", }; diff --git a/apps/admin/app/(all)/(dashboard)/email/page.tsx b/apps/admin/app/(all)/(dashboard)/email/page.tsx index 445ff2ec72..792bafe35a 100644 --- a/apps/admin/app/(all)/(dashboard)/email/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/page.tsx @@ -9,7 +9,7 @@ import { useInstance } from "@/hooks/store"; // components import { InstanceEmailForm } from "./email-config-form"; -const InstanceEmailPage = observer(() => { +const InstanceEmailPage: React.FC = observer(() => { // store const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance(); @@ -29,7 +29,7 @@ const InstanceEmailPage = observer(() => { message: "Email feature has been disabled", type: TOAST_TYPE.SUCCESS, }); - } catch (error) { + } catch (_error) { setToast({ title: "Error disabling email", message: "Failed to disable email feature. Please try again.", diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index cedc735a91..c4421ae5fd 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -7,7 +7,8 @@ import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; // plane internal packages import { WEB_BASE_URL } from "@plane/constants"; -import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; +import { DiscordIcon, GithubIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx index e536a51454..b33ccecffb 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; // plane internal packages -import { Tooltip, WorkspaceIcon } from "@plane/ui"; +import { WorkspaceIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(home)/auth-header.tsx b/apps/admin/app/(all)/(home)/auth-header.tsx index 50fa066cd8..115c853814 100644 --- a/apps/admin/app/(all)/(home)/auth-header.tsx +++ b/apps/admin/app/(all)/(home)/auth-header.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { PlaneLockup } from "@plane/ui"; +import { PlaneLockup } from "@plane/propel/icons"; export const AuthHeader = () => (
diff --git a/apps/admin/core/components/authentication/email-config-switch.tsx b/apps/admin/core/components/authentication/email-config-switch.tsx index 783810e2fb..16eb987049 100644 --- a/apps/admin/core/components/authentication/email-config-switch.tsx +++ b/apps/admin/core/components/authentication/email-config-switch.tsx @@ -25,9 +25,8 @@ export const EmailCodesConfiguration: React.FC = observer((props) => { { - Boolean(parseInt(enableMagicLogin)) === true - ? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0") - : updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1"); + const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1"; + updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin); }} size="sm" disabled={disabled} diff --git a/apps/admin/core/components/authentication/github-config.tsx b/apps/admin/core/components/authentication/github-config.tsx index 57035580f6..249f1ebc43 100644 --- a/apps/admin/core/components/authentication/github-config.tsx +++ b/apps/admin/core/components/authentication/github-config.tsx @@ -35,9 +35,8 @@ export const GithubConfiguration: React.FC = observer((props) => { { - Boolean(parseInt(enableGithubConfig)) === true - ? updateConfig("IS_GITHUB_ENABLED", "0") - : updateConfig("IS_GITHUB_ENABLED", "1"); + const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1"; + updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig); }} size="sm" disabled={disabled} diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx index 4181338d21..f5586f3f31 100644 --- a/apps/admin/core/components/authentication/gitlab-config.tsx +++ b/apps/admin/core/components/authentication/gitlab-config.tsx @@ -35,9 +35,8 @@ export const GitlabConfiguration: React.FC = observer((props) => { { - Boolean(parseInt(enableGitlabConfig)) === true - ? updateConfig("IS_GITLAB_ENABLED", "0") - : updateConfig("IS_GITLAB_ENABLED", "1"); + const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1"; + updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig); }} size="sm" disabled={disabled} diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx index 0f3cc98e38..ec7501b345 100644 --- a/apps/admin/core/components/authentication/google-config.tsx +++ b/apps/admin/core/components/authentication/google-config.tsx @@ -35,9 +35,8 @@ export const GoogleConfiguration: React.FC = observer((props) => { { - Boolean(parseInt(enableGoogleConfig)) === true - ? updateConfig("IS_GOOGLE_ENABLED", "0") - : updateConfig("IS_GOOGLE_ENABLED", "1"); + const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1"; + updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig); }} size="sm" disabled={disabled} diff --git a/apps/admin/core/components/authentication/password-config-switch.tsx b/apps/admin/core/components/authentication/password-config-switch.tsx index 00aa628252..5cbd9b03c2 100644 --- a/apps/admin/core/components/authentication/password-config-switch.tsx +++ b/apps/admin/core/components/authentication/password-config-switch.tsx @@ -25,9 +25,8 @@ export const PasswordLoginConfiguration: React.FC = observer((props) => { { - Boolean(parseInt(enableEmailPassword)) === true - ? updateConfig("ENABLE_EMAIL_PASSWORD", "0") - : updateConfig("ENABLE_EMAIL_PASSWORD", "1"); + const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1"; + updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword); }} size="sm" disabled={disabled} diff --git a/apps/admin/core/components/common/breadcrumb-link.tsx b/apps/admin/core/components/common/breadcrumb-link.tsx index d5a00ccaa5..567b88d923 100644 --- a/apps/admin/core/components/common/breadcrumb-link.tsx +++ b/apps/admin/core/components/common/breadcrumb-link.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; type Props = { label?: string; diff --git a/apps/admin/core/components/workspace/list-item.tsx b/apps/admin/core/components/workspace/list-item.tsx index ae693eb728..85a2b3c61e 100644 --- a/apps/admin/core/components/workspace/list-item.tsx +++ b/apps/admin/core/components/workspace/list-item.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { ExternalLink } from "lucide-react"; // plane internal packages import { WEB_BASE_URL } from "@plane/constants"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { getFileURL } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store"; diff --git a/apps/admin/core/store/instance.store.ts b/apps/admin/core/store/instance.store.ts index 1179f04d6f..764c95bf23 100644 --- a/apps/admin/core/store/instance.store.ts +++ b/apps/admin/core/store/instance.store.ts @@ -209,7 +209,7 @@ export class InstanceStore implements IInstanceStore { }); }); await this.instanceService.disableEmail(); - } catch (error) { + } catch (_error) { console.error("Error disabling the email"); this.instanceConfigurations = instanceConfigurations; } diff --git a/apps/admin/package.json b/apps/admin/package.json index d82ed71705..18acfb50f3 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -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": { @@ -26,30 +26,30 @@ "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", "autoprefixer": "10.4.14", - "axios": "1.11.0", - "lodash": "^4.17.21", - "lucide-react": "^0.469.0", - "mobx": "^6.12.0", - "mobx-react": "^9.1.1", - "next": "14.2.30", + "axios": "catalog:", + "lodash": "catalog:", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "next": "catalog:", "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", - "uuid": "^9.0.1" + "sharp": "catalog:", + "swr": "catalog:", + "uuid": "catalog:" }, "devDependencies": { "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", - "@types/lodash": "^4.17.6", + "@types/lodash": "catalog:", "@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:" } } diff --git a/apps/api/package.json b/apps/api/package.json index 6b374e6116..97122880ff 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py index 4f89a98c7c..46bd398bc1 100644 --- a/apps/api/plane/api/serializers/base.py +++ b/apps/api/plane/api/serializers/base.py @@ -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, diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index 69c3562465..075823cbfe 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -24,7 +24,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_json_content, validate_binary_data, ) @@ -89,20 +88,24 @@ class IssueSerializer(BaseSerializer): raise serializers.ValidationError("Invalid HTML passed") # Validate description content for security - if data.get("description"): - is_valid, error_msg = validate_json_content(data["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - if data.get("description_html"): - is_valid, error_msg = validate_html_content(data["description_html"]) + is_valid, error_msg, sanitized_html = validate_html_content( + data["description_html"] + ) if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html if data.get("description_binary"): is_valid, error_msg = validate_binary_data(data["description_binary"]) if not is_valid: - raise serializers.ValidationError({"description_binary": error_msg}) + raise serializers.ValidationError( + {"description_binary": "Invalid binary data"} + ) # Validate assignees are from project if data.get("assignees", []): diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index e6a257f3eb..d860c46b2d 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -12,7 +12,6 @@ from plane.db.models import ( from plane.utils.content_validator import ( validate_html_content, - validate_json_content, ) from .base import BaseSerializer @@ -200,27 +199,18 @@ class ProjectSerializer(BaseSerializer): ) # Validate description content for security - if "description" in data and data["description"]: - # For Project, description might be text field, not JSON - if isinstance(data["description"], dict): - is_valid, error_msg = validate_json_content(data["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - - if "description_text" in data and data["description_text"]: - is_valid, error_msg = validate_json_content(data["description_text"]) - if not is_valid: - raise serializers.ValidationError({"description_text": error_msg}) - if "description_html" in data and data["description_html"]: if isinstance(data["description_html"], dict): - is_valid, error_msg = validate_json_content(data["description_html"]) - else: - is_valid, error_msg = validate_html_content( + is_valid, error_msg, sanitized_html = validate_html_content( str(data["description_html"]) ) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) return data diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py index 7ba12a2e26..881088a3fb 100644 --- a/apps/api/plane/app/permissions/base.py +++ b/apps/api/plane/app/permissions/base.py @@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): ).exists(): return view_func(instance, request, *args, **kwargs) else: - if ProjectMember.objects.filter( + is_user_has_allowed_role = ProjectMember.objects.filter( member=request.user, workspace__slug=kwargs["slug"], project_id=kwargs["project_id"], role__in=allowed_role_values, is_active=True, - ).exists(): + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): return view_func(instance, request, *args, **kwargs) # Return permission denied if no conditions are met diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index 1596d90b37..e095ffed48 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission # Module import from plane.db.models import ProjectMember, WorkspaceMember - -# Permission Mappings -Admin = 20 -Member = 15 -Guest = 5 +from plane.db.models.project import ROLE class ProjectBasePermission(BasePermission): @@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() - ## Only Project Admins can update project attributes - return ProjectMember.objects.filter( + project_member_qs = ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role=Admin, project_id=view.project_id, is_active=True, - ).exists() + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) class ProjectMemberPermission(BasePermission): @@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() @@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() @@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py index 38fa65527b..852caf8bf3 100644 --- a/apps/api/plane/app/serializers/draft.py +++ b/apps/api/plane/app/serializers/draft.py @@ -23,7 +23,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_json_content, validate_binary_data, ) from plane.app.permissions import ROLE @@ -76,20 +75,24 @@ class DraftIssueCreateSerializer(BaseSerializer): raise serializers.ValidationError("Start date cannot exceed target date") # Validate description content for security - if "description" in attrs and attrs["description"]: - is_valid, error_msg = validate_json_content(attrs["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - if "description_html" in attrs and attrs["description_html"]: - is_valid, error_msg = validate_html_content(attrs["description_html"]) + is_valid, error_msg, sanitized_html = validate_html_content( + attrs["description_html"] + ) if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) + # Update the attrs with sanitized HTML if available + if sanitized_html is not None: + attrs["description_html"] = sanitized_html if "description_binary" in attrs and attrs["description_binary"]: is_valid, error_msg = validate_binary_data(attrs["description_binary"]) if not is_valid: - raise serializers.ValidationError({"description_binary": error_msg}) + raise serializers.ValidationError( + {"description_binary": "Invalid binary data"} + ) # Validate assignees are from project if attrs.get("assignee_ids", []): diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index d002de3390..8a643ce4dc 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -43,7 +43,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_json_content, validate_binary_data, ) @@ -128,20 +127,24 @@ class IssueCreateSerializer(BaseSerializer): raise serializers.ValidationError("Start date cannot exceed target date") # Validate description content for security - if "description" in attrs and attrs["description"]: - is_valid, error_msg = validate_json_content(attrs["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - if "description_html" in attrs and attrs["description_html"]: - is_valid, error_msg = validate_html_content(attrs["description_html"]) + is_valid, error_msg, sanitized_html = validate_html_content( + attrs["description_html"] + ) if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) + # Update the attrs with sanitized HTML if available + if sanitized_html is not None: + attrs["description_html"] = sanitized_html if "description_binary" in attrs and attrs["description_binary"]: is_valid, error_msg = validate_binary_data(attrs["description_binary"]) if not is_valid: - raise serializers.ValidationError({"description_binary": error_msg}) + raise serializers.ValidationError( + {"description_binary": "Invalid binary data"} + ) # Validate assignees are from project if attrs.get("assignee_ids", []): @@ -664,16 +667,33 @@ class IssueReactionSerializer(BaseSerializer): class IssueReactionLiteSerializer(DynamicBaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = IssueReaction - fields = ["id", "actor", "issue", "reaction"] + fields = ["id", "actor", "issue", "reaction", "display_name"] class CommentReactionSerializer(BaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] + fields = [ + "id", + "actor", + "comment", + "reaction", + "display_name", + "deleted_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"] class IssueVoteSerializer(BaseSerializer): @@ -908,9 +928,14 @@ class IssueLiteSerializer(DynamicBaseSerializer): class IssueDetailSerializer(IssueSerializer): description_html = serializers.CharField() is_subscribed = serializers.BooleanField(read_only=True) + is_intake = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + "is_intake", + ] read_only_fields = fields diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index 78762e4b4e..9ac6cc414f 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -7,7 +7,6 @@ from .base import BaseSerializer from plane.utils.content_validator import ( validate_binary_data, validate_html_content, - validate_json_content, ) from plane.db.models import ( Page, @@ -229,23 +228,13 @@ class PageBinaryUpdateSerializer(serializers.Serializer): return value # Use the validation function from utils - is_valid, error_message = validate_html_content(value) + is_valid, error_message, sanitized_html = validate_html_content(value) if not is_valid: raise serializers.ValidationError(error_message) - return value + # Return sanitized HTML if available, otherwise return original + return sanitized_html if sanitized_html is not None else value - def validate_description(self, value): - """Validate the JSON description""" - if not value: - return value - - # Use the validation function from utils - is_valid, error_message = validate_json_content(value) - if not is_valid: - raise serializers.ValidationError(error_message) - - return value def update(self, instance, validated_data): """Update the page instance with validated data""" diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index dfa541d9f1..76f76d0e0f 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -15,8 +15,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_json_content, - validate_binary_data, ) @@ -65,27 +63,18 @@ class ProjectSerializer(BaseSerializer): def validate(self, data): # Validate description content for security - if "description" in data and data["description"]: - # For Project, description might be text field, not JSON - if isinstance(data["description"], dict): - is_valid, error_msg = validate_json_content(data["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - - if "description_text" in data and data["description_text"]: - is_valid, error_msg = validate_json_content(data["description_text"]) - if not is_valid: - raise serializers.ValidationError({"description_text": error_msg}) - if "description_html" in data and data["description_html"]: - if isinstance(data["description_html"], dict): - is_valid, error_msg = validate_json_content(data["description_html"]) - else: - is_valid, error_msg = validate_html_content( - str(data["description_html"]) - ) + is_valid, error_msg, sanitized_html = validate_html_content( + str(data["description_html"]) + ) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) return data diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index ec4c4bf63e..6b22f59e83 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -26,7 +26,6 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.utils.url import contains_url from plane.utils.content_validator import ( validate_html_content, - validate_json_content, validate_binary_data, ) @@ -319,20 +318,24 @@ class StickySerializer(BaseSerializer): def validate(self, data): # Validate description content for security - if "description" in data and data["description"]: - is_valid, error_msg = validate_json_content(data["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - if "description_html" in data and data["description_html"]: - is_valid, error_msg = validate_html_content(data["description_html"]) + is_valid, error_msg, sanitized_html = validate_html_content( + data["description_html"] + ) if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html if "description_binary" in data and data["description_binary"]: is_valid, error_msg = validate_binary_data(data["description_binary"]) if not is_valid: - raise serializers.ValidationError({"description_binary": error_msg}) + raise serializers.ValidationError( + {"description_binary": "Invalid binary data"} + ) return data diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 7e0c14fdd8..b699496218 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -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) diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 21e5eaf709..4d0d4457ea 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -51,6 +51,7 @@ from plane.db.models import ( IssueRelation, IssueAssignee, IssueLabel, + IntakeIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -1223,7 +1224,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): # Fetch the issue issue = ( - Issue.issue_objects.filter(project_id=project.id) + Issue.objects.filter(project_id=project.id) .filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -1315,6 +1316,16 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): ) ) ) + .annotate( + is_intake=Exists( + IntakeIssue.objects.filter( + issue=OuterRef("id"), + status__in=[-2, 0], + workspace__slug=slug, + project_id=project.id, + ) + ) + ) ).first() # Check if the issue exists diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index 96de81abfb..e4ee1890b7 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -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) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index b4ee113c46..d4eeca2f7b 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -5,13 +5,12 @@ from django.utils import timezone import json # Django imports -from django.db import IntegrityError from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import serializers, status +from rest_framework import status from rest_framework.permissions import AllowAny # Module imports @@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet): # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.data["id"], + member=request.user, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user _ = IssueUserProperty.objects.create( @@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet): ProjectMember.objects.create( project_id=serializer.data["id"], member_id=serializer.data["project_lead"], - role=20, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user IssueUserProperty.objects.create( @@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet): def partial_update(self, request, slug, pk=None): # try: - if not ProjectMember.objects.filter( + is_workspace_admin = WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, + ).exists() + + is_project_admin = ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, - ).exists(): + ).exists() + + # Return error for if the user is neither workspace admin nor project admin + if not is_project_admin and not is_workspace_admin: return Response( {"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN, @@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet): def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=20 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, ).exists() or ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, ).exists() ): diff --git a/apps/api/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py index ed826782a7..b3bce1eda0 100644 --- a/apps/api/plane/app/views/search/issue.py +++ b/apps/api/plane/app/views/search/issue.py @@ -59,9 +59,10 @@ class IssueSearchEndpoint(BaseAPIView): ) related_issue_ids = [item for sublist in related_issue_ids for item in sublist] + related_issue_ids.append(issue_id) if issue: - issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids)) + issues = issues.exclude(pk__in=related_issue_ids) return issues diff --git a/apps/api/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py index a5e61d6b47..e4b032725b 100644 --- a/apps/api/plane/app/views/workspace/draft.py +++ b/apps/api/plane/app/views/workspace/draft.py @@ -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"), }, ) diff --git a/apps/api/plane/app/views/workspace/state.py b/apps/api/plane/app/views/workspace/state.py index 3a7d767fa1..3bfc8d22de 100644 --- a/apps/api/plane/app/views/workspace/state.py +++ b/apps/api/plane/app/views/workspace/state.py @@ -7,7 +7,6 @@ from plane.app.serializers import StateSerializer from plane.app.views.base import BaseAPIView from plane.db.models import State from plane.app.permissions import WorkspaceEntityPermission -from plane.utils.cache import cache_response from collections import defaultdict @@ -15,7 +14,6 @@ class WorkspaceStatesEndpoint(BaseAPIView): permission_classes = [WorkspaceEntityPermission] use_read_replica = True - @cache_response(60 * 60 * 2) def get(self, request, slug): states = State.objects.filter( workspace__slug=slug, diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 0ac51265e0..417e7b40ea 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -19,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignInAuthEndpoint(View): @@ -34,11 +31,11 @@ class SignInAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) # Base URL join - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -58,10 +55,10 @@ class SignInAuthEndpoint(View): ) params = exc.get_error_dict() # Next path - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -76,10 +73,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -92,10 +89,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -112,19 +109,23 @@ class SignInAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Validate the email @@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py index 18cbe7b6c7..35c4d2121b 100644 --- a/apps/api/plane/authentication/views/app/github.py +++ b/apps/api/plane/authentication/views/app/github.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,8 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path - +from plane.utils.path_validator import get_safe_redirect_url class GitHubOauthInitiateEndpoint(View): def get(self, request): @@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -61,7 +60,6 @@ class GitHubCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -70,9 +68,11 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,9 +81,11 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -93,17 +95,23 @@ class GitHubCallbackEndpoint(View): user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) - # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host, path) + + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py index d6479e9549..b2e5da80f1 100644 --- a/apps/api/plane/authentication/views/app/gitlab.py +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitLabOauthInitiateEndpoint(View): @@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View): request.session["host"] = base_host(request=request, is_app=True) next_path = request.GET.get("next_path") if next_path: - request.session["next_path"] = str(validate_next_path(next_path)) + request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -70,9 +69,11 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,9 +82,11 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -94,16 +97,23 @@ class GitLabCallbackEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path + if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host, path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py index 66b6f7662d..cfa409ae51 100644 --- a/apps/api/plane/authentication/views/app/google.py +++ b/apps/api/plane/authentication/views/app/google.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -18,7 +17,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GoogleOauthInitiateEndpoint(View): @@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -63,7 +62,6 @@ class GoogleCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -72,9 +70,11 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -82,9 +82,11 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider( @@ -94,15 +96,21 @@ class GoogleCallbackEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path - path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin( - base_host, str(validate_next_path(next_path)) if next_path else path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 4b1bdb02e2..694fca6cb7 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -26,7 +23,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, ) from plane.authentication.rate_limit import AuthenticationThrottle -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class MagicGenerateEndpoint(APIView): @@ -72,10 +69,10 @@ class MagicSignInEndpoint(View): error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -88,10 +85,10 @@ class MagicSignInEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -107,7 +104,8 @@ class MagicSignInEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) if user.is_password_autoset and profile.is_onboarded: - path = "accounts/set-password" + # Redirect to the home page + path = "/" else: # Get the redirection path path = ( @@ -116,15 +114,19 @@ class MagicSignInEndpoint(View): else str(get_redirection_path(user=user)) ) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -144,10 +146,10 @@ class MagicSignUpEndpoint(View): error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Existing user @@ -158,10 +160,10 @@ class MagicSignUpEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -177,18 +179,22 @@ class MagicSignUpEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 6fa2d45174..cd0954db83 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -17,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignInAuthSpaceEndpoint(View): @@ -32,9 +29,11 @@ class SignInAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # set the referer as session to redirect after login @@ -51,9 +50,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Validate email @@ -67,9 +68,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Existing User @@ -82,9 +85,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -95,13 +100,19 @@ class SignInAuthSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to next path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -117,9 +128,11 @@ class SignUpAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) email = request.POST.get("email", False) @@ -135,9 +148,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Validate the email email = email.strip().lower() @@ -151,9 +166,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Existing User @@ -166,9 +183,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -179,11 +198,17 @@ class SignUpAuthSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index fec71cb48b..e3b64e8a0d 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitHubOauthInitiateSpaceEndpoint(View): @@ -23,9 +22,6 @@ class GitHubOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -34,9 +30,11 @@ class GitHubOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +45,11 @@ class GitHubOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -66,9 +66,11 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +79,11 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +93,17 @@ class GitHubCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 4bdcf9514e..a63466005f 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitLabOauthInitiateSpaceEndpoint(View): @@ -23,8 +22,6 @@ class GitLabOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -34,9 +31,11 @@ class GitLabOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +46,11 @@ class GitLabOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -66,9 +67,11 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +80,11 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +94,17 @@ class GitLabCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index 03ad977935..7b9728762f 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,15 +14,13 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GoogleOauthInitiateSpaceEndpoint(View): def get(self, request): request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -33,9 +30,11 @@ class GoogleOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -46,9 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -65,9 +66,11 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -75,9 +78,11 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider(request=request, code=code) @@ -85,11 +90,17 @@ class GoogleCallbackSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index d230af7edf..0a5f2b42c9 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -23,7 +20,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class MagicGenerateSpaceEndpoint(APIView): @@ -66,9 +63,11 @@ class MagicSignInSpaceEndpoint(View): error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) existing_user = User.objects.filter(email=email).first() @@ -79,9 +78,11 @@ class MagicSignInSpaceEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) # Active User @@ -93,15 +94,18 @@ class MagicSignInSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - path = str(next_path) if next_path else "" - url = f"{base_host(request=request, is_space=True)}{path}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) @@ -120,9 +124,11 @@ class MagicSignUpSpaceEndpoint(View): error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) # Existing User existing_user = User.objects.filter(email=email).first() @@ -133,9 +139,11 @@ class MagicSignUpSpaceEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) try: @@ -146,12 +154,16 @@ class MagicSignUpSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py index 11e617436e..613f705ade 100644 --- a/apps/api/plane/authentication/views/space/signout.py +++ b/apps/api/plane/authentication/views/space/signout.py @@ -7,7 +7,7 @@ from django.utils import timezone # Module imports from plane.authentication.utils.host import base_host, user_ip from plane.db.models import User -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignOutAuthSpaceEndpoint(View): @@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View): user.save() # Log the user out logout(request) - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) except Exception: - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/bgtasks/api_logs_task.py b/apps/api/plane/bgtasks/api_logs_task.py deleted file mode 100644 index 038b939d54..0000000000 --- a/apps/api/plane/bgtasks/api_logs_task.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.utils import timezone -from datetime import timedelta -from plane.db.models import APIActivityLog -from celery import shared_task - - -@shared_task -def delete_api_logs(): - # Get the logs older than 30 days to delete - logs_to_delete = APIActivityLog.objects.filter( - created_at__lte=timezone.now() - timedelta(days=30) - ) - - # Delete the logs - logs_to_delete._raw_delete(logs_to_delete.db) diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py new file mode 100644 index 0000000000..c9d86b6398 --- /dev/null +++ b/apps/api/plane/bgtasks/cleanup_task.py @@ -0,0 +1,423 @@ +# Python imports +from datetime import timedelta +import logging +from typing import List, Dict, Any, Callable, Optional +import os + +# Django imports +from django.utils import timezone +from django.db.models import F, Window, Subquery +from django.db.models.functions import RowNumber + +# Third party imports +from celery import shared_task +from pymongo.errors import BulkWriteError +from pymongo.collection import Collection +from pymongo.operations import InsertOne + +# Module imports +from plane.db.models import ( + EmailNotificationLog, + PageVersion, + APIActivityLog, + IssueDescriptionVersion, +) +from plane.settings.mongo import MongoConnection +from plane.utils.exception_logger import log_exception + + +logger = logging.getLogger("plane.worker") +BATCH_SIZE = 1000 + + +def get_mongo_collection(collection_name: str) -> Optional[Collection]: + """Get MongoDB collection if available, otherwise return None.""" + if not MongoConnection.is_configured(): + logger.info("MongoDB not configured") + return None + + try: + mongo_collection = MongoConnection.get_collection(collection_name) + logger.info(f"MongoDB collection '{collection_name}' connected successfully") + return mongo_collection + except Exception as e: + logger.error(f"Failed to get MongoDB collection: {str(e)}") + log_exception(e) + return None + + +def flush_to_mongo_and_delete( + mongo_collection: Optional[Collection], + buffer: List[Dict[str, Any]], + ids_to_delete: List[int], + model, + mongo_available: bool, +) -> None: + """ + Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL. + """ + if not buffer: + logger.debug("No records to flush - buffer is empty") + return + + logger.info( + f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete" + ) + + mongo_archival_failed = False + + # Try to insert into MongoDB if 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: + logger.error(f"MongoDB bulk write error: {str(bwe)}") + log_exception(bwe) + mongo_archival_failed = True + + # If MongoDB is available and archival failed, log the error and return + if mongo_available and mongo_archival_failed: + logger.error(f"MongoDB archival failed for {len(buffer)} records") + return + + # Delete from PostgreSQL - delete() returns (count, {model: count}) + delete_result = model.all_objects.filter(id__in=ids_to_delete).delete() + deleted_count = ( + delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0 + ) + logger.info(f"Batch flush completed: {deleted_count} records deleted") + + +def process_cleanup_task( + queryset_func: Callable, + transform_func: Callable[[Dict], Dict], + model, + task_name: str, + collection_name: str, +): + """ + Generic function to process cleanup tasks. + + Args: + queryset_func: Function that returns the queryset to process + transform_func: Function to transform each record for MongoDB + model: Django model class + task_name: Name of the task for logging + collection_name: MongoDB collection name + """ + logger.info(f"Starting {task_name} cleanup task") + + # Get MongoDB collection + mongo_collection = get_mongo_collection(collection_name) + mongo_available = mongo_collection is not None + + # Get queryset + queryset = queryset_func() + + # Process records in batches + buffer: List[Dict[str, Any]] = [] + ids_to_delete: List[int] = [] + total_processed = 0 + total_batches = 0 + + for record in queryset: + # Transform record for MongoDB + buffer.append(transform_func(record)) + ids_to_delete.append(record["id"]) + + # Flush batch when it reaches BATCH_SIZE + if len(buffer) >= BATCH_SIZE: + total_batches += 1 + flush_to_mongo_and_delete( + mongo_collection=mongo_collection, + buffer=buffer, + ids_to_delete=ids_to_delete, + model=model, + mongo_available=mongo_available, + ) + total_processed += len(buffer) + buffer.clear() + ids_to_delete.clear() + + # Process final batch if any records remain + if buffer: + total_batches += 1 + flush_to_mongo_and_delete( + mongo_collection=mongo_collection, + buffer=buffer, + ids_to_delete=ids_to_delete, + model=model, + mongo_available=mongo_available, + ) + total_processed += len(buffer) + + logger.info( + f"{task_name} cleanup task completed", + extra={ + "total_records_processed": total_processed, + "total_batches": total_batches, + "mongo_available": mongo_available, + "collection_name": collection_name, + }, + ) + + +# Transform functions for each model +def transform_api_log(record: Dict) -> Dict: + """Transform API activity log record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "token_identifier": str(record["token_identifier"]), + "path": record["path"], + "method": record["method"], + "query_params": record.get("query_params"), + "headers": record.get("headers"), + "body": record.get("body"), + "response_code": record["response_code"], + "response_body": record["response_body"], + "ip_address": record["ip_address"], + "user_agent": record["user_agent"], + "created_by_id": str(record["created_by_id"]), + } + + +def transform_email_log(record: Dict) -> Dict: + """Transform email notification log record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "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": ( + str(record["processed_at"]) if record.get("processed_at") else None + ), + "sent_at": str(record["sent_at"]) if record.get("sent_at") else None, + "entity": record["entity"], + "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": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "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": 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 + ), + } + + +def transform_issue_description_version(record: Dict) -> Dict: + """Transform issue description version record.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "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 + ), + "description_binary": record["description_binary"], + "description_html": record["description_html"], + "description_stripped": record["description_stripped"], + "description_json": record["description_json"], + "deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None, + } + + +# Queryset functions for each cleanup task +def get_api_logs_queryset(): + """Get API logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"API logs cutoff time: {cutoff_time}") + + return ( + APIActivityLog.all_objects.filter(created_at__lte=cutoff_time) + .values( + "id", + "created_at", + "token_identifier", + "path", + "method", + "query_params", + "headers", + "body", + "response_code", + "response_body", + "ip_address", + "user_agent", + "created_by_id", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_email_logs_queryset(): + """Get email logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"Email logs cutoff time: {cutoff_time}") + + return ( + EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time) + .values( + "id", + "created_at", + "receiver_id", + "triggered_by_id", + "entity_identifier", + "entity_name", + "data", + "processed_at", + "sent_at", + "entity", + "old_value", + "new_value", + "created_by_id", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_page_versions_queryset(): + """Get page versions beyond the maximum allowed (20 per page).""" + subq = ( + PageVersion.all_objects.annotate( + row_num=Window( + expression=RowNumber(), + partition_by=[F("page_id")], + order_by=F("created_at").desc(), + ) + ) + .filter(row_num__gt=20) + .values("id") + ) + + return ( + PageVersion.all_objects.filter(id__in=Subquery(subq)) + .values( + "id", + "created_at", + "page_id", + "workspace_id", + "owned_by_id", + "description_html", + "description_binary", + "description_stripped", + "description_json", + "sub_pages_data", + "created_by_id", + "updated_by_id", + "deleted_at", + "last_saved_at", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +def get_issue_description_versions_queryset(): + """Get issue description versions beyond the maximum allowed (20 per issue).""" + subq = ( + IssueDescriptionVersion.all_objects.annotate( + row_num=Window( + expression=RowNumber(), + partition_by=[F("issue_id")], + order_by=F("created_at").desc(), + ) + ) + .filter(row_num__gt=20) + .values("id") + ) + + return ( + IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq)) + .values( + "id", + "created_at", + "issue_id", + "workspace_id", + "project_id", + "created_by_id", + "updated_by_id", + "owned_by_id", + "last_saved_at", + "description_binary", + "description_html", + "description_stripped", + "description_json", + "deleted_at", + ) + .iterator(chunk_size=BATCH_SIZE) + ) + + +# Celery tasks - now much simpler! +@shared_task +def delete_api_logs(): + """Delete old API activity logs.""" + process_cleanup_task( + queryset_func=get_api_logs_queryset, + transform_func=transform_api_log, + model=APIActivityLog, + task_name="API Activity Log", + collection_name="api_activity_logs", + ) + + +@shared_task +def delete_email_notification_logs(): + """Delete old email notification logs.""" + process_cleanup_task( + queryset_func=get_email_logs_queryset, + transform_func=transform_email_log, + model=EmailNotificationLog, + task_name="Email Notification Log", + collection_name="email_notification_logs", + ) + + +@shared_task +def delete_page_versions(): + """Delete excess page versions.""" + process_cleanup_task( + queryset_func=get_page_versions_queryset, + transform_func=transform_page_version, + model=PageVersion, + task_name="Page Version", + collection_name="page_versions", + ) + + +@shared_task +def delete_issue_description_versions(): + """Delete excess issue description versions.""" + process_cleanup_task( + queryset_func=get_issue_description_versions_queryset, + transform_func=transform_issue_description_version, + model=IssueDescriptionVersion, + task_name="Issue Description Version", + collection_name="issue_description_versions", + ) diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py index 7a5f94c9e0..ec1f6c3ca8 100644 --- a/apps/api/plane/bgtasks/page_version_task.py +++ b/apps/api/plane/bgtasks/page_version_task.py @@ -30,6 +30,8 @@ def page_version(page_id, existing_instance, user_id): description_binary=page.description_binary, owned_by_id=user_id, last_saved_at=page.updated_at, + description_json=page.description, + description_stripped=page.description_stripped, ) # If page versions are greater than 20 delete the oldest one diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index c2fbfb0655..6fae83e414 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: name=workspace.name, # Use workspace name identifier=project_identifier, created_by_id=workspace.created_by_id, + # Enable all views in seed data + cycle_view=True, + module_view=True, + issue_views_view=True, ) # Create project members diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 0ffa4689b9..2eeac358c6 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -50,9 +50,21 @@ app.conf.beat_schedule = { "schedule": crontab(hour=2, minute=0), # UTC 02:00 }, "check-every-day-to-delete-api-logs": { - "task": "plane.bgtasks.api_logs_task.delete_api_logs", + "task": "plane.bgtasks.cleanup_task.delete_api_logs", "schedule": crontab(hour=2, minute=30), # UTC 02:30 }, + "check-every-day-to-delete-email-notification-logs": { + "task": "plane.bgtasks.cleanup_task.delete_email_notification_logs", + "schedule": crontab(hour=3, minute=0), # UTC 03:00 + }, + "check-every-day-to-delete-page-versions": { + "task": "plane.bgtasks.cleanup_task.delete_page_versions", + "schedule": crontab(hour=3, minute=30), # UTC 03:30 + }, + "check-every-day-to-delete-issue-description-versions": { + "task": "plane.bgtasks.cleanup_task.delete_issue_description_versions", + "schedule": crontab(hour=4, minute=0), # UTC 04:00 + }, } diff --git a/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py b/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py new file mode 100644 index 0000000000..59908a96b8 --- /dev/null +++ b/apps/api/plane/db/migrations/0102_page_sort_order_pagelog_entity_type_and_more.py @@ -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), + ), + ] diff --git a/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py b/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py new file mode 100644 index 0000000000..82deba4627 --- /dev/null +++ b/apps/api/plane/db/migrations/0103_fileasset_asset_entity_type_idx_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.22 on 2025-09-01 14:33 + +from django.db import migrations, models +from django.contrib.postgres.operations import AddIndexConcurrently + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('db', '0102_page_sort_order_pagelog_entity_type_and_more'), + ] + + operations = [ + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_type'], name='asset_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_identifier'], name='asset_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='fileasset', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='asset_entity_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['entity_identifier'], name='notif_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['entity_name'], name='notif_entity_name_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['read_at'], name='notif_read_at_idx'), + ), + AddIndexConcurrently( + model_name='notification', + index=models.Index(fields=['receiver', 'read_at'], name='notif_entity_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_type'], name='pagelog_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_identifier'], name='pagelog_entity_id_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_name'], name='pagelog_entity_name_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='pagelog_type_id_idx'), + ), + AddIndexConcurrently( + model_name='pagelog', + index=models.Index(fields=['entity_name', 'entity_identifier'], name='pagelog_name_id_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_type'], name='fav_entity_type_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_identifier'], name='fav_entity_identifier_idx'), + ), + AddIndexConcurrently( + model_name='userfavorite', + index=models.Index(fields=['entity_type', 'entity_identifier'], name='fav_entity_idx'), + ), + ] diff --git a/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py b/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py new file mode 100644 index 0000000000..6344e31651 --- /dev/null +++ b/apps/api/plane/db/migrations/0104_cycleuserproperties_rich_filters_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.22 on 2025-09-03 05:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0103_fileasset_asset_entity_type_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='cycleuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='exporterhistory', + name='rich_filters', + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name='issueuserproperty', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='issueview', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='moduleuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspaceuserproperties', + name='rich_filters', + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py new file mode 100644 index 0000000000..ef477fbc19 --- /dev/null +++ b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.22 on 2025-09-10 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0104_cycleuserproperties_rich_filters_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="cycle_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="issue_views_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="module_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="session", + name="user_id", + field=models.CharField(db_index=True, max_length=50, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0106_auto_20250912_0845.py b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py new file mode 100644 index 0000000000..8a0813fc1e --- /dev/null +++ b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py @@ -0,0 +1,152 @@ +# Generated by Django 4.2.22 on 2025-09-12 08:45 +import uuid +import django +from django.conf import settings +from django.db import migrations, models + + +def set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + + batch_size = 3000 + sort_order = 100 + + # Get page IDs ordered by name using the historical model + # This should include all pages regardless of soft-delete status + page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True)) + + updated_pages = [] + for page_id in page_ids: + # Create page instance with minimal data + updated_pages.append(Page(id=page_id, sort_order=sort_order)) + sort_order += 100 + + # Bulk update when batch is full + if len(updated_pages) >= batch_size: + Page.objects.bulk_update( + updated_pages, ["sort_order"], batch_size=batch_size + ) + updated_pages = [] + + # Update remaining pages + if updated_pages: + Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size) + + +def reverse_set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0105_alter_project_cycle_view_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectWebhook", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_webhooks", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Webhook", + "verbose_name_plural": "Project Webhooks", + "db_table": "project_webhooks", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="projectwebhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "webhook"), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="projectwebhook", + unique_together={("project", "webhook", "deleted_at")}, + ), + migrations.AlterField( + model_name="issuerelation", + name="relation_type", + field=models.CharField( + default="blocked_by", max_length=20, verbose_name="Issue Relation Type" + ), + ), + migrations.RunPython( + set_page_sort_order, reverse_code=reverse_set_page_sort_order + ), + ] diff --git a/apps/api/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py index 9973d122f5..9652624822 100644 --- a/apps/api/plane/db/models/asset.py +++ b/apps/api/plane/db/models/asset.py @@ -76,6 +76,15 @@ class FileAsset(BaseModel): verbose_name_plural = "File Assets" db_table = "file_assets" ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="asset_entity_type_idx"), + models.Index( + fields=["entity_identifier"], name="asset_entity_identifier_idx" + ), + models.Index( + fields=["entity_type", "entity_identifier"], name="asset_entity_idx" + ), + ] def __str__(self): return str(self.asset) diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py index 26b152c6cf..9e45028c59 100644 --- a/apps/api/plane/db/models/cycle.py +++ b/apps/api/plane/db/models/cycle.py @@ -139,6 +139,7 @@ class CycleUserProperties(ProjectBaseModel): filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) class Meta: unique_together = ["cycle", "user", "deleted_at"] diff --git a/apps/api/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py index 48d40a1aaf..40c13576da 100644 --- a/apps/api/plane/db/models/exporter.py +++ b/apps/api/plane/db/models/exporter.py @@ -56,6 +56,7 @@ class ExporterHistory(BaseModel): related_name="workspace_exporters", ) filters = models.JSONField(blank=True, null=True) + rich_filters = models.JSONField(default=dict, blank=True, null=True) class Meta: verbose_name = "Exporter" diff --git a/apps/api/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py index 680bf7e376..1650720889 100644 --- a/apps/api/plane/db/models/favorite.py +++ b/apps/api/plane/db/models/favorite.py @@ -41,6 +41,15 @@ class UserFavorite(WorkspaceBaseModel): verbose_name_plural = "User Favorites" db_table = "user_favorites" ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="fav_entity_type_idx"), + models.Index( + fields=["entity_identifier"], name="fav_entity_identifier_idx" + ), + models.Index( + fields=["entity_type", "entity_identifier"], name="fav_entity_idx" + ), + ] def save(self, *args, **kwargs): if self._state.adding: diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index a3994d79e0..2baf8ace11 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices): BLOCKED_BY = "blocked_by", "Blocked By" START_BEFORE = "start_before", "Start Before" FINISH_BEFORE = "finish_before", "Finish Before" + IMPLEMENTED_BY = "implemented_by", "Implemented By" class IssueRelation(ProjectBaseModel): @@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel): ) relation_type = models.CharField( max_length=20, - choices=IssueRelationChoices.choices, verbose_name="Issue Relation Type", default=IssueRelationChoices.BLOCKED_BY, ) @@ -509,6 +509,7 @@ class IssueUserProperty(ProjectBaseModel): filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) class Meta: verbose_name = "Issue User Property" diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py index 6015461d53..897cf26b19 100644 --- a/apps/api/plane/db/models/module.py +++ b/apps/api/plane/db/models/module.py @@ -207,6 +207,7 @@ class ModuleUserProperties(ProjectBaseModel): filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) class Meta: unique_together = ["module", "user", "deleted_at"] diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py index 2847c07cf0..a57e288abf 100644 --- a/apps/api/plane/db/models/notification.py +++ b/apps/api/plane/db/models/notification.py @@ -39,6 +39,14 @@ class Notification(BaseModel): verbose_name_plural = "Notifications" db_table = "notifications" ordering = ("-created_at",) + indexes = [ + models.Index( + fields=["entity_identifier"], name="notif_entity_identifier_idx" + ), + models.Index(fields=["entity_name"], name="notif_entity_name_idx"), + models.Index(fields=["read_at"], name="notif_read_at_idx"), + models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"), + ] def __str__(self): """Return name of the notifications""" diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 30a641ef83..4d465cd588 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -19,6 +19,7 @@ def get_view_props(): class Page(BaseModel): PRIVATE_ACCESS = 1 PUBLIC_ACCESS = 0 + DEFAULT_SORT_ORDER = 65535 ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) @@ -57,6 +58,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=DEFAULT_SORT_ORDER) external_id = models.CharField(max_length=255, null=True, blank=True) external_source = models.CharField(max_length=255, null=True, blank=True) @@ -98,8 +100,11 @@ 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" ) @@ -110,6 +115,17 @@ class PageLog(BaseModel): verbose_name_plural = "Page Logs" db_table = "page_logs" ordering = ("-created_at",) + indexes = [ + models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"), + models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"), + models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"), + models.Index( + fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx" + ), + models.Index( + fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx" + ), + ] def __str__(self): return f"{self.page.name} {self.entity_name}" diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index e58f60e804..81a84f1ace 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -18,6 +18,12 @@ from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + class ProjectNetwork(Enum): SECRET = 0 PUBLIC = 2 @@ -89,9 +95,9 @@ class Project(BaseModel): ) emoji = models.CharField(max_length=255, null=True, blank=True) icon_prop = models.JSONField(null=True) - module_view = models.BooleanField(default=True) - cycle_view = models.BooleanField(default=True) - issue_views_view = models.BooleanField(default=True) + module_view = models.BooleanField(default=False) + cycle_view = models.BooleanField(default=False) + issue_views_view = models.BooleanField(default=False) page_view = models.BooleanField(default=True) intake_view = models.BooleanField(default=False) is_time_tracking_enabled = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py index 3b35ebc705..e884498bf1 100644 --- a/apps/api/plane/db/models/session.py +++ b/apps/api/plane/db/models/session.py @@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits class Session(AbstractBaseSession): device_info = models.JSONField(null=True, blank=True, default=None) session_key = models.CharField(max_length=128, primary_key=True) - user_id = models.CharField(null=True, max_length=50) + user_id = models.CharField(null=True, max_length=50, db_index=True) @classmethod def get_session_store_class(cls): diff --git a/apps/api/plane/db/models/view.py b/apps/api/plane/db/models/view.py index c9182acce3..87d22e44f7 100644 --- a/apps/api/plane/db/models/view.py +++ b/apps/api/plane/db/models/view.py @@ -58,6 +58,7 @@ class IssueView(WorkspaceBaseModel): filters = models.JSONField(default=dict) display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) access = models.PositiveSmallIntegerField( default=1, choices=((0, "Private"), (1, "Public")) ) diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index b1428523b4..189ccb279f 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -7,7 +7,7 @@ from django.db import models from django.core.exceptions import ValidationError # Module imports -from plane.db.models import BaseModel +from plane.db.models import BaseModel, ProjectBaseModel def generate_token(): @@ -90,3 +90,24 @@ class WebhookLog(BaseModel): def __str__(self): return f"{self.event_type} {str(self.webhook)}" + + + +class ProjectWebhook(ProjectBaseModel): + webhook = models.ForeignKey( + "db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks" + ) + + class Meta: + unique_together = ["project", "webhook", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "webhook"], + condition=models.Q(deleted_at__isnull=True), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ) + ] + verbose_name = "Project Webhook" + verbose_name_plural = "Project Webhooks" + db_table = "project_webhooks" + ordering = ("-created_at",) \ No newline at end of file diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index cb176086dd..75a45f72c0 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -332,6 +332,7 @@ class WorkspaceUserProperties(BaseModel): filters = models.JSONField(default=get_default_filters) display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) class Meta: unique_together = ["workspace", "user", "deleted_at"] diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index e1e3860827..3a9563e3bb 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -34,6 +34,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, ) from plane.utils.ip_address import get_client_ip +from plane.utils.path_validator import get_safe_redirect_url class InstanceAdminEndpoint(BaseAPIView): @@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_admin=True), + next_path="" + ) return HttpResponseRedirect(url) except Exception: - return HttpResponseRedirect(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_admin=True), + next_path="" + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py new file mode 100644 index 0000000000..f8c2c30bc3 --- /dev/null +++ b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.22 on 2025-09-11 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0005_rename_product_instance_edition_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="instance", + name="is_current_version_deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py index 113b59ce4a..0e596d8de2 100644 --- a/apps/api/plane/license/models/instance.py +++ b/apps/api/plane/license/models/instance.py @@ -38,6 +38,8 @@ class Instance(BaseModel): is_signup_screen_visited = models.BooleanField(default=False) is_verified = models.BooleanField(default=False) is_test = models.BooleanField(default=False) + # field for validating if the current version is deprecated + is_current_version_deprecated = models.BooleanField(default=False) class Meta: verbose_name = "Instance" diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index cad1249018..3c3410107d 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -284,7 +284,7 @@ CELERY_IMPORTS = ( "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", "plane.bgtasks.email_notification_task", - "plane.bgtasks.api_logs_task", + "plane.bgtasks.cleanup_task", "plane.license.bgtasks.tracer", # management tasks "plane.bgtasks.dummy_data_task", @@ -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) diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py index db60501f79..15af36a2da 100644 --- a/apps/api/plane/settings/local.py +++ b/apps/api/plane/settings/local.py @@ -73,5 +73,10 @@ LOGGING = { "handlers": ["console"], "propagate": False, }, + "plane.mongo": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, }, } diff --git a/apps/api/plane/settings/mongo.py b/apps/api/plane/settings/mongo.py new file mode 100644 index 0000000000..57d25b4777 --- /dev/null +++ b/apps/api/plane/settings/mongo.py @@ -0,0 +1,124 @@ +# Django imports +from django.conf import settings +import logging + +# Third party imports +from pymongo import MongoClient +from pymongo.database import Database +from pymongo.collection import Collection +from typing import Optional, TypeVar, Type + + +T = TypeVar("T", bound="MongoConnection") + +# Set up logger +logger = logging.getLogger("plane.mongo") + + +class MongoConnection: + """ + A singleton class that manages MongoDB connections. + + This class ensures only one MongoDB connection is maintained throughout the application. + It provides methods to access the MongoDB client, database, and collections. + + Attributes: + _instance (Optional[MongoConnection]): The singleton instance of this class + _client (Optional[MongoClient]): The MongoDB client instance + _db (Optional[Database]): The MongoDB database instance + """ + + _instance: Optional["MongoConnection"] = None + _client: Optional[MongoClient] = None + _db: Optional[Database] = None + + def __new__(cls: Type[T]) -> T: + """ + Creates a new instance of MongoConnection if one doesn't exist. + + Returns: + MongoConnection: The singleton instance + """ + if cls._instance is None: + cls._instance = super(MongoConnection, cls).__new__(cls) + try: + mongo_url = getattr(settings, "MONGO_DB_URL", None) + mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None) + + if not mongo_url or not mongo_db_database: + logger.warning( + "MongoDB connection parameters not configured. MongoDB functionality will be disabled." + ) + return cls._instance + + cls._client = MongoClient(mongo_url) + cls._db = cls._client[mongo_db_database] + + # Test the connection + cls._client.server_info() + logger.info("MongoDB connection established successfully") + except Exception as e: + logger.warning( + f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled." + ) + return cls._instance + + @classmethod + def get_client(cls) -> Optional[MongoClient]: + """ + Returns the MongoDB client instance. + + Returns: + Optional[MongoClient]: The MongoDB client instance or None if not configured + """ + if cls._client is None: + cls._instance = cls() + return cls._client + + @classmethod + def get_db(cls) -> Optional[Database]: + """ + Returns the MongoDB database instance. + + Returns: + Optional[Database]: The MongoDB database instance or None if not configured + """ + if cls._db is None: + cls._instance = cls() + return cls._db + + @classmethod + def get_collection(cls, collection_name: str) -> Optional[Collection]: + """ + Returns a MongoDB collection by name. + + Args: + collection_name (str): The name of the collection to retrieve + + Returns: + Optional[Collection]: The MongoDB collection instance or None if not configured + """ + try: + db = cls.get_db() + if db is None: + logger.warning( + f"Cannot access collection '{collection_name}': MongoDB not configured" + ) + return None + return db[collection_name] + except Exception as e: + logger.warning(f"Failed to access collection '{collection_name}': {str(e)}") + return None + + @classmethod + def is_configured(cls) -> bool: + """ + Check if MongoDB is properly configured and connected. + + 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 diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py index abd95d006b..4f4e99bdb2 100644 --- a/apps/api/plane/settings/production.py +++ b/apps/api/plane/settings/production.py @@ -83,5 +83,10 @@ LOGGING = { "handlers": ["console"], "propagate": False, }, + "plane.mongo": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, }, } diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py index 3549e76262..64f151a2d2 100644 --- a/apps/api/plane/space/serializer/issue.py +++ b/apps/api/plane/space/serializer/issue.py @@ -30,7 +30,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_json_content, validate_binary_data, ) @@ -290,20 +289,22 @@ class IssueCreateSerializer(BaseSerializer): raise serializers.ValidationError("Start date cannot exceed target date") # Validate description content for security - if "description" in data and data["description"]: - is_valid, error_msg = validate_json_content(data["description"]) - if not is_valid: - raise serializers.ValidationError({"description": error_msg}) - if "description_html" in data and data["description_html"]: - is_valid, error_msg = validate_html_content(data["description_html"]) + is_valid, error_msg, sanitized_html = validate_html_content( + data["description_html"] + ) if not is_valid: - raise serializers.ValidationError({"description_html": error_msg}) + raise serializers.ValidationError( + {"error": "html content is not valid"} + ) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html if "description_binary" in data and data["description_binary"]: is_valid, error_msg = validate_binary_data(data["description_binary"]) if not is_valid: - raise serializers.ValidationError({"description_binary": error_msg}) + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) return data diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index d28b83fc70..cf7c235ee8 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -1,36 +1,14 @@ # Python imports import base64 -import json -import re +import nh3 +from plane.utils.exception_logger import log_exception +from bs4 import BeautifulSoup +from collections import defaultdict # Maximum allowed size for binary data (10MB) MAX_SIZE = 10 * 1024 * 1024 -# Maximum recursion depth to prevent stack overflow -MAX_RECURSION_DEPTH = 20 - -# Dangerous text patterns that could indicate XSS or script injection -DANGEROUS_TEXT_PATTERNS = [ - r"]*>.*?", - r"javascript\s*:", - r"data\s*:\s*text/html", - r"eval\s*\(", - r"document\s*\.", - r"window\s*\.", - r"location\s*\.", -] - -# Dangerous attribute patterns for HTML attributes -DANGEROUS_ATTR_PATTERNS = [ - r"javascript\s*:", - r"data\s*:\s*text/html", - r"eval\s*\(", - r"alert\s*\(", - r"document\s*\.", - r"window\s*\.", -] - # Suspicious patterns for binary data content SUSPICIOUS_BINARY_PATTERNS = [ "]*>", - r"", - # JavaScript URLs in various attributes - r'(?:href|src|action)\s*=\s*["\']?\s*javascript:', - # Data URLs with text/html (potential XSS) - r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html', - # Dangerous event handlers with JavaScript-like content - r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?', - # Object and embed tags that could load external content - r"<(?:object|embed)[^>]*(?:data|src)\s*=", - # Base tag that could change relative URL resolution - r"]*href\s*=", - # Dangerous iframe sources - r']*src\s*=\s*["\']?(?:javascript:|data:text/html)', - # Meta refresh redirects - r']*http-equiv\s*=\s*["\']?refresh["\']?', - # Link tags - simplified patterns - r']*rel\s*=\s*["\']?stylesheet["\']?', - r']*href\s*=\s*["\']?https?://', - r']*href\s*=\s*["\']?//', - r']*href\s*=\s*["\']?(?:data:|javascript:)', - # Style tags with external imports - r"]*>.*?@import.*?(?:https?://|//)", - # Link tags with dangerous rel types - r']*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?', - # Forms with action attributes - r"]*action\s*=", -] - -# Dangerous JavaScript patterns for event handlers -DANGEROUS_JS_PATTERNS = [ - r"alert\s*\(", - r"eval\s*\(", - r"document\s*\.", - r"window\s*\.", - r"location\s*\.", - r"fetch\s*\(", - r"XMLHttpRequest", - r"innerHTML\s*=", - r"outerHTML\s*=", - r"document\.write", - r"script\s*>", -] - -# HTML self-closing tags that don't need closing tags -SELF_CLOSING_TAGS = { - "img", - "br", - "hr", - "input", - "meta", - "link", - "area", - "base", - "col", - "embed", - "source", - "track", - "wbr", -} - def validate_binary_data(data): """ - Validate that binary data appears to be valid document format and doesn't contain malicious content. + Validate that binary data appears to be a valid document format + and doesn't contain malicious content. Args: data (bytes or str): The binary data to validate, or base64-encoded string @@ -149,191 +64,180 @@ def validate_binary_data(data): return True, None -def validate_html_content(html_content): +# Combine custom components and editor-specific nodes into a single set of tags +CUSTOM_TAGS = { + # editor node/tag names + "mention-component", + "label", + "input", + "image-component", +} +ALLOWED_TAGS = nh3.ALLOWED_TAGS | CUSTOM_TAGS + +# Merge nh3 defaults with all attributes used across our custom components +ATTRIBUTES = { + "*": { + "class", + "id", + "title", + "role", + "aria-label", + "aria-hidden", + "style", + "start", + "type", + # common editor data-* attributes seen in stored HTML + # (wildcards like data-* are NOT supported by nh3; we add known keys + # here and dynamically include all data-* seen in the input below) + "data-tight", + "data-node-type", + "data-type", + "data-checked", + "data-background-color", + "data-text-color", + "data-name", + # callout attributes + "data-icon-name", + "data-icon-color", + "data-background", + "data-emoji-unicode", + "data-emoji-url", + "data-logo-in-use", + "data-block-type", + }, + "a": {"href", "target"}, + # editor node/tag attributes + "image-component": { + "id", + "width", + "height", + "aspectRatio", + "aspectratio", + "src", + "alignment", + }, + "img": { + "width", + "height", + "aspectRatio", + "aspectratio", + "alignment", + "src", + "alt", + "title", + }, + "mention-component": {"id", "entity_identifier", "entity_name"}, + "th": { + "colspan", + "rowspan", + "colwidth", + "background", + "hideContent", + "hidecontent", + "style", + }, + "td": { + "colspan", + "rowspan", + "colwidth", + "background", + "textColor", + "textcolor", + "hideContent", + "hidecontent", + "style", + }, + "tr": {"background", "textColor", "textcolor", "style"}, + "pre": {"language"}, + "code": {"language", "spellcheck"}, + "input": {"type", "checked"}, +} + +SAFE_PROTOCOLS = {"http", "https", "mailto", "tel"} + + +def _compute_html_sanitization_diff(before_html: str, after_html: str): """ - Validate that HTML content is safe and doesn't contain malicious patterns. + Compute a coarse diff between original and sanitized HTML. - Args: - html_content (str): The HTML content to validate + Returns a dict with: + - removed_tags: mapping[tag] -> removed_count + - removed_attributes: mapping[tag] -> sorted list of attribute names removed + """ + try: - Returns: - tuple: (is_valid: bool, error_message: str or None) + def collect(soup): + tag_counts = defaultdict(int) + attrs_by_tag = defaultdict(set) + for el in soup.find_all(True): + tag_name = (el.name or "").lower() + if not tag_name: + continue + tag_counts[tag_name] += 1 + for attr_name in list(el.attrs.keys()): + if isinstance(attr_name, str) and attr_name: + attrs_by_tag[tag_name].add(attr_name.lower()) + return tag_counts, attrs_by_tag + + soup_before = BeautifulSoup(before_html or "", "html.parser") + soup_after = BeautifulSoup(after_html or "", "html.parser") + + counts_before, attrs_before = collect(soup_before) + counts_after, attrs_after = collect(soup_after) + + removed_tags = {} + for tag, cnt_before in counts_before.items(): + cnt_after = counts_after.get(tag, 0) + if cnt_after < cnt_before: + removed = cnt_before - cnt_after + removed_tags[tag] = removed + + removed_attributes = {} + for tag, before_set in attrs_before.items(): + after_set = attrs_after.get(tag, set()) + removed = before_set - after_set + if removed: + removed_attributes[tag] = sorted(list(removed)) + + return {"removed_tags": removed_tags, "removed_attributes": removed_attributes} + except Exception: + # Best-effort only; if diffing fails we don't block the request + return {"removed_tags": {}, "removed_attributes": {}} + + +def validate_html_content(html_content: str): + """ + Sanitize HTML content using nh3. + Returns a tuple: (is_valid, error_message, clean_html) """ if not html_content: - return True, None # Empty is OK + return True, None, None # Size check - 10MB limit (consistent with binary validation) if len(html_content.encode("utf-8")) > MAX_SIZE: - return False, "HTML content exceeds maximum size limit (10MB)" - - # Check for specific malicious patterns (simplified and more reliable) - for pattern in MALICIOUS_HTML_PATTERNS: - if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL): - return ( - False, - f"HTML content contains potentially malicious patterns: {pattern}", - ) - - # Additional check for inline event handlers that contain suspicious content - # This is more permissive - only blocks if the event handler contains actual dangerous code - event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']' - event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE) - - for handler_content in event_matches: - for js_pattern in DANGEROUS_JS_PATTERNS: - if re.search(js_pattern, handler_content, re.IGNORECASE): - return ( - False, - f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}", - ) - - - return True, None - - -def validate_json_content(json_content): - """ - Validate that JSON content is safe and doesn't contain malicious patterns. - - Args: - json_content (dict): The JSON content to validate - - Returns: - tuple: (is_valid: bool, error_message: str or None) - """ - if not json_content: - return True, None # Empty is OK + return False, "HTML content exceeds maximum size limit (10MB)", None try: - # Size check - 10MB limit (consistent with other validations) - json_str = json.dumps(json_content) - if len(json_str.encode("utf-8")) > MAX_SIZE: - return False, "JSON content exceeds maximum size limit (10MB)" + clean_html = nh3.clean( + html_content, + tags=ALLOWED_TAGS, + attributes=ATTRIBUTES, + url_schemes=SAFE_PROTOCOLS, + ) + # Report removals to logger (Sentry) if anything was stripped + diff = _compute_html_sanitization_diff(html_content, clean_html) + if diff.get("removed_tags") or diff.get("removed_attributes"): + try: + import json - # Basic structure validation for page description JSON - if isinstance(json_content, dict): - # Check for expected page description structure - # This is based on ProseMirror/Tiptap JSON structure - if "type" in json_content and json_content.get("type") == "doc": - # Valid document structure - if "content" in json_content and isinstance( - json_content["content"], list - ): - # Recursively check content for suspicious patterns - is_valid, error_msg = _validate_json_content_array( - json_content["content"] - ) - if not is_valid: - return False, error_msg - elif "type" not in json_content and "content" not in json_content: - # Allow other JSON structures but validate for suspicious content - is_valid, error_msg = _validate_json_content_recursive(json_content) - if not is_valid: - return False, error_msg - else: - return False, "JSON description must be a valid object" - - except (TypeError, ValueError) as e: - return False, "Invalid JSON structure" + summary = json.dumps(diff) + except Exception: + summary = str(diff) + log_exception( + f"HTML sanitization removals: {summary}", + warning=True, + ) + return True, None, clean_html except Exception as e: - return False, "Failed to validate JSON content" - - return True, None - - -def _validate_json_content_array(content, depth=0): - """ - Validate JSON content array for suspicious patterns. - - Args: - content (list): Array of content nodes to validate - depth (int): Current recursion depth (default: 0) - - Returns: - tuple: (is_valid: bool, error_message: str or None) - """ - # Check recursion depth to prevent stack overflow - if depth > MAX_RECURSION_DEPTH: - return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded" - - if not isinstance(content, list): - return True, None - - for node in content: - if isinstance(node, dict): - # Check text content for suspicious patterns (more targeted) - if node.get("type") == "text" and "text" in node: - text_content = node["text"] - for pattern in DANGEROUS_TEXT_PATTERNS: - if re.search(pattern, text_content, re.IGNORECASE): - return ( - False, - "JSON content contains suspicious script patterns in text", - ) - - # Check attributes for suspicious content (more targeted) - if "attrs" in node and isinstance(node["attrs"], dict): - for attr_name, attr_value in node["attrs"].items(): - if isinstance(attr_value, str): - # Only check specific attributes that could be dangerous - if attr_name.lower() in [ - "href", - "src", - "action", - "onclick", - "onload", - "onerror", - ]: - for pattern in DANGEROUS_ATTR_PATTERNS: - if re.search(pattern, attr_value, re.IGNORECASE): - return ( - False, - f"JSON content contains dangerous pattern in {attr_name} attribute", - ) - - # Recursively check nested content - if "content" in node and isinstance(node["content"], list): - is_valid, error_msg = _validate_json_content_array( - node["content"], depth + 1 - ) - if not is_valid: - return False, error_msg - - return True, None - - -def _validate_json_content_recursive(obj, depth=0): - """ - Recursively validate JSON object for suspicious content. - - Args: - obj: JSON object (dict, list, or primitive) to validate - depth (int): Current recursion depth (default: 0) - - Returns: - tuple: (is_valid: bool, error_message: str or None) - """ - # Check recursion depth to prevent stack overflow - if depth > MAX_RECURSION_DEPTH: - return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded" - if isinstance(obj, dict): - for key, value in obj.items(): - if isinstance(value, str): - # Check for dangerous patterns using module constants - for pattern in DANGEROUS_TEXT_PATTERNS: - if re.search(pattern, value, re.IGNORECASE): - return ( - False, - "JSON content contains suspicious script patterns", - ) - elif isinstance(value, (dict, list)): - is_valid, error_msg = _validate_json_content_recursive(value, depth + 1) - if not is_valid: - return False, error_msg - elif isinstance(obj, list): - for item in obj: - is_valid, error_msg = _validate_json_content_recursive(item, depth + 1) - if not is_valid: - return False, error_msg - - return True, None + log_exception(e) + return False, "Failed to sanitize HTML", None diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index 89e154a7f7..d69a1f5832 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -1,7 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Q, UUIDField, Value, QuerySet +from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery from django.db.models.functions import Coalesce # Module imports @@ -14,6 +14,9 @@ from plane.db.models import ( ProjectMember, State, WorkspaceMember, + IssueAssignee, + ModuleIssue, + IssueLabel, ) from typing import Optional, Dict, Tuple, Any, Union, List @@ -39,33 +42,52 @@ def issue_queryset_grouper( if group_key in GROUP_FILTER_MAPPER: queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key]) + issue_assignee_subquery = Subquery( + IssueAssignee.objects.filter( + issue_id=OuterRef("pk"), + deleted_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("assignee_id", distinct=True)) + .values("arr") + ) + + issue_module_subquery = Subquery( + ModuleIssue.objects.filter( + issue_id=OuterRef("pk"), + deleted_at__isnull=True, + module__archived_at__isnull=True, + ) + .values("issue_id") + .annotate(arr=ArrayAgg("module_id", distinct=True)) + .values("arr") + ) + + issue_label_subquery = Subquery( + IssueLabel.objects.filter(issue_id=OuterRef("pk"), deleted_at__isnull=True) + .values("issue_id") + .annotate(arr=ArrayAgg("label_id", distinct=True)) + .values("arr") + ) + annotations_map: Dict[str, Tuple[str, Q]] = { - "assignee_ids": ( - "assignees__id", - ~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True), + "assignee_ids": Coalesce( + issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField())) ), - "label_ids": ( - "labels__id", - ~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True), + "label_ids": Coalesce( + issue_label_subquery, Value([], output_field=ArrayField(UUIDField())) ), - "module_ids": ( - "issue_module__module_id", - ( - ~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True) - & Q(issue_module__deleted_at__isnull=True) - ), + "module_ids": Coalesce( + issue_module_subquery, Value([], output_field=ArrayField(UUIDField())) ), } - default_annotations: Dict[str, Any] = { - key: Coalesce( - ArrayAgg(field, distinct=True, filter=condition), - Value([], output_field=ArrayField(UUIDField())), - ) - for key, (field, condition) in annotations_map.items() - if FIELD_MAPPER.get(key) != group_by or FIELD_MAPPER.get(key) != sub_group_by - } + default_annotations: Dict[str, Any] = {} + + for key, expression in annotations_map.items(): + if FIELD_MAPPER.get(key) in {group_by, sub_group_by}: + continue + default_annotations[key] = expression return queryset.annotate(**default_annotations) diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py index 1c9619890c..9136367b6e 100644 --- a/apps/api/plane/utils/issue_filters.py +++ b/apps/api/plane/utils/issue_filters.py @@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""): issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get( "subscriber" ) + issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True + return issue_filter diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py index f3188eb268..19d65c1112 100644 --- a/apps/api/plane/utils/issue_relation_mapper.py +++ b/apps/api/plane/utils/issue_relation_mapper.py @@ -6,12 +6,14 @@ def get_inverse_relation(relation_type): "blocking": "blocked_by", "start_before": "start_after", "finish_before": "finish_after", + "implemented_by": "implements", + "implements": "implemented_by", } return relation_mapping.get(relation_type, relation_type) def get_actual_relation(relation_type): - # This function is used to get the actual relation type which is store in database + # This function is used to get the actual relation type which is stored in database actual_relation = { "start_after": "start_before", "finish_after": "finish_before", @@ -19,6 +21,8 @@ def get_actual_relation(relation_type): "blocked_by": "blocked_by", "start_before": "start_before", "finish_before": "finish_before", + "implemented_by": "implemented_by", + "implements": "implemented_by", } return actual_relation.get(relation_type, relation_type) diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ba81e9cabf..ebac7ca0be 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -2,20 +2,97 @@ from urllib.parse import urlparse +def _contains_suspicious_patterns(path: str) -> bool: + """ + Check for suspicious patterns that might indicate malicious intent. + + Args: + path (str): The path to check + + Returns: + bool: True if suspicious patterns found, False otherwise + """ + suspicious_patterns = [ + r'javascript:', # JavaScript injection + r'data:', # Data URLs + r'vbscript:', # VBScript injection + r'file:', # File protocol + r'ftp:', # FTP protocol + r'%2e%2e', # URL encoded path traversal + r'%2f%2f', # URL encoded double slash + r'%5c%5c', # URL encoded backslashes + r' Promise = async () => { const extensions: Extension[] = [ diff --git a/apps/live/src/core/hocuspocus-server.ts b/apps/live/src/core/hocuspocus-server.ts index ceea6548be..df69c2cb63 100644 --- a/apps/live/src/core/hocuspocus-server.ts +++ b/apps/live/src/core/hocuspocus-server.ts @@ -1,12 +1,12 @@ import { Server } from "@hocuspocus/server"; import { v4 as uuidv4 } from "uuid"; -// lib -import { handleAuthentication } from "@/core/lib/authentication.js"; -// extensions -import { getExtensions } from "@/core/extensions/index.js"; -import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; // editor types import { TUserDetails } from "@plane/editor"; +import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; +// extensions +import { getExtensions } from "@/core/extensions/index.js"; +// lib +import { handleAuthentication } from "@/core/lib/authentication.js"; // types import { type HocusPocusServerContext } from "@/core/types/common.js"; diff --git a/apps/live/src/core/lib/authentication.ts b/apps/live/src/core/lib/authentication.ts index 0f679337c7..c7f190e3ac 100644 --- a/apps/live/src/core/lib/authentication.ts +++ b/apps/live/src/core/lib/authentication.ts @@ -1,7 +1,7 @@ -// services -import { UserService } from "@/core/services/user.service.js"; // core helpers import { manualLogger } from "@/core/helpers/logger.js"; +// services +import { UserService } from "@/core/services/user.service.js"; const userService = new UserService(); diff --git a/apps/live/src/core/services/api.service.ts b/apps/live/src/core/services/api.service.ts index abc53c1113..dbef2ae179 100644 --- a/apps/live/src/core/services/api.service.ts +++ b/apps/live/src/core/services/api.service.ts @@ -14,6 +14,7 @@ export abstract class APIService { this.axiosInstance = axios.create({ baseURL, withCredentials: true, + timeout: 20000, }); } diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index c4a3535389..69d0e642ea 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -1,13 +1,13 @@ import compression from "compression"; import cors from "cors"; -import expressWs from "express-ws"; import express, { Request, Response } from "express"; +import expressWs from "express-ws"; import helmet from "helmet"; // hocuspocus server -import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; // 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"; diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json index 810a68a5cb..57d47a3d86 100644 --- a/apps/live/tsconfig.json +++ b/apps/live/tsconfig.json @@ -21,6 +21,6 @@ "emitDecoratorMetadata": true, "sourceRoot": "/" }, - "include": ["src/**/*.ts", "tsup.config.ts"], + "include": ["src/**/*.ts", "tsdown.config.ts"], "exclude": ["./dist", "./build", "./node_modules"] } diff --git a/apps/live/tsdown.config.ts b/apps/live/tsdown.config.ts new file mode 100644 index 0000000000..2b97503a6e --- /dev/null +++ b/apps/live/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/server.ts"], + outDir: "dist", + format: ["esm", "cjs"], +}); diff --git a/apps/live/tsup.config.ts b/apps/live/tsup.config.ts deleted file mode 100644 index 05fbe7e86c..0000000000 --- a/apps/live/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/server.ts"], - format: ["esm", "cjs"], - dts: true, - splitting: false, - sourcemap: true, - minify: false, - target: "node18", - outDir: "dist", - env: { - NODE_ENV: process.env.NODE_ENV || "development", - }, -}); diff --git a/apps/space/.eslintignore b/apps/space/.eslintignore new file mode 100644 index 0000000000..27e50ad7c6 --- /dev/null +++ b/apps/space/.eslintignore @@ -0,0 +1,12 @@ +.next/* +out/* +public/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/space/.eslintrc.js b/apps/space/.eslintrc.js index 5a6f060678..1662fabf75 100644 --- a/apps/space/.eslintrc.js +++ b/apps/space/.eslintrc.js @@ -1,6 +1,4 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], - parser: "@typescript-eslint/parser", }; diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx index 91631d6c0b..46f187ddc4 100644 --- a/apps/space/app/issues/[anchor]/layout.tsx +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -13,6 +13,11 @@ export async function generateMetadata({ params }: Props) { const { anchor } = params; const DEFAULT_TITLE = "Plane"; const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities."; + // Validate anchor before using in request (only allow alphanumeric, -, _) + const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/; + if (!ANCHOR_REGEX.test(anchor)) { + return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; + } try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`); const data = await response.json(); diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index c5ccaa3c8f..e71a3a08de 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -172,7 +172,7 @@ export const AuthRoot: FC = observer(() => { text: `${content} with GitHub`, icon: ( GitHub Logo, + Omit, "disabledExtensions" | "flaggedExtensions" > & { anchor: string; @@ -63,6 +62,7 @@ export const LiteTextEditor = React.forwardRef , }} + extendedEditorProps={{}} {...rest} // overriding the containerClassName to add relative class passed containerClassName={cn(containerClassName, "relative")} diff --git a/apps/space/core/components/editor/rich-text-editor.tsx b/apps/space/core/components/editor/rich-text-editor.tsx index 8d0818353d..c058d3e88d 100644 --- a/apps/space/core/components/editor/rich-text-editor.tsx +++ b/apps/space/core/components/editor/rich-text-editor.tsx @@ -12,7 +12,7 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; import { EditorMentionsRoot } from "./embeds/mentions"; type RichTextEditorWrapperProps = MakeOptional< - Omit, + Omit, "disabledExtensions" | "flaggedExtensions" > & { anchor: string; @@ -56,9 +56,10 @@ export const RichTextEditor = forwardRef ); diff --git a/apps/space/core/components/editor/toolbar.tsx b/apps/space/core/components/editor/toolbar.tsx index ed9c855d8b..48fec23ea9 100644 --- a/apps/space/core/components/editor/toolbar.tsx +++ b/apps/space/core/components/editor/toolbar.tsx @@ -3,7 +3,8 @@ import React, { useEffect, useState, useCallback } from "react"; // plane imports import { TOOLBAR_ITEMS, type ToolbarMenuItem, type EditorRefApi } from "@plane/editor"; -import { Button, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Button } from "@plane/ui"; import { cn } from "@plane/utils"; type Props = { diff --git a/apps/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx index 33af39e21c..7fdf900bb2 100644 --- a/apps/space/core/components/issues/filters/applied-filters/priority.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/priority.tsx @@ -1,7 +1,7 @@ "use client"; import { X } from "lucide-react"; -import { PriorityIcon, type TIssuePriorities } from "@plane/ui"; +import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons"; type Props = { handleRemove: (val: string) => void; diff --git a/apps/space/core/components/issues/filters/applied-filters/state.tsx b/apps/space/core/components/issues/filters/applied-filters/state.tsx index 70ebd14dd2..c80c8688a5 100644 --- a/apps/space/core/components/issues/filters/applied-filters/state.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/state.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // plane imports import { EIconSize } from "@plane/constants"; -import { StateGroupIcon } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; // hooks import { useStates } from "@/hooks/store/use-state"; diff --git a/apps/space/core/components/issues/filters/priority.tsx b/apps/space/core/components/issues/filters/priority.tsx index 76851ee0a3..674b052f5b 100644 --- a/apps/space/core/components/issues/filters/priority.tsx +++ b/apps/space/core/components/issues/filters/priority.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; // plane imports import { ISSUE_PRIORITY_FILTERS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { PriorityIcon } from "@plane/ui"; +import { PriorityIcon } from "@plane/propel/icons"; // local imports import { FilterHeader } from "./helpers/filter-header"; import { FilterOption } from "./helpers/filter-option"; diff --git a/apps/space/core/components/issues/filters/state.tsx b/apps/space/core/components/issues/filters/state.tsx index 865878c521..a794d532af 100644 --- a/apps/space/core/components/issues/filters/state.tsx +++ b/apps/space/core/components/issues/filters/state.tsx @@ -4,7 +4,8 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // ui import { EIconSize } from "@plane/constants"; -import { Loader, StateGroupIcon } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; // hooks import { useStates } from "@/hooks/store/use-state"; // local imports diff --git a/apps/space/core/components/issues/issue-layouts/kanban/block.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx index 806b16a52b..7511d9aaf9 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/block.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -5,9 +5,9 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; // plane types +import { Tooltip } from "@plane/propel/tooltip"; import { IIssueDisplayProperties } from "@plane/types"; // plane ui -import { Tooltip } from "@plane/ui"; // plane utils import { cn } from "@plane/utils"; // components diff --git a/apps/space/core/components/issues/issue-layouts/list/block.tsx b/apps/space/core/components/issues/issue-layouts/list/block.tsx index f66e3da28b..a56c3d5b9d 100644 --- a/apps/space/core/components/issues/issue-layouts/list/block.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/block.tsx @@ -5,9 +5,9 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; // plane types +import { Tooltip } from "@plane/propel/tooltip"; import { IIssueDisplayProperties } from "@plane/types"; // plane ui -import { Tooltip } from "@plane/ui"; // plane utils import { cn } from "@plane/utils"; // helpers @@ -75,7 +75,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { onClick={handleIssuePeekOverview} className="w-full truncate cursor-pointer text-sm text-custom-text-100" > - +

{issue.name}

diff --git a/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx index 1072f34108..e8a8ead178 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -3,8 +3,8 @@ import { observer } from "mobx-react"; import { Layers, Link, Paperclip } from "lucide-react"; // plane imports +import { Tooltip } from "@plane/propel/tooltip"; import type { IIssueDisplayProperties } from "@plane/types"; -import { Tooltip } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; diff --git a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx index 1774781cd3..c58badcf92 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // plane ui -import { ContrastIcon, Tooltip } from "@plane/ui"; +import { ContrastIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; //hooks diff --git a/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx index a4285cb934..2f1669837e 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { CalendarCheck2 } from "lucide-react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; diff --git a/apps/space/core/components/issues/issue-layouts/properties/labels.tsx b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx index d4040f2e20..12ed76d3a5 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/labels.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { Tags } from "lucide-react"; // plane imports -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // hooks import { useLabel } from "@/hooks/store/use-label"; diff --git a/apps/space/core/components/issues/issue-layouts/properties/modules.tsx b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx index 465f469c0b..c5e250f082 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/modules.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // plane ui -import { DiceIcon, Tooltip } from "@plane/ui"; +import { DiceIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; // hooks diff --git a/apps/space/core/components/issues/issue-layouts/properties/priority.tsx b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx index d2c4c55292..8514c05958 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/priority.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx @@ -3,8 +3,9 @@ import { SignalHigh } from "lucide-react"; import { useTranslation } from "@plane/i18n"; // types +import { PriorityIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { TIssuePriorities } from "@plane/types"; -import { PriorityIcon, Tooltip } from "@plane/ui"; // constants import { cn, getIssuePriorityFilters } from "@plane/utils"; diff --git a/apps/space/core/components/issues/issue-layouts/properties/state.tsx b/apps/space/core/components/issues/issue-layouts/properties/state.tsx index 16bfcb4f75..2613adc4d3 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/state.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/state.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // plane ui -import { StateGroupIcon, Tooltip } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; //hooks diff --git a/apps/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx index 335c8f2100..4733dc78a7 100644 --- a/apps/space/core/components/issues/issue-layouts/utils.tsx +++ b/apps/space/core/components/issues/issue-layouts/utils.tsx @@ -4,6 +4,7 @@ import isNil from "lodash/isNil"; import { ContrastIcon } from "lucide-react"; // types import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants"; +import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; import { GroupByColumnTypes, IGroupByColumn, @@ -12,7 +13,7 @@ import { TGroupedIssues, } from "@plane/types"; // ui -import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui"; +import { Avatar } from "@plane/ui"; // components // constants // stores diff --git a/apps/space/core/components/issues/navbar/layout-selection.tsx b/apps/space/core/components/issues/navbar/layout-selection.tsx index a56240b9e6..143d81d826 100644 --- a/apps/space/core/components/issues/navbar/layout-selection.tsx +++ b/apps/space/core/components/issues/navbar/layout-selection.tsx @@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { SITES_ISSUE_LAYOUTS } from "@plane/constants"; // plane i18n import { useTranslation } from "@plane/i18n"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks diff --git a/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx index cbe7b31c00..165441108b 100644 --- a/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx +++ b/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx @@ -3,7 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // plane imports import { cn } from "@plane/utils"; // ui diff --git a/apps/space/core/components/issues/peek-overview/header.tsx b/apps/space/core/components/issues/peek-overview/header.tsx index 9795e530f0..4791f17490 100644 --- a/apps/space/core/components/issues/peek-overview/header.tsx +++ b/apps/space/core/components/issues/peek-overview/header.tsx @@ -5,7 +5,8 @@ import { observer } from "mobx-react"; import { Link2, MoveRight } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; // ui -import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui"; +import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { setToast, TOAST_TYPE } from "@plane/ui"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks diff --git a/apps/space/core/components/issues/peek-overview/issue-properties.tsx b/apps/space/core/components/issues/peek-overview/issue-properties.tsx index f0e7eb77cc..3bee765ca9 100644 --- a/apps/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-properties.tsx @@ -5,7 +5,8 @@ import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { cn, getIssuePriorityFilters } from "@plane/utils"; // components import { Icon } from "@/components/ui"; diff --git a/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx b/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx index ca0427f3ba..ecf01210ca 100644 --- a/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx +++ b/apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; // lib -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { ReactionSelector } from "@/components/ui"; // helpers import { groupReactions, renderEmoji } from "@/helpers/emoji.helper"; diff --git a/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx index 153d0059e5..ce8642bb56 100644 --- a/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx +++ b/apps/space/core/components/issues/reactions/issue-vote-reactions.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; // plane imports -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; diff --git a/apps/space/core/components/views/header.tsx b/apps/space/core/components/views/header.tsx index 13f037e5b2..80ba76de91 100644 --- a/apps/space/core/components/views/header.tsx +++ b/apps/space/core/components/views/header.tsx @@ -2,7 +2,7 @@ import React from "react"; import Link from "next/link"; -import { PlaneLockup } from "@plane/ui"; +import { PlaneLockup } from "@plane/propel/icons"; export const AuthHeader = () => (
diff --git a/apps/space/helpers/string.helper.ts b/apps/space/helpers/string.helper.ts index 2f4e48ab0d..2cca3177a6 100644 --- a/apps/space/helpers/string.helper.ts +++ b/apps/space/helpers/string.helper.ts @@ -57,24 +57,6 @@ export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] return cleanText.trim() === ""; }; -/** - * @description this function returns whether a comment is empty or not by checking for the following conditions- - * 1. If comment is undefined - * 2. If comment is an empty string - * 3. If comment is "

" - * @param {string | undefined} comment - * @returns {boolean} - */ -export const isCommentEmpty = (comment: string | undefined): boolean => { - // return true if comment is undefined - if (!comment) return true; - return ( - comment?.trim() === "" || - comment === "

" || - isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"]) - ); -}; - export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); diff --git a/apps/space/package.json b/apps/space/package.json index b3f8715577..58caf798d3 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.28.0", + "version": "1.0.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -15,8 +15,6 @@ "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" }, "dependencies": { - "@blueprintjs/core": "^4.16.3", - "@blueprintjs/popover2": "^1.13.3", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.13", @@ -30,41 +28,40 @@ "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", "@popperjs/core": "^2.11.8", - "axios": "1.11.0", + "axios": "catalog:", "clsx": "^2.0.0", "date-fns": "^4.1.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", - "lodash": "^4.17.21", + "lodash": "catalog:", "lowlight": "^2.9.0", - "lucide-react": "^0.469.0", - "mobx": "^6.10.0", - "mobx-react": "^9.1.1", - "mobx-utils": "^6.0.8", - "next": "14.2.30", + "lucide-react": "catalog:", + "mobx": "catalog:", + "mobx-react": "catalog:", + "mobx-utils": "catalog:", + "next": "catalog:", "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", - "sharp": "^0.33.5", - "swr": "^2.2.2", + "sharp": "catalog:", + "swr": "catalog:", "tailwind-merge": "^2.0.0", - "uuid": "^9.0.0" + "uuid": "catalog:" }, "devDependencies": { "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", - "@types/lodash": "^4.17.6", + "@types/lodash": "catalog:", "@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:" } } diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css index 9541e20f56..870f137e4f 100644 --- a/apps/space/styles/globals.css +++ b/apps/space/styles/globals.css @@ -1,4 +1,5 @@ @import "@plane/propel/styles/fonts"; +@import "@plane/editor/styles"; @tailwind base; @tailwind components; diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore index ddd70e6f92..e29e17a088 100644 --- a/apps/web/.eslintignore +++ b/apps/web/.eslintignore @@ -1,4 +1,13 @@ .next/* out/* public/* -core/local-db/worker/wa-sqlite/src/* \ No newline at end of file +core/local-db/worker/wa-sqlite/src/* +dist/* +node_modules/* +.turbo/* +.env* +.env +.env.local +.env.development +.env.production +.env.test \ No newline at end of file diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 5a6f060678..1662fabf75 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -1,6 +1,4 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], - parser: "@typescript-eslint/parser", }; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx index 6215ec3284..d147d0ed12 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // ui -import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui"; +import { ContrastIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; // plane web components diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index 1d24faa46c..dabb43eb7c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -78,6 +78,12 @@ const IssueDetailsPage = observer(() => { return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + useEffect(() => { + if (data?.is_intake) { + router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`); + } + }, [workspaceSlug, data]); + return ( <> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index fcb95316ae..de71b5e959 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -7,7 +7,8 @@ import { useParams } from "next/navigation"; import { Plus, Search } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project/create-project-modal"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx index 938b458548..27ef0ad423 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -1,28 +1,22 @@ "use client"; import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; import { Home, Shapes } from "lucide-react"; -// images -import githubBlackImage from "/public/logos/github-black.png"; -import githubWhiteImage from "/public/logos/github-white.png"; -// ui -import { GITHUB_REDIRECTED_TRACKER_EVENT, HEADER_GITHUB_ICON } from "@plane/constants"; +// plane imports import { useTranslation } from "@plane/i18n"; import { Breadcrumbs, Button, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; -// constants // hooks -import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; import { useHome } from "@/hooks/store/use-home"; +// local imports +import { StarUsOnGitHubLink } from "./star-us-link"; export const WorkspaceDashboardHeader = observer(() => { - // hooks - const { resolvedTheme } = useTheme(); - const { toggleWidgetSettings } = useHome(); + // plane hooks const { t } = useTranslation(); + // hooks + const { toggleWidgetSettings } = useHome(); return ( <> @@ -48,31 +42,7 @@ export const WorkspaceDashboardHeader = observer(() => {
{t("home.manage_widgets")}
- - captureElementAndEvent({ - element: { - elementName: HEADER_GITHUB_ICON, - }, - event: { - eventName: GITHUB_REDIRECTED_TRACKER_EVENT, - state: "SUCCESS", - }, - }) - } - className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" - href="https://github.com/makeplane/plane" - target="_blank" - rel="noopener noreferrer" - > - GitHub Logo - {t("home.star_us_on_github")} - + diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 545ad9ed3e..8e839c0eeb 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -8,8 +8,9 @@ import { useParams } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { UserActivityIcon } from "@plane/propel/icons"; import { IUserProfileProjectSegregation } from "@plane/types"; -import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui"; +import { Breadcrumbs, Header, CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 5470686944..0f6606ba3b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -3,9 +3,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { ArchiveIcon, ContrastIcon, DiceIcon, LayersIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType } from "@plane/types"; // ui -import { ArchiveIcon, Breadcrumbs, Tooltip, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; // hooks diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index c2e0e9c42a..fbfce0d00d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -4,7 +4,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // ui -import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui"; +import { ArchiveIcon, LayersIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 09b41721f1..7fe963c845 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -16,6 +16,8 @@ import { } from "@plane/constants"; import { usePlatformOS } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; +import { ContrastIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType, ICustomSearchSelectOption, @@ -24,7 +26,7 @@ import { IIssueFilterOptions, EIssueLayoutTypes, } from "@plane/types"; -import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; import { cn, isIssueFilterActive } from "@plane/utils"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx deleted file mode 100644 index 1430fc0928..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import { FC, useCallback } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane constants -import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; -// i18n -import { useTranslation } from "@plane/i18n"; -// types -import { - EIssuesStoreType, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, - EIssueLayoutTypes, -} from "@plane/types"; -// ui -import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; -// components -import { isIssueFilterActive } from "@plane/utils"; -import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; -import { - DisplayFiltersSelection, - FiltersDropdown, - FilterSelection, - LayoutSelection, -} from "@/components/issues/issue-layouts/filters"; -// helpers -// hooks -import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; -import { useProject } from "@/hooks/store/use-project"; -import { useProjectState } from "@/hooks/store/use-project-state"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web -import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project"; - -// FIXME: Deprecated. Remove it -export const ProjectDraftIssueHeader: FC = observer(() => { - // i18n - const { t } = useTranslation(); - // router - const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; - // store hooks - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.DRAFT); - const { currentProjectDetails, loader } = useProject(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); - const { isMobile } = usePlatformOS(); - const activeLayout = issueFilters?.displayFilters?.layout; - - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [workspaceSlug, projectId, issueFilters, updateFilters] - ); - - const handleLayoutChange = useCallback( - (layout: EIssueLayoutTypes) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const handleDisplayFilters = useCallback( - (updatedDisplayFilter: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const handleDisplayProperties = useCallback( - (property: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const issueCount = undefined; - - return ( -
-
-
- - - - } - /> - } - /> - - {issueCount && issueCount > 0 ? ( - 1 ? "work items" : "work item"} in project's draft`} - position="bottom" - > - - {issueCount} - - - ) : null} -
- -
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - - - - - -
-
-
- ); -}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx deleted file mode 100644 index ec6cdc1dd7..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -// components -import { AppHeader } from "@/components/core/app-header"; -import { ContentWrapper } from "@/components/core/content-wrapper"; -import { ProjectDraftIssueHeader } from "./header"; - -export default function ProjectDraftIssuesLayou({ children }: { children: React.ReactNode }) { - return ( - <> - } /> - {children} - - ); -} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx deleted file mode 100644 index ce91afb616..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { X, PenSquare } from "lucide-react"; -// components -import { PageHead } from "@/components/core/page-title"; -import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root"; -// hooks -import { useProject } from "@/hooks/store/use-project"; -import { useAppRouter } from "@/hooks/use-app-router"; - -const ProjectDraftIssuesPage = observer(() => { - const router = useAppRouter(); - const { workspaceSlug, projectId } = useParams(); - // store - const { getProjectById } = useProject(); - // derived values - const project = projectId ? getProjectById(projectId.toString()) : undefined; - const pageTitle = project?.name ? `${project?.name} - Draft work items` : undefined; - - return ( - <> - -
-
- -
- -
- - ); -}); - -export default ProjectDraftIssuesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx index 4f9c5af7e9..72573c4490 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx @@ -3,7 +3,7 @@ // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; -import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake"; +import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header"; export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index 22728d5e36..e63f7282ab 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; -import { redirect, useParams } from "next/navigation"; +import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; @@ -34,7 +34,7 @@ const IssueDetailsPage = observer(() => { useEffect(() => { if (data) { - redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); + router.push(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`); } }, [workspaceSlug, data]); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 310f2a0bf0..3a1b477b52 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -14,6 +14,8 @@ import { EProjectFeatureKey, WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; +import { DiceIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType, ICustomSearchSelectOption, @@ -22,7 +24,7 @@ import { IIssueFilterOptions, EIssueLayoutTypes, } from "@plane/types"; -import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; import { cn, isIssueFilterActive } from "@plane/utils"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 94cdfd2796..1ae3317b95 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -20,6 +20,7 @@ import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages import { useEditorConfig } from "@/hooks/editor"; import { useEditorAsset } from "@/hooks/store/use-editor-asset"; import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useAppRouter } from "@/hooks/use-app-router"; // plane web hooks import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; // plane web services @@ -30,13 +31,17 @@ const workspaceService = new WorkspaceService(); const projectPageService = new ProjectPageService(); const projectPageVersionService = new ProjectPageVersionService(); +const storeType = EPageStoreType.PROJECT; + const PageDetailsPage = observer(() => { + // router + const router = useAppRouter(); const { workspaceSlug, projectId, pageId } = useParams(); // store hooks - const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT); + const { createPage, fetchPageDetails } = usePageStore(storeType); const page = usePage({ pageId: pageId?.toString() ?? "", - storeType: EPageStoreType.PROJECT, + storeType, }); const { getWorkspaceBySlug } = useWorkspace(); const { uploadEditorAsset } = useEditorAsset(); @@ -88,10 +93,25 @@ const PageDetailsPage = observer(() => { versionId ); }, - getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, + restoreVersion: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + await projectPageVersionService.restoreVersion( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => { + if (pageId) { + return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`; + } else { + return `/${workspaceSlug}/projects/${projectId}/pages`; + } + }, updateDescription: updateDescription ?? (async () => {}), }), - [createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug] + [createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId] ); // page root config const pageRootConfig: TPageRootConfig = useMemo( @@ -115,7 +135,7 @@ const PageDetailsPage = observer(() => { workspaceSlug: workspaceSlug?.toString() ?? "", }), }), - [getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] + [getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug] ); const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( @@ -127,6 +147,12 @@ const PageDetailsPage = observer(() => { [projectId, workspaceSlug] ); + useEffect(() => { + if (page?.deleted_at && page?.id) { + router.push(pageRootHandlers.getRedirectionLink()); + } + }, [page?.deleted_at, page?.id, router, pageRootHandlers]); + if ((!page || !id) && !pageDetailsError) return (
@@ -150,7 +176,7 @@ const PageDetailsPage = observer(() => {
); - if (!page) return null; + if (!page || !workspaceSlug || !projectId) return null; return ( <> @@ -160,9 +186,11 @@ const PageDetailsPage = observer(() => {
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index c143487cc8..70a530f391 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -14,6 +14,7 @@ import { WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType, EViewAccess, @@ -24,7 +25,7 @@ import { EIssueLayoutTypes, } from "@plane/types"; // ui -import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components import { isIssueFilterActive } from "@plane/utils"; import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 2fd3218c07..f392746443 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -1,36 +1,24 @@ -import { FC, useEffect, useRef } from "react"; +import { FC } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; // plane helpers import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { useOutsideClickDetector } from "@plane/hooks"; // components -import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; -import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown"; +import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; -import { HelpMenu } from "@/components/workspace/sidebar/help-menu"; import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; // hooks -import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useFavorite } from "@/hooks/store/use-favorite"; import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRail } from "@/hooks/use-app-rail"; -import useSize from "@/hooks/use-window-size"; // plane web components -import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge"; import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; export const AppSidebar: FC = observer(() => { // store hooks const { allowPermissions } = useUserPermissions(); - const { toggleSidebar, sidebarCollapsed } = useAppTheme(); - const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail(); const { groupedFavorites } = useFavorite(); - const windowSize = useSize(); - // refs - const ref = useRef(null); // derived values const canPerformWorkspaceMemberActions = allowPermissions( @@ -38,55 +26,17 @@ export const AppSidebar: FC = observer(() => { EUserPermissionsLevel.WORKSPACE ); - useOutsideClickDetector(ref, () => { - if (sidebarCollapsed === false) { - if (window.innerWidth < 768) { - toggleSidebar(); - } - } - }); - - useEffect(() => { - if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [windowSize]); - const isFavoriteEmpty = isEmpty(groupedFavorites); return ( - <> -
- {/* Workspace switcher and settings */} - {!shouldRenderAppRail && } - - {isAppRailEnabled && ( -
- Projects -
- -
-
- )} - {/* Quick actions */} - -
-
- - {/* Favorites Menu */} - {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - {/* Teams List */} - - {/* Projects List */} - -
- {/* Help Section */} -
- -
- {!shouldRenderAppRail && } - {!isAppRailEnabled && } -
-
- + }> + + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + + ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx new file mode 100644 index 0000000000..1573e75295 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/star-us-link.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Image from "next/image"; +import { useTheme } from "next-themes"; +// plane imports +import { HEADER_GITHUB_ICON, GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// helpers +import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; +// public imports +import githubBlackImage from "@/public/logos/github-black.png"; +import githubWhiteImage from "@/public/logos/github-white.png"; + +export const StarUsOnGitHubLink = () => { + // plane hooks + const { t } = useTranslation(); + // hooks + const { resolvedTheme } = useTheme(); + const imageSrc = resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage; + + return ( + + captureElementAndEvent({ + element: { + elementName: HEADER_GITHUB_ICON, + }, + event: { + eventName: GITHUB_REDIRECTED_TRACKER_EVENT, + state: "SUCCESS", + }, + }) + } + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" + href="https://github.com/makeplane/plane" + target="_blank" + rel="noopener noreferrer" + > + + {t("home.star_us_on_github")} + + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx index 1b4b1a2360..08a216ab19 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui"; +import { RecentStickyIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { StickySearch } from "@/components/stickies/modal/search"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index 1c50844c8c..b4fa1bbd9f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -191,7 +191,7 @@ export const GlobalIssuesHeader = observer(() => { - + {!isLocked ? ( <> { diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx index e715f83795..92c2ba3e1a 100644 --- a/apps/web/app/(all)/create-workspace/page.tsx +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -6,8 +6,9 @@ import Image from "next/image"; import Link from "next/link"; // plane imports import { useTranslation } from "@plane/i18n"; +import { PlaneLogo } from "@plane/propel/icons"; import { IWorkspace } from "@plane/types"; -import { Button, getButtonStyling, PlaneLogo } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/ui"; // components import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; // hooks diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx index 3ac1ce86e5..82201b0f25 100644 --- a/apps/web/app/(all)/invitations/page.tsx +++ b/apps/web/app/(all)/invitations/page.tsx @@ -10,9 +10,10 @@ import { CheckCircle2 } from "lucide-react"; import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types +import { PlaneLogo } from "@plane/propel/icons"; import type { IWorkspaceMemberInvitation } from "@plane/types"; // ui -import { Button, TOAST_TYPE, setToast, PlaneLogo } from "@plane/ui"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { truncateText } from "@plane/utils"; // components import { EmptyState } from "@/components/common/empty-state"; diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 32589c4bf0..2fde651885 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -5,7 +5,7 @@ import { PreloadResources } from "./layout.preload"; // styles import "@/styles/command-pallette.css"; import "@/styles/emoji.css"; -import "@/styles/react-day-picker.css"; +import "@plane/propel/styles/react-day-picker"; export const metadata: Metadata = { robots: { diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx index 50144d9335..6d4c8ce362 100644 --- a/apps/web/app/(all)/profile/sidebar.tsx +++ b/apps/web/app/(all)/profile/sidebar.tsx @@ -21,7 +21,8 @@ import { import { PROFILE_ACTION_LINKS } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 565c362d5c..7e383ce2bf 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -1,69 +1,83 @@ "use client"; -import Link from "next/link"; -// plane imports -import { API_BASE_URL } from "@plane/constants"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; +import Image from "next/image"; +import { useTheme } from "next-themes"; // layouts +import { Button } from "@plane/ui"; +import { useAppRouter } from "@/hooks/use-app-router"; import DefaultLayout from "@/layouts/default-layout"; -// services -import { AuthService } from "@/services/auth.service"; +// images +import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; +import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; -// services -const authService = new AuthService(); +const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + { + key: "status", + label: "Status Page", + value: "https://status.plane.so/", + }, + { + key: "twitter_handle", + label: "@planepowers", + value: "https://x.com/planepowers", + }, +]; export default function CustomErrorComponent() { + // hooks + const { resolvedTheme } = useTheme(); const router = useAppRouter(); - const handleSignOut = async () => { - await authService - .signOut(API_BASE_URL) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", - }) - ) - .finally(() => router.push("/")); - }; + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; return ( -
-
-
-
-

Yikes! That doesn{"'"}t look good.

-

- That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more - details, please write to{" "} - - support@plane.so - {" "} - or on our{" "} +

+
+ ProjectSettingImg +
+
+
+

+ 🚧 Looks like something went wrong! +

+ + We track these errors automatically and working on getting things back up and running. If the problem + persists feel free to contact us. In the meantime, try refreshing. + +
+ +
+ {linkMap.map((link) => ( + -
- - Go to home - - -
+
+ ))} +
+ +
+
diff --git a/apps/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx index fefbe9d187..ba67aa9cbc 100644 --- a/apps/web/ce/components/breadcrumbs/project-feature.tsx +++ b/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -4,7 +4,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { EProjectFeatureKey } from "@plane/constants"; -import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui"; +import { ISvgIcons } from "@plane/propel/icons"; +import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui"; // components import { SwitcherLabel } from "@/components/common/switcher-label"; import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; @@ -12,7 +13,7 @@ import type { TNavigationItem } from "@/components/workspace/sidebar/project-nav import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; // local imports -import { getProjectFeatureNavigation } from "../projects/navigation"; +import { getProjectFeatureNavigation } from "../projects/navigation/helper"; type TProjectFeatureBreadcrumbProps = { workspaceSlug: string; diff --git a/apps/web/ce/components/breadcrumbs/project.tsx b/apps/web/ce/components/breadcrumbs/project.tsx index c8c0924891..18c4fea762 100644 --- a/apps/web/ce/components/breadcrumbs/project.tsx +++ b/apps/web/ce/components/breadcrumbs/project.tsx @@ -4,8 +4,9 @@ import { observer } from "mobx-react"; import { Briefcase } from "lucide-react"; // plane imports import { ICustomSearchSelectOption } from "@plane/types"; -import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; // components +import { Logo } from "@/components/common/logo"; import { SwitcherLabel } from "@/components/common/switcher-label"; // hooks import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx index d5bc3aba15..fb3595d565 100644 --- a/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx +++ b/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; import { Check } from "lucide-react"; // plane imports import { EIconSize } from "@plane/constants"; -import { Spinner, StateGroupIcon } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Spinner } from "@plane/ui"; // store hooks import { useProjectState } from "@/hooks/store/use-project-state"; diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index 7a5eb6802b..d098b1a480 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -2,6 +2,7 @@ // types import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; +import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; import { IWorkspaceDefaultSearchResult, IWorkspaceIssueSearchResult, @@ -10,7 +11,6 @@ import { IWorkspaceSearchResult, } from "@plane/types"; // ui -import { ContrastIcon, DiceIcon } from "@plane/ui"; // helpers import { generateWorkItemLink } from "@plane/utils"; // plane web components diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/issue-level.tsx index 843e39cfb7..b30d5ec30e 100644 --- a/apps/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/issue-level.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; // plane imports import { EIssueServiceType, EIssuesStoreType, TIssue } from "@plane/types"; // components @@ -22,7 +22,6 @@ export type TIssueLevelModalsProps = { export const IssueLevelModals: FC = observer((props) => { const { projectId, issueId } = props; // router - const pathname = usePathname(); const { workspaceSlug, cycleId, moduleId } = useParams(); const router = useAppRouter(); // store hooks @@ -45,7 +44,6 @@ export const IssueLevelModals: FC = observer((props) => } = useCommandPalette(); // derived values const issueDetails = issueId ? getIssueById(issueId) : undefined; - const isDraftIssue = pathname?.includes("draft-issues") || false; const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail(); const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS); @@ -81,7 +79,6 @@ export const IssueLevelModals: FC = observer((props) => isOpen={isCreateIssueModalOpen} onClose={() => toggleCreateIssueModal(false)} data={getCreateIssueModalData()} - isDraft={isDraftIssue} onSubmit={handleCreateIssueSubmit} allowedProjectIds={createWorkItemAllowedProjectIds} /> diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index 46d3f3b58e..6ed7fc0946 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -2,7 +2,7 @@ import { FC, ReactNode, useRef } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types"; import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks @@ -26,7 +26,13 @@ export const CommentBlock: FC = observer((props) => { // translation const { t } = useTranslation(); - if (!comment || !userDetails) return null; + const displayName = comment?.actor_detail?.is_bot + ? comment?.actor_detail?.first_name + ` ${t("bot")}` + : (userDetails?.display_name ?? comment?.actor_detail?.display_name); + + const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url; + + if (!comment) return null; return (
= observer((props) => { "flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium" )} > - +
-
- {comment?.actor_detail?.is_bot - ? comment?.actor_detail?.first_name + ` ${t("bot")}` - : comment?.actor_detail?.display_name || userDetails.display_name} +
+ + {`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`} +
commented{" "} diff --git a/apps/web/ce/components/common/index.ts b/apps/web/ce/components/common/index.ts deleted file mode 100644 index 38406d4cc3..0000000000 --- a/apps/web/ce/components/common/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./subscription"; -export * from "./extended-app-header"; diff --git a/apps/web/ce/components/common/subscription/index.ts b/apps/web/ce/components/common/subscription/index.ts deleted file mode 100644 index cfd65903d4..0000000000 --- a/apps/web/ce/components/common/subscription/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./subscription-pill"; diff --git a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx index 90193a69c9..e1b8e21688 100644 --- a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx +++ b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -18,15 +18,6 @@ type TDeDupeIssuePopoverRootProps = { }; export const DeDupeIssuePopoverRoot: FC = observer((props) => { - const { - workspaceSlug, - projectId, - rootIssueId, - issues, - issueOperations, - disabled = false, - renderDeDupeActionModals = true, - isIntakeIssue = false, - } = props; + const {} = props; return <>; }); diff --git a/apps/web/ce/components/de-dupe/index.ts b/apps/web/ce/components/de-dupe/index.ts deleted file mode 100644 index 91856db18e..0000000000 --- a/apps/web/ce/components/de-dupe/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./de-dupe-button"; -export * from "./duplicate-modal"; -export * from "./duplicate-popover"; -export * from "./issue-block"; diff --git a/apps/web/ce/components/de-dupe/issue-block/index.ts b/apps/web/ce/components/de-dupe/issue-block/index.ts deleted file mode 100644 index f50893b65d..0000000000 --- a/apps/web/ce/components/de-dupe/issue-block/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./button-label"; diff --git a/apps/web/ce/components/epics/index.ts b/apps/web/ce/components/epics/index.ts deleted file mode 100644 index 29da0cc8ac..0000000000 --- a/apps/web/ce/components/epics/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./epic-modal"; diff --git a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx index c6b94b8d89..7cde59b8a3 100644 --- a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx +++ b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Pen, Trash } from "lucide-react"; import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // components import { ProIcon } from "@/components/common/pro-icon"; diff --git a/apps/web/ce/components/global/product-updates-header.tsx b/apps/web/ce/components/global/product-updates-header.tsx index 418a940a7e..26d4ebbdef 100644 --- a/apps/web/ce/components/global/product-updates-header.tsx +++ b/apps/web/ce/components/global/product-updates-header.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { PlaneLogo } from "@plane/ui"; +import { PlaneLogo } from "@plane/propel/icons"; // helpers import { cn } from "@plane/utils"; // package.json diff --git a/apps/web/ce/components/instance/maintenance-message.tsx b/apps/web/ce/components/instance/maintenance-message.tsx index 1f7efa79f3..067c95f5ec 100644 --- a/apps/web/ce/components/instance/maintenance-message.tsx +++ b/apps/web/ce/components/instance/maintenance-message.tsx @@ -1,17 +1,37 @@ -import { observer } from "mobx-react"; -import { useTranslation } from "@plane/i18n"; - -export const MaintenanceMessage = observer(() => { - // hooks - const { t } = useTranslation(); +export const MaintenanceMessage = () => { + const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + ]; return ( -

- {t( - "self_hosted_maintenance_message.plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start" - )} -
- {t("self_hosted_maintenance_message.choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure")} -

+ <> +
+

+ 🚧 Looks like Plane didn't start up correctly! +

+ + Some services might have failed to start. Please check your container logs to identify and resolve the issue. + If you're stuck, reach out to our support team for more help. + +
+
+ {linkMap.map((link) => ( + + ))} +
+ ); -}); +}; diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx index 5a88e139ff..8bdcc3292c 100644 --- a/apps/web/ce/components/issues/header.tsx +++ b/apps/web/ce/components/issues/header.tsx @@ -14,8 +14,9 @@ import { EProjectFeatureKey, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType } from "@plane/types"; -import { Breadcrumbs, Button, Tooltip, Header } from "@plane/ui"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components import { CountChip } from "@/components/common/count-chip"; // constants diff --git a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx index 338f24fb06..c69704c9e3 100644 --- a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,9 +1,10 @@ import { FC } from "react"; import { observer } from "mobx-react"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { IIssueDisplayProperties } from "@plane/types"; // ui -import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; +import { setToast, TOAST_TYPE } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx index c0b82ae955..fcf1cd91b9 100644 --- a/apps/web/ce/components/issues/issue-layouts/utils.tsx +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -13,8 +13,8 @@ import { Users, } from "lucide-react"; // types +import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/propel/icons"; import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; -import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/ui"; // components import { SpreadsheetAssigneeColumn, diff --git a/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index 26aeb8e452..adeab2da94 100644 --- a/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-react"; // ui -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // components import { cn } from "@plane/utils"; import { RichTextEditor } from "@/components/editor/rich-text"; diff --git a/apps/web/ce/components/pages/editor/ai/menu.tsx b/apps/web/ce/components/pages/editor/ai/menu.tsx index d09024369d..6d79584abb 100644 --- a/apps/web/ce/components/pages/editor/ai/menu.tsx +++ b/apps/web/ce/components/pages/editor/ai/menu.tsx @@ -5,7 +5,7 @@ import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, Triang // plane editor import type { EditorRefApi } from "@plane/editor"; // plane ui -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // components import { cn } from "@plane/utils"; import { RichTextEditor } from "@/components/editor/rich-text"; diff --git a/apps/web/ce/components/pages/header/lock-control.tsx b/apps/web/ce/components/pages/header/lock-control.tsx index daeb1109d1..2403b6889f 100644 --- a/apps/web/ce/components/pages/header/lock-control.tsx +++ b/apps/web/ce/components/pages/header/lock-control.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { LockKeyhole, LockKeyholeOpen } from "lucide-react"; // plane imports import { PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; // hooks diff --git a/apps/web/ce/components/pages/modals/index.ts b/apps/web/ce/components/pages/modals/index.ts index da78df1c84..c1c5c24d22 100644 --- a/apps/web/ce/components/pages/modals/index.ts +++ b/apps/web/ce/components/pages/modals/index.ts @@ -1 +1,2 @@ export * from "./move-page-modal"; +export * from "./modals"; diff --git a/apps/web/ce/components/pages/modals/modals.tsx b/apps/web/ce/components/pages/modals/modals.tsx new file mode 100644 index 0000000000..780dc85313 --- /dev/null +++ b/apps/web/ce/components/pages/modals/modals.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +// components +import { EPageStoreType } from "@/plane-web/hooks/store"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageModalsProps = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageModals: React.FC = observer((props) => null); diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx index e0bf49ad15..c0bd72cca4 100644 --- a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/assets.tsx @@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; export const PageNavigationPaneAssetsTabEmptyState = () => { // asset resolved path - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/assets" }); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/assets" }); // translation const { t } = useTranslation(); diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx index dd71bf3c1b..1e692ea3b3 100644 --- a/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/empty-states/outline.tsx @@ -6,7 +6,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; export const PageNavigationPaneOutlineTabEmptyState = () => { // asset resolved path - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/outline" }); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/wiki/navigation-pane/outline" }); // translation const { t } = useTranslation(); diff --git a/apps/web/ce/components/projects/navigation/helper.tsx b/apps/web/ce/components/projects/navigation/helper.tsx index ad3d4dd3b2..b486684153 100644 --- a/apps/web/ce/components/projects/navigation/helper.tsx +++ b/apps/web/ce/components/projects/navigation/helper.tsx @@ -1,7 +1,7 @@ import { FileText, Layers } from "lucide-react"; // plane imports import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; -import { ContrastIcon, DiceIcon, Intake, LayersIcon } from "@plane/ui"; +import { ContrastIcon, DiceIcon, LayersIcon, Intake } from "@plane/propel/icons"; // components import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; diff --git a/apps/web/ce/components/projects/navigation/index.ts b/apps/web/ce/components/projects/navigation/index.ts deleted file mode 100644 index b9755e783e..0000000000 --- a/apps/web/ce/components/projects/navigation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./helper"; diff --git a/apps/web/ce/components/projects/settings/intake/index.ts b/apps/web/ce/components/projects/settings/intake/index.ts deleted file mode 100644 index 49ac70fe21..0000000000 --- a/apps/web/ce/components/projects/settings/intake/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./header"; diff --git a/apps/web/ce/components/projects/teamspaces/index.ts b/apps/web/ce/components/projects/teamspaces/index.ts deleted file mode 100644 index 968205a9b1..0000000000 --- a/apps/web/ce/components/projects/teamspaces/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./teamspace-list"; diff --git a/apps/web/ce/components/relations/index.tsx b/apps/web/ce/components/relations/index.tsx index b259a38d7a..2a7ebf0ebc 100644 --- a/apps/web/ce/components/relations/index.tsx +++ b/apps/web/ce/components/relations/index.tsx @@ -1,5 +1,5 @@ import { CircleDot, CopyPlus, XCircle } from "lucide-react"; -import { RelatedIcon } from "@plane/ui"; +import { RelatedIcon } from "@plane/propel/icons"; import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations"; import type { TIssueRelationTypes } from "../../types"; diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx index 5905f74eab..6e8b2381c2 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -1,4 +1,7 @@ -import { EIssueLayoutTypes } from "@plane/types"; +import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import { EIssueLayoutTypes, IProjectView } from "@plane/types"; +import { TContextMenuItem } from "@plane/ui"; import { TWorkspaceLayoutProps } from "@/components/views/helper"; export type TLayoutSelectionProps = { @@ -10,3 +13,68 @@ export type TLayoutSelectionProps = { export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <>; export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>; + +export type TMenuItemsFactoryProps = { + isOwner: boolean; + isAdmin: boolean; + setDeleteViewModal: (open: boolean) => void; + setCreateUpdateViewModal: (open: boolean) => void; + handleOpenInNewTab: () => void; + handleCopyText: () => void; + isLocked: boolean; + workspaceSlug: string; + projectId?: string; + viewId: string; +}; + +export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => { + const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props; + + const { t } = useTranslation(); + + const editMenuItem = () => ({ + key: "edit", + action: () => setCreateUpdateViewModal(true), + title: t("edit"), + icon: Pencil, + shouldRender: isOwner, + }); + + const openInNewTabMenuItem = () => ({ + key: "open-new-tab", + action: handleOpenInNewTab, + title: t("open_in_new_tab"), + icon: ExternalLink, + }); + + const copyLinkMenuItem = () => ({ + key: "copy-link", + action: handleCopyText, + title: t("copy_link"), + icon: Link, + }); + + const deleteMenuItem = () => ({ + key: "delete", + action: () => setDeleteViewModal(true), + title: t("delete"), + icon: Trash2, + shouldRender: isOwner || isAdmin, + }); + + return { + editMenuItem, + openInNewTabMenuItem, + copyLinkMenuItem, + deleteMenuItem, + }; +}; + +export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => { + const factory = useMenuItemsFactory(props); + + return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()]; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AdditionalHeaderItems = (view: IProjectView) => <>; diff --git a/apps/web/ce/components/workspace/edition-badge.tsx b/apps/web/ce/components/workspace/edition-badge.tsx index 82a8f7f978..7af2cf71eb 100644 --- a/apps/web/ce/components/workspace/edition-badge.tsx +++ b/apps/web/ce/components/workspace/edition-badge.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; // ui import { useTranslation } from "@plane/i18n"; -import { Button, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Button } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; import packageJson from "package.json"; diff --git a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index 344064d10a..ea16bc6b3f 100644 --- a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -9,7 +9,8 @@ import { Pin, PinOff } from "lucide-react"; // plane imports import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { DragHandle, DropIndicator, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { DragHandle, DropIndicator } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; @@ -165,7 +166,7 @@ export const ExtendedSidebarItem: FC = observer((prop
@@ -281,4 +289,4 @@ export const DateRangeDropdown: React.FC = (props) => { )} ); -}; +}); diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index f01f1ea501..87d783d9a2 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -1,12 +1,12 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; -import { Matcher } from "react-day-picker"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui -import { ComboDropDown, Calendar } from "@plane/ui"; +import { Calendar, Matcher } from "@plane/propel/calendar"; +import { ComboDropDown } from "@plane/ui"; import { cn, renderFormattedDate, getDate } from "@plane/utils"; // helpers // hooks @@ -178,11 +178,11 @@ export const DateDropdown: React.FC = observer((props) => { {...attributes.popper} > { + onSelect={(date: Date | undefined) => { dropdownOnChange(date ?? null); }} showOutsideDays diff --git a/apps/web/core/components/dropdowns/module/button-content.tsx b/apps/web/core/components/dropdowns/module/button-content.tsx index a8d25aba63..6b8ecc1760 100644 --- a/apps/web/core/components/dropdowns/module/button-content.tsx +++ b/apps/web/core/components/dropdowns/module/button-content.tsx @@ -2,7 +2,8 @@ import { ChevronDown, X } from "lucide-react"; // plane imports -import { DiceIcon, Tooltip } from "@plane/ui"; +import { DiceIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks import { useModule } from "@/hooks/store/use-module"; diff --git a/apps/web/core/components/dropdowns/module/module-options.tsx b/apps/web/core/components/dropdowns/module/module-options.tsx index 28f16839b6..aab6568a29 100644 --- a/apps/web/core/components/dropdowns/module/module-options.tsx +++ b/apps/web/core/components/dropdowns/module/module-options.tsx @@ -8,8 +8,8 @@ import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { DiceIcon } from "@plane/propel/icons"; import { IModule } from "@plane/types"; -import { DiceIcon } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; diff --git a/apps/web/core/components/dropdowns/priority.tsx b/apps/web/core/components/dropdowns/priority.tsx index e3bc44e737..d2485d1129 100644 --- a/apps/web/core/components/dropdowns/priority.tsx +++ b/apps/web/core/components/dropdowns/priority.tsx @@ -8,9 +8,11 @@ import { Combobox } from "@headlessui/react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types +import { PriorityIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { TIssuePriorities } from "@plane/types"; // ui -import { ComboDropDown, PriorityIcon, Tooltip } from "@plane/ui"; +import { ComboDropDown } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/dropdowns/state/base.tsx b/apps/web/core/components/dropdowns/state/base.tsx index 3d17c0e27f..0fd654d91e 100644 --- a/apps/web/core/components/dropdowns/state/base.tsx +++ b/apps/web/core/components/dropdowns/state/base.tsx @@ -7,8 +7,9 @@ import { ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState } from "@plane/types"; -import { ComboDropDown, Spinner, StateGroupIcon } from "@plane/ui"; +import { ComboDropDown, Spinner } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { DropdownButton } from "@/components/dropdowns/buttons"; diff --git a/apps/web/core/components/editor/document/editor.tsx b/apps/web/core/components/editor/document/editor.tsx index 910d891083..19679895df 100644 --- a/apps/web/core/components/editor/document/editor.tsx +++ b/apps/web/core/components/editor/document/editor.tsx @@ -1,6 +1,12 @@ import React, { forwardRef } from "react"; // plane imports -import { DocumentEditorWithRef, type EditorRefApi, type IDocumentEditorProps, type TFileHandler } from "@plane/editor"; +import { + DocumentEditorWithRef, + IEditorPropsExtended, + type EditorRefApi, + type IDocumentEditorProps, + type TFileHandler, +} from "@plane/editor"; import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import { cn } from "@plane/utils"; // hooks @@ -8,15 +14,14 @@ import { useEditorConfig, useEditorMention } from "@/hooks/editor"; import { useMember } from "@/hooks/store/use-member"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; -import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed"; // local imports import { EditorMentionsRoot } from "../embeds/mentions"; type DocumentEditorWrapperProps = MakeOptional< - Omit, + Omit, "disabledExtensions" | "editable" | "flaggedExtensions" > & { - embedHandler?: Partial; + extendedEditorProps?: Partial; workspaceSlug: string; workspaceId: string; projectId?: string; @@ -35,7 +40,7 @@ export const DocumentEditor = forwardRef await props.searchMentionCallback(payload) : async () => ({}), }); // editor config const { getEditorFileHandlers } = useEditorConfig(); - // issue-embed - const { issueEmbedProps } = useIssueEmbed({ - projectId, - workspaceSlug, - }); return ( ({ display_name: getUserDetails(id)?.display_name ?? "" }), }} - embedHandler={{ - issue: issueEmbedProps, - ...embedHandler, - }} + extendedEditorProps={extendedEditorProps} {...rest} containerClassName={cn("relative pl-3 pb-3", containerClassName)} /> diff --git a/apps/web/core/components/editor/lite-text/editor.tsx b/apps/web/core/components/editor/lite-text/editor.tsx index 5f4d28203c..42d6f0bd95 100644 --- a/apps/web/core/components/editor/lite-text/editor.tsx +++ b/apps/web/core/components/editor/lite-text/editor.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; -// plane imports +// plane constants import { EIssueCommentAccessSpecifier } from "@plane/constants"; +// plane imports import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; import type { MakeOptional } from "@plane/types"; @@ -18,7 +19,7 @@ import { WorkspaceService } from "@/plane-web/services"; const workspaceService = new WorkspaceService(); type LiteTextEditorWrapperProps = MakeOptional< - Omit, + Omit, "disabledExtensions" | "flaggedExtensions" > & { workspaceSlug: string; @@ -67,7 +68,9 @@ export const LiteTextEditor = React.forwardRef(ref) ? ref.current : null; - return (
{showToolbar && editable && ( diff --git a/apps/web/core/components/editor/lite-text/toolbar.tsx b/apps/web/core/components/editor/lite-text/toolbar.tsx index 0c2759f252..9b230f01cf 100644 --- a/apps/web/core/components/editor/lite-text/toolbar.tsx +++ b/apps/web/core/components/editor/lite-text/toolbar.tsx @@ -8,7 +8,8 @@ import type { EditorRefApi } from "@plane/editor"; // i18n import { useTranslation } from "@plane/i18n"; // ui -import { Button, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Button } from "@plane/ui"; // constants import { cn } from "@plane/utils"; import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; diff --git a/apps/web/core/components/editor/rich-text/editor.tsx b/apps/web/core/components/editor/rich-text/editor.tsx index aaaa0898db..67abc21d22 100644 --- a/apps/web/core/components/editor/rich-text/editor.tsx +++ b/apps/web/core/components/editor/rich-text/editor.tsx @@ -12,7 +12,7 @@ import { useMember } from "@/hooks/store/use-member"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; type RichTextEditorWrapperProps = MakeOptional< - Omit, + Omit, "disabledExtensions" | "editable" | "flaggedExtensions" > & { workspaceSlug: string; @@ -42,7 +42,9 @@ export const RichTextEditor = forwardRef await props.searchMentionCallback(payload) : async () => ({}), @@ -73,6 +75,7 @@ export const RichTextEditor = forwardRef diff --git a/apps/web/core/components/editor/sticky-editor/editor.tsx b/apps/web/core/components/editor/sticky-editor/editor.tsx index 776d96cacc..2372665298 100644 --- a/apps/web/core/components/editor/sticky-editor/editor.tsx +++ b/apps/web/core/components/editor/sticky-editor/editor.tsx @@ -15,7 +15,7 @@ import { StickyEditorToolbar } from "./toolbar"; interface StickyEditorWrapperProps extends Omit< - ILiteTextEditorProps, + Omit, "disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler" > { workspaceSlug: string; @@ -51,7 +51,9 @@ export const StickyEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { @@ -59,7 +61,6 @@ export const StickyEditor = React.forwardRef(ref) ? ref.current : null; - return (
<>, }} + extendedEditorProps={{}} containerClassName={cn(containerClassName, "relative")} {...rest} /> diff --git a/apps/web/core/components/editor/sticky-editor/toolbar.tsx b/apps/web/core/components/editor/sticky-editor/toolbar.tsx index 6811e70039..9626fa46ed 100644 --- a/apps/web/core/components/editor/sticky-editor/toolbar.tsx +++ b/apps/web/core/components/editor/sticky-editor/toolbar.tsx @@ -6,8 +6,8 @@ import { Palette, Trash2 } from "lucide-react"; import type { EditorRefApi } from "@plane/editor"; // ui import { useOutsideClickDetector } from "@plane/hooks"; +import { Tooltip } from "@plane/propel/tooltip"; import { TSticky } from "@plane/types"; -import { Tooltip } from "@plane/ui"; // constants import { cn } from "@plane/utils"; import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; diff --git a/apps/web/core/components/estimates/create/stage-one.tsx b/apps/web/core/components/estimates/create/stage-one.tsx index dc38ebeb57..78628996ac 100644 --- a/apps/web/core/components/estimates/create/stage-one.tsx +++ b/apps/web/core/components/estimates/create/stage-one.tsx @@ -5,8 +5,8 @@ import { Info } from "lucide-react"; // plane imports import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { TEstimateSystemKeys } from "@plane/types"; -import { Tooltip } from "@plane/ui"; // components import { convertMinutesToHoursMinutesString } from "@plane/utils"; // plane web imports diff --git a/apps/web/core/components/estimates/points/create.tsx b/apps/web/core/components/estimates/points/create.tsx index 7f751ce9ca..695e1b09aa 100644 --- a/apps/web/core/components/estimates/points/create.tsx +++ b/apps/web/core/components/estimates/points/create.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { Check, Info, X } from "lucide-react"; import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; -import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { cn, isEstimatePointValuesRepeated } from "@plane/utils"; import { EstimateInputRoot } from "@/components/estimates/inputs/root"; // helpers diff --git a/apps/web/core/components/estimates/points/update.tsx b/apps/web/core/components/estimates/points/update.tsx index 6b3cd59766..d36a57e1f7 100644 --- a/apps/web/core/components/estimates/points/update.tsx +++ b/apps/web/core/components/estimates/points/update.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { Check, Info, X } from "lucide-react"; import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; -import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; import { cn, isEstimatePointValuesRepeated } from "@plane/utils"; import { EstimateInputRoot } from "@/components/estimates/inputs/root"; // helpers diff --git a/apps/web/core/components/gantt-chart/helpers/add-block.tsx b/apps/web/core/components/gantt-chart/helpers/add-block.tsx index 742591a26b..841a0ab6dc 100644 --- a/apps/web/core/components/gantt-chart/helpers/add-block.tsx +++ b/apps/web/core/components/gantt-chart/helpers/add-block.tsx @@ -5,8 +5,8 @@ import { addDays } from "date-fns"; import { observer } from "mobx-react"; import { Plus } from "lucide-react"; // ui +import { Tooltip } from "@plane/propel/tooltip"; import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; -import { Tooltip } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/global/product-updates/footer.tsx b/apps/web/core/components/global/product-updates/footer.tsx index 7e361b2515..ab679d56a6 100644 --- a/apps/web/core/components/global/product-updates/footer.tsx +++ b/apps/web/core/components/global/product-updates/footer.tsx @@ -1,7 +1,8 @@ import { USER_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { getButtonStyling, PlaneLogo } from "@plane/ui"; +import { PlaneLogo } from "@plane/propel/icons"; +import { getButtonStyling } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/home/widgets/empty-states/recents.tsx b/apps/web/core/components/home/widgets/empty-states/recents.tsx index 5d48d29565..c939cae61b 100644 --- a/apps/web/core/components/home/widgets/empty-states/recents.tsx +++ b/apps/web/core/components/home/widgets/empty-states/recents.tsx @@ -1,6 +1,6 @@ import { Briefcase, FileText, History } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { LayersIcon } from "@plane/ui"; +import { LayersIcon } from "@plane/propel/icons"; const getDisplayContent = (type: string) => { switch (type) { diff --git a/apps/web/core/components/home/widgets/empty-states/stickies.tsx b/apps/web/core/components/home/widgets/empty-states/stickies.tsx index 1210ea096c..5a639ceef4 100644 --- a/apps/web/core/components/home/widgets/empty-states/stickies.tsx +++ b/apps/web/core/components/home/widgets/empty-states/stickies.tsx @@ -1,6 +1,6 @@ // plane ui import { useTranslation } from "@plane/i18n"; -import { RecentStickyIcon } from "@plane/ui"; +import { RecentStickyIcon } from "@plane/propel/icons"; export const StickiesEmptyState = () => { const { t } = useTranslation(); diff --git a/apps/web/core/components/home/widgets/recents/index.tsx b/apps/web/core/components/home/widgets/recents/index.tsx index 5622a2541d..f02317aa82 100644 --- a/apps/web/core/components/home/widgets/recents/index.tsx +++ b/apps/web/core/components/home/widgets/recents/index.tsx @@ -6,9 +6,9 @@ import useSWR from "swr"; import { Briefcase, FileText } from "lucide-react"; import { useTranslation } from "@plane/i18n"; // plane types +import { LayersIcon } from "@plane/propel/icons"; import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; // plane ui -import { LayersIcon } from "@plane/ui"; // components import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; // plane web services diff --git a/apps/web/core/components/home/widgets/recents/issue.tsx b/apps/web/core/components/home/widgets/recents/issue.tsx index ed90156769..8cad587850 100644 --- a/apps/web/core/components/home/widgets/recents/issue.tsx +++ b/apps/web/core/components/home/widgets/recents/issue.tsx @@ -1,8 +1,9 @@ import { observer } from "mobx-react"; // plane types +import { LayersIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, TActivityEntityData, TIssueEntityData } from "@plane/types"; // plane ui -import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui"; import { calculateTimeAgo, generateWorkItemLink } from "@plane/utils"; // components import { ListItem } from "@/components/core/list"; diff --git a/apps/web/core/components/home/widgets/recents/page.tsx b/apps/web/core/components/home/widgets/recents/page.tsx index 288c0027cb..0d21062e62 100644 --- a/apps/web/core/components/home/widgets/recents/page.tsx +++ b/apps/web/core/components/home/widgets/recents/page.tsx @@ -2,9 +2,10 @@ import { useRouter } from "next/navigation"; import { FileText } from "lucide-react"; // plane import import type { TActivityEntityData, TPageEntityData } from "@plane/types"; -import { Avatar, Logo } from "@plane/ui"; +import { Avatar } from "@plane/ui"; import { calculateTimeAgo, getFileURL, getPageName } from "@plane/utils"; // components +import { Logo } from "@/components/common/logo"; import { ListItem } from "@/components/core/list"; // hooks import { useMember } from "@/hooks/store/use-member"; diff --git a/apps/web/core/components/home/widgets/recents/project.tsx b/apps/web/core/components/home/widgets/recents/project.tsx index 062e933a53..ab2699113e 100644 --- a/apps/web/core/components/home/widgets/recents/project.tsx +++ b/apps/web/core/components/home/widgets/recents/project.tsx @@ -1,10 +1,9 @@ import { useRouter } from "next/navigation"; // plane types import { TActivityEntityData, TProjectEntityData } from "@plane/types"; -// plane ui -import { Logo } from "@plane/ui"; -// components import { calculateTimeAgo } from "@plane/utils"; +// components +import { Logo } from "@/components/common/logo"; import { ListItem } from "@/components/core/list"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; // helpers diff --git a/apps/web/core/components/icons/locked-component.tsx b/apps/web/core/components/icons/locked-component.tsx index 36230a093c..1fcd486808 100644 --- a/apps/web/core/components/icons/locked-component.tsx +++ b/apps/web/core/components/icons/locked-component.tsx @@ -1,5 +1,5 @@ import { Lock } from "lucide-react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; export const LockedComponent = (props: { toolTipContent?: string }) => { const { toolTipContent } = props; diff --git a/apps/web/core/components/inbox/content/inbox-issue-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-header.tsx index e379c869c2..bb68cdba6a 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-header.tsx @@ -104,8 +104,6 @@ export const InboxIssueActionsHeader: FC = observer((p const currentInboxIssueId = inboxIssue?.issue?.id; - const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/intake/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`; - const redirectIssue = (): string | undefined => { let nextOrPreviousIssueId: string | undefined = undefined; const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId); @@ -413,7 +411,7 @@ export const InboxIssueActionsHeader: FC = observer((p
)} - handleCopyIssueLink(intakeIssueLink)}> + handleCopyIssueLink(workItemLink)}>
{t("inbox_issue.actions.copy")} diff --git a/apps/web/core/components/inbox/content/issue-properties.tsx b/apps/web/core/components/inbox/content/issue-properties.tsx index 4102ae5253..47ea8f6ae0 100644 --- a/apps/web/core/components/inbox/content/issue-properties.tsx +++ b/apps/web/core/components/inbox/content/issue-properties.tsx @@ -3,8 +3,10 @@ import React from "react"; import { observer } from "mobx-react"; import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react"; +import { DoubleCircleIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; -import { ControlLink, DoubleCircleIcon, Tooltip } from "@plane/ui"; +import { ControlLink } from "@plane/ui"; import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils"; // components import { DateDropdown } from "@/components/dropdowns/date"; diff --git a/apps/web/core/components/inbox/content/issue-root.tsx b/apps/web/core/components/inbox/content/issue-root.tsx index 095d466b3c..390aed13a7 100644 --- a/apps/web/core/components/inbox/content/issue-root.tsx +++ b/apps/web/core/components/inbox/content/issue-root.tsx @@ -26,7 +26,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useUser } from "@/hooks/store/user"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // store types -import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // services import { IntakeWorkItemVersionService } from "@/services/inbox"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx index a2eb5246b7..d6886eaf4e 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { PriorityIcon } from "@plane/propel/icons"; import { TIssuePriorities } from "@plane/types"; -import { PriorityIcon, Tag } from "@plane/ui"; +import { Tag } from "@plane/ui"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx index b5114ccd7f..0871a17da5 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx @@ -4,7 +4,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; import { EIconSize } from "@plane/constants"; -import { StateGroupIcon, Tag } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; +import { Tag } from "@plane/ui"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectState } from "@/hooks/store/use-project-state"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx b/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx index a3174f51c8..580fa39789 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx @@ -4,8 +4,8 @@ import { FC, useState } from "react"; import { observer } from "mobx-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { PriorityIcon } from "@plane/propel/icons"; import { TIssuePriorities } from "@plane/types"; -import { PriorityIcon } from "@plane/ui"; // plane constants // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/state.tsx b/apps/web/core/components/inbox/inbox-filter/filters/state.tsx index 7c2778db2a..4180ec3638 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/state.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/state.tsx @@ -3,8 +3,9 @@ import { FC, useState } from "react"; import { observer } from "mobx-react"; import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState } from "@plane/types"; -import { Loader, StateGroupIcon } from "@plane/ui"; +import { Loader } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; // hooks diff --git a/apps/web/core/components/inbox/modals/create-modal/create-root.tsx b/apps/web/core/components/inbox/modals/create-modal/create-root.tsx index d8d09247c9..66eb349ae4 100644 --- a/apps/web/core/components/inbox/modals/create-modal/create-root.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/create-root.tsx @@ -19,7 +19,8 @@ import { useAppRouter } from "@/hooks/use-app-router"; import useKeypress from "@/hooks/use-keypress"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web imports -import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe"; +import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button"; +import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // services import { FileService } from "@/services/file.service"; diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx index 2a36099378..b066199097 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -89,7 +89,6 @@ export const InboxIssueProperties: FC = observer((props) {/* labels */}
{}} value={data?.label_ids || []} onChange={(labelIds) => handleData("label_ids", labelIds)} projectId={projectId} diff --git a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx index 6d53a02340..d137f6a03e 100644 --- a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx +++ b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx @@ -4,7 +4,8 @@ import { FC, Fragment, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // ui import { useTranslation } from "@plane/i18n"; -import { Button, Calendar } from "@plane/ui"; +import { Calendar } from "@plane/propel/calendar"; +import { Button } from "@plane/ui"; export type InboxIssueSnoozeModalProps = { isOpen: boolean; @@ -48,11 +49,11 @@ export const InboxIssueSnoozeModal: FC = (props) =>
{ + onSelect={(date: Date | undefined) => { if (!date) return; setDate(date); }} diff --git a/apps/web/core/components/inbox/root.tsx b/apps/web/core/components/inbox/root.tsx index aefb688614..2b7dbe4436 100644 --- a/apps/web/core/components/inbox/root.tsx +++ b/apps/web/core/components/inbox/root.tsx @@ -3,8 +3,8 @@ import { observer } from "mobx-react"; import { PanelLeft } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { Intake } from "@plane/propel/icons"; import { EInboxIssueCurrentTab } from "@plane/types"; -import { Intake } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; diff --git a/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx b/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx index 367334f553..a99e459e47 100644 --- a/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx +++ b/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx @@ -5,7 +5,9 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; // plane imports -import { Tooltip, PriorityIcon, Row, Avatar } from "@plane/ui"; +import { PriorityIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Row, Avatar } from "@plane/ui"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; // components import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; diff --git a/apps/web/core/components/instance/maintenance-view.tsx b/apps/web/core/components/instance/maintenance-view.tsx index 8dd4c34e78..2fdc7f635a 100644 --- a/apps/web/core/components/instance/maintenance-view.tsx +++ b/apps/web/core/components/instance/maintenance-view.tsx @@ -2,33 +2,36 @@ import { FC } from "react"; import Image from "next/image"; -// ui -import { Button } from "@plane/ui"; +import { useTheme } from "next-themes"; // layouts import DefaultLayout from "@/layouts/default-layout"; // components import { MaintenanceMessage } from "@/plane-web/components/instance"; // images -import maintenanceModeImage from "@/public/maintenance-mode.webp"; +import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; +import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; -export const MaintenanceView: FC = () => ( - -
-
- ProjectSettingImg +export const MaintenanceView: FC = () => { + // hooks + const { resolvedTheme } = useTheme(); + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; + return ( + +
+
+ ProjectSettingImg +
+
+ +
-
- - -
-
- -); + + ); +}; diff --git a/apps/web/core/components/integration/github/single-user-select.tsx b/apps/web/core/components/integration/github/single-user-select.tsx index 1145355186..d8332dfc1c 100644 --- a/apps/web/core/components/integration/github/single-user-select.tsx +++ b/apps/web/core/components/integration/github/single-user-select.tsx @@ -92,7 +92,6 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, newUsers[index].email = ""; setUsers(newUsers); }} - optionsClassName="w-full" noChevron > {importOptions.map((option) => ( diff --git a/apps/web/core/components/integration/jira/give-details.tsx b/apps/web/core/components/integration/jira/give-details.tsx index 5f7e699d80..293a5c8715 100644 --- a/apps/web/core/components/integration/jira/give-details.tsx +++ b/apps/web/core/components/integration/jira/give-details.tsx @@ -181,7 +181,6 @@ export const JiraGetImportDetail: React.FC = observer(() => { )} } - optionsClassName="w-full" > {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( workspaceProjectIds.map((projectId) => { diff --git a/apps/web/core/components/integration/jira/import-users.tsx b/apps/web/core/components/integration/jira/import-users.tsx index 2adaf3ba35..f14be78d66 100644 --- a/apps/web/core/components/integration/jira/import-users.tsx +++ b/apps/web/core/components/integration/jira/import-users.tsx @@ -96,7 +96,6 @@ export const JiraImportUsers: FC = () => { input value={value} onChange={onChange} - optionsClassName="w-full" label={{Boolean(value) ? value : ("Ignore" as any)}} > Invite by email diff --git a/apps/web/core/components/integration/single-integration-card.tsx b/apps/web/core/components/integration/single-integration-card.tsx index dc6cec94ab..b4ce693d82 100644 --- a/apps/web/core/components/integration/single-integration-card.tsx +++ b/apps/web/core/components/integration/single-integration-card.tsx @@ -7,9 +7,10 @@ import { useParams } from "next/navigation"; import useSWR, { mutate } from "swr"; import { CheckCircle } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { Tooltip } from "@plane/propel/tooltip"; import { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; // ui -import { Button, Loader, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys"; // hooks diff --git a/apps/web/core/components/issues/attachment/attachment-detail.tsx b/apps/web/core/components/issues/attachment/attachment-detail.tsx index ae7ca9dbdd..eede3c1ff2 100644 --- a/apps/web/core/components/issues/attachment/attachment-detail.tsx +++ b/apps/web/core/components/issues/attachment/attachment-detail.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; // ui -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { convertBytesToSize, getFileExtension, diff --git a/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/apps/web/core/components/issues/attachment/attachment-list-item.tsx index 918c502917..fc071750a6 100644 --- a/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -4,9 +4,10 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Trash } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, TIssueServiceType } from "@plane/types"; // ui -import { CustomMenu, Tooltip } from "@plane/ui"; +import { CustomMenu } from "@plane/ui"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; // components // diff --git a/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx b/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx index 61924a25f0..7106896353 100644 --- a/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx +++ b/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // ui -import { CircularProgressIndicator, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { CircularProgressIndicator } from "@plane/ui"; // components import { getFileExtension } from "@plane/utils"; import { getFileIcon } from "@/components/icons"; diff --git a/apps/web/core/components/issues/attachment/attachment-upload-details.tsx b/apps/web/core/components/issues/attachment/attachment-upload-details.tsx index 1bc36318bb..ea868c8d2c 100644 --- a/apps/web/core/components/issues/attachment/attachment-upload-details.tsx +++ b/apps/web/core/components/issues/attachment/attachment-upload-details.tsx @@ -1,7 +1,8 @@ "use client"; import { observer } from "mobx-react"; -import { CircularProgressIndicator, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { CircularProgressIndicator } from "@plane/ui"; import { getFileExtension, truncateText } from "@plane/utils"; // ui // icons diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx index 7ace014ca2..f5862a7052 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx @@ -10,7 +10,7 @@ import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // Plane-web -import { CreateUpdateEpicModal } from "@/plane-web/components/epics"; +import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal"; import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; import { TIssueRelationTypes } from "@/plane-web/types"; // helper diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts index fe3fc5dfd8..0f4f8fdd58 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts @@ -46,7 +46,10 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub type: TOAST_TYPE.SUCCESS, title: t("common.link_copied"), message: t("entity.link_copied_to_clipboard", { - entity: t("epic.label", { count: 1 }), + entity: + issueServiceType === EIssueServiceType.ISSUES + ? t("common.sub_work_items", { count: 1 }) + : t("issue.label", { count: 1 }), }), }); }); @@ -77,7 +80,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub entity: issueServiceType === EIssueServiceType.ISSUES ? t("common.sub_work_items") - : t("issue.label", { count: 2 }), + : t("issue.label", { count: issueIds.length }), }), }); } catch { @@ -88,7 +91,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub entity: issueServiceType === EIssueServiceType.ISSUES ? t("common.sub_work_items") - : t("issue.label", { count: 2 }), + : t("issue.label", { count: issueIds.length }), }), }); } @@ -169,7 +172,12 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub setToast({ type: TOAST_TYPE.SUCCESS, title: t("toast.success"), - message: t("sub_work_item.remove.success"), + message: t("entity.remove.success", { + entity: + issueServiceType === EIssueServiceType.ISSUES + ? t("common.sub_work_items") + : t("issue.label", { count: 1 }), + }), }); captureSuccess({ eventName: WORK_ITEM_TRACKER_EVENTS.sub_issue.remove, @@ -185,7 +193,12 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub setToast({ type: TOAST_TYPE.ERROR, title: t("toast.error"), - message: t("sub_work_item.remove.error"), + message: t("entity.remove.failed", { + entity: + issueServiceType === EIssueServiceType.ISSUES + ? t("common.sub_work_items") + : t("issue.label", { count: 1 }), + }), }); } }, @@ -208,7 +221,12 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub setToast({ type: TOAST_TYPE.ERROR, title: t("toast.error"), - message: t("issue.delete.error"), + message: t("entity.delete.failed", { + entity: + issueServiceType === EIssueServiceType.ISSUES + ? t("common.sub_work_items") + : t("issue.label", { count: 1 }), + }), }); } }, diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index 1740d86be8..ad6cc65378 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -4,8 +4,9 @@ import { observer } from "mobx-react"; import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, EIssuesStoreType, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; -import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; +import { ControlLink, CustomMenu } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // helpers import { useSubIssueOperations } from "@/components/issues/issue-detail-widgets/sub-issues/helper"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index eec5db1a87..8dd4e681bd 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -17,6 +17,7 @@ import { IssueActivityLoader } from "./loader"; type TIssueActivityCommentRoot = { workspaceSlug: string; projectId: string; + isIntakeIssue: boolean; issueId: string; selectedFilters: TActivityFilters[]; activityOperations: TCommentsOperations; @@ -28,6 +29,7 @@ type TIssueActivityCommentRoot = { export const IssueActivityCommentRoot: FC = observer((props) => { const { workspaceSlug, + isIntakeIssue, issueId, selectedFilters, activityOperations, @@ -62,7 +64,7 @@ export const IssueActivityCommentRoot: FC = observer( activityOperations={activityOperations} ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined} showAccessSpecifier={!!showAccessSpecifier} - showCopyLinkOption + showCopyLinkOption={!isIntakeIssue} disabled={disabled} projectId={projectId} /> diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx index 22e18556e1..2c0cc96fd9 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx @@ -4,7 +4,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { RotateCcw } from "lucide-react"; // hooks -import { ArchiveIcon } from "@plane/ui"; +import { ArchiveIcon } from "@plane/propel/icons"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // components import { IssueActivityBlockComponent } from "./"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx index f24c9056b9..2297ccccd3 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx @@ -3,7 +3,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks -import { ContrastIcon } from "@plane/ui"; +import { ContrastIcon } from "@plane/propel/icons"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // components import { IssueActivityBlockComponent } from "./"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx index 7ef63f96ef..39272c6cab 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx @@ -3,8 +3,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; // plane imports +import { LayersIcon } from "@plane/propel/icons"; import { EInboxIssueSource } from "@plane/types"; -import { LayersIcon } from "@plane/ui"; // hooks import { capitalizeFirstLetter } from "@plane/utils"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index 8d84489e95..4543f81969 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -3,7 +3,7 @@ import { FC, ReactNode } from "react"; import { Network } from "lucide-react"; // plane imports -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { usePlatformOS } from "@/hooks/use-platform-os"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx index 5535b1bfa5..4bbe31dfeb 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // hooks -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { generateWorkItemLink } from "@plane/utils"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { usePlatformOS } from "@/hooks/use-platform-os"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/inbox.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/inbox.tsx index 5f3012c0ef..86f54c00cb 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/inbox.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/inbox.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks -import { Intake } from "@plane/ui"; +import { Intake } from "@plane/propel/icons"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // components import { IssueActivityBlockComponent } from "./"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/label-activity-chip.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/label-activity-chip.tsx index e7cf63f83b..9d9a263638 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/label-activity-chip.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/label-activity-chip.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; type TIssueLabelPill = { name?: string; color?: string }; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/module.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/module.tsx index e59b52b09c..63d748b351 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/module.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/module.tsx @@ -3,7 +3,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks -import { DiceIcon } from "@plane/ui"; +import { DiceIcon } from "@plane/propel/icons"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // components import { IssueActivityBlockComponent } from "./"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/state.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/state.tsx index b441304eef..5af66eede2 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/state.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/state.tsx @@ -3,7 +3,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks -import { DoubleCircleIcon } from "@plane/ui"; +import { DoubleCircleIcon } from "@plane/propel/icons"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/root.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/root.tsx index a07a9dbadd..4f2cee9fd3 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -130,6 +130,7 @@ export const IssueActivity: FC = observer((props) => { = observer((props) const reactionUsers = (reactionIds?.[reaction] || []) .map((reactionId) => { const reactionDetails = getCommentReactionById(reactionId); - return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + return reactionDetails + ? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name + : null; }) .filter((displayName): displayName is string => !!displayName); const formattedUsers = formatTextList(reactionUsers); diff --git a/apps/web/core/components/issues/issue-detail/reactions/issue.tsx b/apps/web/core/components/issues/issue-detail/reactions/issue.tsx index f641cbb56f..ff5ae30996 100644 --- a/apps/web/core/components/issues/issue-detail/reactions/issue.tsx +++ b/apps/web/core/components/issues/issue-detail/reactions/issue.tsx @@ -2,10 +2,11 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react"; +import { Tooltip } from "@plane/propel/tooltip"; import { IUser } from "@plane/types"; // hooks // ui -import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { cn, formatTextList } from "@plane/utils"; // helpers import { renderEmoji } from "@/helpers/emoji.helper"; @@ -84,7 +85,9 @@ export const IssueReaction: FC = observer((props) => { const reactionUsers = (reactionIds?.[reaction] || []) .map((reactionId) => { const reactionDetails = getReactionById(reactionId); - return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + return reactionDetails + ? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name + : null; }) .filter((displayName): displayName is string => !!displayName); diff --git a/apps/web/core/components/issues/issue-detail/relation-select.tsx b/apps/web/core/components/issues/issue-detail/relation-select.tsx index b79058274c..3bcc6221d1 100644 --- a/apps/web/core/components/issues/issue-detail/relation-select.tsx +++ b/apps/web/core/components/issues/issue-detail/relation-select.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { Pencil, X } from "lucide-react"; // Plane +import { Tooltip } from "@plane/propel/tooltip"; import { ISearchIssueResponse } from "@plane/types"; -import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // components import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal"; diff --git a/apps/web/core/components/issues/issue-detail/sidebar.tsx b/apps/web/core/components/issues/issue-detail/sidebar.tsx index 65c5803e11..141f3ed193 100644 --- a/apps/web/core/components/issues/issue-detail/sidebar.tsx +++ b/apps/web/core/components/issues/issue-detail/sidebar.tsx @@ -6,7 +6,7 @@ import { CalendarCheck2, CalendarClock, LayoutPanelTop, Signal, Tag, Triangle, U // i18n import { useTranslation } from "@plane/i18n"; // ui -import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/ui"; +import { ContrastIcon, DiceIcon, DoubleCircleIcon } from "@plane/propel/icons"; import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils"; // components import { DateDropdown } from "@/components/dropdowns/date"; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx b/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx index 93c18c9c30..e5ea1c0cf6 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx @@ -8,9 +8,10 @@ import { MoreHorizontal } from "lucide-react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { TIssue } from "@plane/types"; // ui -import { Tooltip, ControlLink } from "@plane/ui"; +import { ControlLink } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // helpers // hooks diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/draft-issues.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/draft-issues.tsx deleted file mode 100644 index bb10176ae9..0000000000 --- a/apps/web/core/components/issues/issue-layouts/empty-states/draft-issues.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { observer } from "mobx-react"; - -// FIXME: Project drafts is deprecated. Remove this component and all the related code. -export const ProjectDraftEmptyState: React.FC = observer(() => ( -
-)); diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx index 1db3a3d8d1..ae23acb574 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx @@ -5,7 +5,6 @@ import { TeamProjectWorkItemEmptyState } from "@/plane-web/components/issues/iss // components import { ProjectArchivedEmptyState } from "./archived-issues"; import { CycleEmptyState } from "./cycle"; -import { ProjectDraftEmptyState } from "./draft-issues"; import { GlobalViewEmptyState } from "./global-view"; import { ModuleEmptyState } from "./module"; import { ProfileViewEmptyState } from "./profile-view"; @@ -29,8 +28,6 @@ export const IssueLayoutEmptyState = (props: Props) => { return ; case EIssuesStoreType.MODULE: return ; - case EIssuesStoreType.DRAFT: - return ; case EIssuesStoreType.GLOBAL: return ; case EIssuesStoreType.PROFILE: diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/cycle.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/cycle.tsx index 0a08aec5fa..3be676baf1 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/cycle.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/cycle.tsx @@ -2,9 +2,9 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; +import { CycleGroupIcon } from "@plane/propel/icons"; import { TCycleGroups } from "@plane/types"; // hooks -import { CycleGroupIcon } from "@plane/ui"; import { useCycle } from "@/hooks/store/use-cycle"; // ui // types diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/module.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/module.tsx index 524938c473..84e92d6336 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/module.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/module.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // hooks -import { DiceIcon } from "@plane/ui"; +import { DiceIcon } from "@plane/propel/icons"; import { useModule } from "@/hooks/store/use-module"; // ui diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/priority.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/priority.tsx index 9735d24461..2dc81c2e98 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/priority.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/priority.tsx @@ -4,8 +4,8 @@ import { observer } from "mobx-react"; // icons import { X } from "lucide-react"; +import { PriorityIcon } from "@plane/propel/icons"; import { TIssuePriorities } from "@plane/types"; -import { PriorityIcon } from "@plane/ui"; // types type Props = { diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx deleted file mode 100644 index 3336327c37..0000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// plane imports -import { EIssueFilterType } from "@plane/constants"; -import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types"; -// hooks -import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useProjectState } from "@/hooks/store/use-project-state"; -// local imports -import { AppliedFiltersList } from "../filters-list"; - -export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { - // router - const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; - // store hooks - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.DRAFT); - const { projectLabels } = useLabel(); - const { projectStates } = useProjectState(); - // derived values - const userFilters = issueFilters?.filters; - // filters whose value not null or empty array - const appliedFilters: IIssueFilterOptions = {}; - Object.entries(userFilters ?? {}).forEach(([key, value]) => { - if (!value) return; - if (Array.isArray(value) && value.length === 0) return; - appliedFilters[key as keyof IIssueFilterOptions] = value; - }); - - const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { - if (!workspaceSlug || !projectId) return; - - // remove all values of the key if value is null - if (!value) { - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { - [key]: null, - }); - return; - } - - // remove the passed value from the key - let newValues = issueFilters?.filters?.[key] ?? []; - newValues = newValues.filter((val) => val !== value); - - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { - [key]: newValues, - }); - }; - - const handleClearAllFilters = () => { - if (!workspaceSlug || !projectId) return; - - const newFilters: IIssueFilterOptions = {}; - Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters }); - }; - - // return if no filters are applied - if (Object.keys(appliedFilters).length === 0) return null; - - return ( -
- -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx index e1b7129839..010492274c 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx @@ -90,7 +90,7 @@ export const ProjectAppliedFiltersRoot: React.FC - {isEditingAllowed && ( + {isEditingAllowed && storeType === EIssuesStoreType.PROJECT && ( void; diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx index d02fd6dad2..f55aca4347 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx @@ -5,8 +5,8 @@ import { observer } from "mobx-react"; // icons import { X } from "lucide-react"; import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState } from "@plane/types"; -import { StateGroupIcon } from "@plane/ui"; // types type Props = { diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/cycle.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/cycle.tsx index bfb5dfe043..0e4b17b451 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/cycle.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/cycle.tsx @@ -4,9 +4,10 @@ import React, { useMemo, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { CycleGroupIcon } from "@plane/propel/icons"; import { TCycleGroups } from "@plane/types"; // components -import { Loader, CycleGroupIcon } from "@plane/ui"; +import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; import { useCycle } from "@/hooks/store/use-cycle"; // ui diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/module.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/module.tsx index 7b02e9536a..ca4b4c63b2 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/module.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -5,7 +5,8 @@ import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components -import { Loader, DiceIcon } from "@plane/ui"; +import { DiceIcon } from "@plane/propel/icons"; +import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; import { useModule } from "@/hooks/store/use-module"; // ui diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/priority.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/priority.tsx index d519f63843..36bb16991f 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/priority.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/priority.tsx @@ -6,7 +6,7 @@ import { observer } from "mobx-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { PriorityIcon } from "@plane/ui"; +import { PriorityIcon } from "@plane/propel/icons"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/state-group.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/state-group.tsx index be38f0c73d..4896b0d726 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/state-group.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/state-group.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { STATE_GROUPS } from "@plane/constants"; -import { StateGroupIcon } from "@plane/ui"; +import { StateGroupIcon } from "@plane/propel/icons"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/state.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/state.tsx index f8506c817d..c7f0206b8c 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/state.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/state.tsx @@ -4,9 +4,10 @@ import React, { useMemo, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; import { EIconSize } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState } from "@plane/types"; // components -import { Loader, StateGroupIcon } from "@plane/ui"; +import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; // ui // types diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx index baa43a8482..2bd88c2629 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -4,9 +4,9 @@ import React from "react"; // plane constants import { ISSUE_LAYOUTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueLayoutTypes } from "@plane/types"; // ui -import { Tooltip } from "@plane/ui"; // types import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; import { usePlatformOS } from "@/hooks/use-platform-os"; diff --git a/apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx b/apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx index a4fa8f83e6..7f2991f895 100644 --- a/apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx +++ b/apps/web/core/components/issues/issue-layouts/gantt/blocks.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui -import { Tooltip, ControlLink } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { ControlLink } from "@plane/ui"; import { findTotalDaysInRange, generateWorkItemLink } from "@plane/utils"; // components import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants"; @@ -62,7 +63,7 @@ export const IssueGanttBlock: React.FC = observer((props) => {
{message}
} - position="top-left" + position="top-start" disabled={!message} >
= observer((props) => { const storeType = useIssueStoreType(); // router const { workspaceSlug, projectId, moduleId, cycleId } = useParams(); - const pathname = usePathname(); - - const isDraftIssue = pathname.includes("draft-issue"); const renderExistingIssueModal = moduleId || cycleId; const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; @@ -97,7 +94,6 @@ export const HeaderGroupByCard: FC = observer((props) => { onClose={() => setIsOpen(false)} data={issuePayload} storeType={storeType} - isDraft={isDraftIssue} /> )} diff --git a/apps/web/core/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx deleted file mode 100644 index 568dd315fe..0000000000 --- a/apps/web/core/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { observer } from "mobx-react"; -// local imports -import { DraftIssueQuickActions } from "../../quick-action-dropdowns"; -import { BaseKanBanRoot } from "../base-kanban-root"; - -export const DraftKanBanLayout: React.FC = observer(() => ); diff --git a/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx b/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx index 99740ecfab..aef8560536 100644 --- a/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -30,7 +30,6 @@ type ListStoreType = | EIssuesStoreType.MODULE | EIssuesStoreType.CYCLE | EIssuesStoreType.PROJECT_VIEW - | EIssuesStoreType.DRAFT | EIssuesStoreType.PROFILE | EIssuesStoreType.ARCHIVED | EIssuesStoreType.WORKSPACE_DRAFT diff --git a/apps/web/core/components/issues/issue-layouts/list/block.tsx b/apps/web/core/components/issues/issue-layouts/list/block.tsx index d6613b68aa..0451b82c09 100644 --- a/apps/web/core/components/issues/issue-layouts/list/block.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/block.tsx @@ -7,9 +7,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { ChevronRight } from "lucide-react"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui -import { Spinner, Tooltip, ControlLink, setToast, TOAST_TYPE, Row } from "@plane/ui"; +import { Spinner, ControlLink, setToast, TOAST_TYPE, Row } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // components import { MultipleSelectEntityAction } from "@/components/core/multiple-select"; @@ -264,7 +265,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { diff --git a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx index 2e34570d36..a6bf2efe49 100644 --- a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import { CircleDashed, Plus } from "lucide-react"; // types import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; @@ -58,10 +58,8 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false); // router const { workspaceSlug, projectId, moduleId, cycleId } = useParams(); - const pathname = usePathname(); const storeType = useIssueStoreType(); // derived values - const isDraftIssue = pathname.includes("draft-issue"); const renderExistingIssueModal = moduleId || cycleId; const existingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; const isGroupSelectionEmpty = selectionHelpers.isGroupSelected(groupID) === "empty"; @@ -167,7 +165,6 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { onClose={() => setIsOpen(false)} data={issuePayload} storeType={storeType} - isDraft={isDraftIssue} /> )} diff --git a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx index e5f567d35d..073eec27c0 100644 --- a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx @@ -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; diff --git a/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts b/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts index 089623ed93..500ac1a893 100644 --- a/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts @@ -1,4 +1,4 @@ -import { Placement } from "@popperjs/core"; +import { TPlacement } from "@plane/propel/utils/placement"; import { TIssue } from "@plane/types"; export interface IQuickActionProps { @@ -13,7 +13,7 @@ export interface IQuickActionProps { customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; - placements?: Placement; + placements?: TPlacement; } export type TRenderQuickActions = ({ @@ -26,6 +26,6 @@ export type TRenderQuickActions = ({ issue: TIssue; parentRef: React.RefObject; customActionButton?: React.ReactElement; - placement?: Placement; + placement?: TPlacement; portalElement?: HTMLDivElement | null; }) => React.ReactNode; diff --git a/apps/web/core/components/issues/issue-layouts/list/roots/draft-issue-root.tsx b/apps/web/core/components/issues/issue-layouts/list/roots/draft-issue-root.tsx deleted file mode 100644 index 4b3c8c5383..0000000000 --- a/apps/web/core/components/issues/issue-layouts/list/roots/draft-issue-root.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// local imports -import { DraftIssueQuickActions } from "../../quick-action-dropdowns"; -import { BaseListRoot } from "../base-list-root"; - -export const DraftIssueListLayout: FC = observer(() => { - const { workspaceSlug, projectId } = useParams(); - - if (!workspaceSlug || !projectId) return null; - - return ; -}); diff --git a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx index 62fdec30af..a14a2ad958 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -10,9 +10,9 @@ import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-r import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; // i18n import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; // ui -import { Tooltip } from "@plane/ui"; import { cn, getDate, diff --git a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx index 20d137e9d8..357423e049 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx @@ -277,7 +277,7 @@ export const LabelDropdown = (props: ILabelDropdownProps) => { )) ) : submitting ? ( - + ) : canCreateLabel ? (

{ diff --git a/apps/web/core/components/issues/issue-layouts/properties/labels.tsx b/apps/web/core/components/issues/issue-layouts/properties/labels.tsx index 473be0e965..efa9551458 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/labels.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/labels.tsx @@ -9,9 +9,9 @@ import { useOutsideClickDetector } from "@plane/hooks"; // i18n import { useTranslation } from "@plane/i18n"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { IIssueLabel } from "@plane/types"; // ui -import { Tooltip } from "@plane/ui"; // hooks import { cn } from "@plane/utils"; import { useLabel } from "@/hooks/store/use-label"; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index f5ce7441d1..4d8116f3a4 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -118,7 +118,6 @@ export const AllIssueQuickActions: React.FC = observer((props if (issueToEdit && handleUpdate) await handleUpdate(data); }} storeType={EIssuesStoreType.GLOBAL} - isDraft={false} /> {issue.project_id && workspaceSlug && ( = observer((pro if (issueToEdit && handleUpdate) await handleUpdate(data); }} storeType={EIssuesStoreType.CYCLE} - isDraft={false} /> {issue.project_id && workspaceSlug && ( = observer((props) => { - const { - issue, - handleDelete, - handleUpdate, - customActionButton, - portalElement, - readOnly = false, - placements = "bottom-end", - parentRef, - } = props; - // router - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - // states - const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(undefined); - const [deleteIssueModal, setDeleteIssueModal] = useState(false); - // store hooks - const { allowPermissions } = useUserPermissions(); - // derived values - const activeLayout = "Draft Issues"; - // auth - const isEditingAllowed = - allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString(), - issue.project_id ?? undefined - ) && !readOnly; - const isDeletingAllowed = isEditingAllowed; - - const isDraftIssue = pathname?.includes("draft-issues") || false; - - const duplicateIssuePayload = omit( - { - ...issue, - name: `${issue.name} (copy)`, - is_draft: isDraftIssue ? false : issue.is_draft, - sourceIssueId: issue.id, - }, - ["id"] - ); - - // Menu items and modals using helper - const menuItemProps: MenuItemFactoryProps = { - issue, - workspaceSlug: workspaceSlug?.toString(), - activeLayout, - isEditingAllowed, - isDeletingAllowed, - isDraftIssue, - setIssueToEdit, - setCreateUpdateIssueModal, - setDeleteIssueModal, - handleDelete, - handleUpdate, - storeType: EIssuesStoreType.DRAFT, - }; - - const MENU_ITEMS = useDraftIssueMenuItems(menuItemProps); - - const CONTEXT_MENU_ITEMS: TContextMenuItem[] = MENU_ITEMS.map((item) => ({ - ...item, - onClick: () => { - captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.DRAFT }); - item.action(); - }, - })); - - return ( - <> - {/* Modals */} - setDeleteIssueModal(false)} - onSubmit={handleDelete} - /> - { - setCreateUpdateIssueModal(false); - setIssueToEdit(undefined); - }} - data={issueToEdit ?? duplicateIssuePayload} - onSubmit={async (data) => { - if (issueToEdit && handleUpdate) await handleUpdate(data); - }} - storeType={EIssuesStoreType.DRAFT} - isDraft={isDraftIssue} - /> - - - - {MENU_ITEMS.map((item) => { - if (item.shouldRender === false) return null; - return ( - { - e.preventDefault(); - e.stopPropagation(); - captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.QUICK_ACTIONS.DRAFT }); - item.action(); - }} - className={cn( - "flex items-center gap-2", - { - "text-custom-text-400": item.disabled, - }, - item.className - )} - disabled={item.disabled} - > - {item.icon && } -

-
{item.title}
- {item.description && ( -

- {item.description} -

- )} -
- - ); - })} - - - ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx index 9d6862aaa2..32e114e6eb 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/helper.tsx @@ -2,8 +2,9 @@ import { useMemo } from "react"; import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle, ArchiveRestoreIcon } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { ArchiveIcon } from "@plane/propel/icons"; import { EIssuesStoreType, TIssue } from "@plane/types"; -import { ArchiveIcon, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +import { TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; // types import { createCopyMenuWithDuplication } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns"; @@ -54,7 +55,6 @@ export interface MenuItemFactoryProps { isRestoringAllowed?: boolean; isInArchivableGroup?: boolean; issueTypeDetail?: { is_active?: boolean }; - isDraftIssue?: boolean; // Action handlers setIssueToEdit: (issue: TIssue | undefined) => void; setCreateUpdateIssueModal: (open: boolean) => void; @@ -369,9 +369,3 @@ export const useArchivedIssueMenuItems = (props: MenuItemFactoryProps): TContext [factory] ); }; - -export const useDraftIssueMenuItems = (props: MenuItemFactoryProps): TContextMenuItem[] => { - const factory = useMenuItemFactory(props); - - return useMemo(() => [factory.createEditMenuItem(), factory.createDeleteMenuItem()], [factory]); -}; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts index 0862c68a93..19147a0ad6 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -1,7 +1,6 @@ export * from "./all-issue"; export * from "./archived-issue"; export * from "./cycle-issue"; -export * from "./draft-issue"; export * from "./module-issue"; export * from "./project-issue"; export * from "./helper"; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx index f2cd9a5c50..1d83efc4e5 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx @@ -88,13 +88,10 @@ export const WorkItemDetailQuickActions: React.FC {issue.project_id && workspaceSlug && ( diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 8b5db5b56c..de20b469a3 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -134,7 +134,6 @@ export const ModuleIssueQuickActions: React.FC = observer((pr if (issueToEdit && handleUpdate) await handleUpdate(data); }} storeType={EIssuesStoreType.MODULE} - isDraft={false} /> {issue.project_id && workspaceSlug && ( = observer((p } = props; // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [issueToEdit, setIssueToEdit] = useState(undefined); @@ -71,13 +70,10 @@ export const ProjectIssueQuickActions: React.FC = observer((p const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group); const isDeletingAllowed = isEditingAllowed; - const isDraftIssue = pathname?.includes("draft-issues") || false; - const duplicateIssuePayload = omit( { ...issue, name: `${issue.name} (copy)`, - is_draft: isDraftIssue ? false : issue.is_draft, sourceIssueId: issue.id, }, ["id"] @@ -93,7 +89,6 @@ export const ProjectIssueQuickActions: React.FC = observer((p isArchivingAllowed, isDeletingAllowed, isInArchivableGroup, - isDraftIssue, setIssueToEdit, setCreateUpdateIssueModal, setDeleteIssueModal, @@ -141,7 +136,6 @@ export const ProjectIssueQuickActions: React.FC = observer((p if (issueToEdit && handleUpdate) await handleUpdate(data); }} storeType={EIssuesStoreType.PROJECT} - isDraft={isDraftIssue} /> {issue.project_id && workspaceSlug && ( { - switch (props.activeLayout) { - case EIssueLayoutTypes.LIST: - return ; - case EIssueLayoutTypes.KANBAN: - return ; - default: - return null; - } -}; -export const DraftIssueLayoutRoot: React.FC = observer(() => { - // router - const { workspaceSlug, projectId } = useParams(); - // hooks - const { issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - - const { isLoading } = useSWR( - workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, - async () => { - if (workspaceSlug && projectId) { - await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - } - }, - { revalidateIfStale: false, revalidateOnFocus: false } - ); - - const issueFilters = issuesFilter?.getIssueFilters(projectId?.toString()); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; - - if (!workspaceSlug || !projectId) return <>; - - if (isLoading && !issueFilters) - return ( -
- -
- ); - - return ( - -
- -
- - {/* issue peek overview */} - -
-
-
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 750dfb35a4..fabf4c0d4d 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -8,9 +8,10 @@ import { SPREADSHEET_SELECT_GROUP } from "@plane/constants"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, IIssueDisplayProperties, TIssue } from "@plane/types"; // ui -import { ControlLink, Row, Tooltip } from "@plane/ui"; +import { ControlLink, Row } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // components import { MultipleSelectEntityAction } from "@/components/core/multiple-select"; diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index a4c90897c3..a7b0b9fc76 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -12,6 +12,7 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed"; import { ContrastIcon } from "lucide-react"; // plane types import { EIconSize, ISSUE_PRIORITIES, STATE_GROUPS } from "@plane/constants"; +import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon, ISvgIcons } from "@plane/propel/icons"; import { EIssuesStoreType, GroupByColumnTypes, @@ -30,7 +31,7 @@ import { TGetColumns, } from "@plane/types"; // plane ui -import { Avatar, CycleGroupIcon, DiceIcon, ISvgIcons, PriorityIcon, StateGroupIcon } from "@plane/ui"; +import { Avatar } from "@plane/ui"; import { renderFormattedDate, getFileURL } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; @@ -71,7 +72,8 @@ export const isWorkspaceLevel = (type: EIssuesStoreType) => EIssuesStoreType.GLOBAL, EIssuesStoreType.TEAM, EIssuesStoreType.TEAM_VIEW, - EIssuesStoreType.PROJECT_VIEW, + EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS, + EIssuesStoreType.WORKSPACE_DRAFT, ].includes(type) ? true : false; @@ -520,7 +522,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 = {}; const issueUpdates: IssueUpdates = {}; @@ -548,7 +550,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]); diff --git a/apps/web/core/components/issues/issue-modal/base.tsx b/apps/web/core/components/issues/issue-modal/base.tsx index ed1351669f..7a00bc1784 100644 --- a/apps/web/core/components/issues/issue-modal/base.tsx +++ b/apps/web/core/components/issues/issue-modal/base.tsx @@ -86,12 +86,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( setDescription(data?.description_html || "

"); return; } - const response = await fetchIssue( - workspaceSlug.toString(), - projectId.toString(), - issueId, - isDraft ? "DRAFT" : "DEFAULT" - ); + const response = await fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId); if (response) setDescription(response?.description_html || "

"); }; diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index 6b3c060d7b..53abf7bd8e 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -42,7 +42,6 @@ type TIssueDefaultPropertiesProps = { parentId: string | null; isDraft: boolean; handleFormChange: () => void; - setLabelModal: React.Dispatch>; setSelectedParentIssue: (issue: ISearchIssueResponse) => void; }; @@ -58,7 +57,6 @@ export const IssueDefaultProperties: React.FC = ob parentId, isDraft, handleFormChange, - setLabelModal, setSelectedParentIssue, } = props; // states @@ -74,7 +72,8 @@ export const IssueDefaultProperties: React.FC = ob const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); - const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + const canCreateLabel = + projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); const minDate = getDate(startDate); minDate?.setDate(minDate.getDate()); @@ -147,7 +146,6 @@ export const IssueDefaultProperties: React.FC = ob render={({ field: { value, onChange } }) => (
{ onChange(labelIds); @@ -155,7 +153,7 @@ export const IssueDefaultProperties: React.FC = ob }} projectId={projectId ?? undefined} tabIndex={getIndex("label_ids")} - createLabelEnabled={canCreateLabel} + createLabelEnabled={!!canCreateLabel} />
)} diff --git a/apps/web/core/components/issues/issue-modal/form.tsx b/apps/web/core/components/issues/issue-modal/form.tsx index 9196bb7c42..c4fbb6d413 100644 --- a/apps/web/core/components/issues/issue-modal/form.tsx +++ b/apps/web/core/components/issues/issue-modal/form.tsx @@ -28,19 +28,18 @@ import { IssueProjectSelect, IssueTitleInput, } from "@/components/issues/issue-modal/components"; -import { CreateLabelModal } from "@/components/labels"; // helpers // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useLabel } from "@/hooks/store/use-label"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; // plane web imports -import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe"; +import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button"; +import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal"; import { IssueTypeSelect, WorkItemTemplateSelect } from "@/plane-web/components/issues/issue-modal"; import { WorkItemModalAdditionalProperties } from "@/plane-web/components/issues/issue-modal/modal-additional-properties"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; @@ -96,7 +95,6 @@ export const IssueFormRoot: FC = observer((props) => { } = props; // states - const [labelModal, setLabelModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false); const [isMoving, setIsMoving] = useState(false); @@ -125,7 +123,6 @@ export const IssueFormRoot: FC = observer((props) => { } = useIssueModal(); const { isMobile } = usePlatformOS(); const { moveIssue } = useWorkspaceDraftIssues(); - const { createLabel } = useLabel(); const { issue: { getIssueById }, @@ -362,17 +359,6 @@ export const IssueFormRoot: FC = observer((props) => { return ( - {projectId && ( - setLabelModal(false)} - onSuccess={(response) => { - setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]); - handleFormChange(); - }} - /> - )}
= observer((props) => { parentId={watch("parent_id")} isDraft={isDraft} handleFormChange={handleFormChange} - setLabelModal={setLabelModal} setSelectedParentIssue={setSelectedParentIssue} />
diff --git a/apps/web/core/components/issues/label.tsx b/apps/web/core/components/issues/label.tsx index 7e3cbae30f..c88c9a0b40 100644 --- a/apps/web/core/components/issues/label.tsx +++ b/apps/web/core/components/issues/label.tsx @@ -2,7 +2,7 @@ import React from "react"; // components -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { labelDetails: any[]; diff --git a/apps/web/core/components/issues/peek-overview/error.tsx b/apps/web/core/components/issues/peek-overview/error.tsx index 72d4dd319a..ec6f57d9c1 100644 --- a/apps/web/core/components/issues/peek-overview/error.tsx +++ b/apps/web/core/components/issues/peek-overview/error.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { MoveRight } from "lucide-react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // components import { EmptyState } from "@/components/common/empty-state"; // hooks diff --git a/apps/web/core/components/issues/peek-overview/header.tsx b/apps/web/core/components/issues/peek-overview/header.tsx index 8f69f5ac38..99c79ced4b 100644 --- a/apps/web/core/components/issues/peek-overview/header.tsx +++ b/apps/web/core/components/issues/peek-overview/header.tsx @@ -7,16 +7,10 @@ import { Link2, MoveDiagonal, MoveRight } from "lucide-react"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType, TNameDescriptionLoader } from "@plane/types"; -import { - CenterPanelIcon, - CustomSelect, - FullScreenPanelIcon, - SidePanelIcon, - TOAST_TYPE, - Tooltip, - setToast, -} from "@plane/ui"; +import { CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/issues/peek-overview/issue-detail.tsx b/apps/web/core/components/issues/peek-overview/issue-detail.tsx index fc3df13586..781aa623cf 100644 --- a/apps/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/apps/web/core/components/issues/peek-overview/issue-detail.tsx @@ -14,7 +14,7 @@ import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // plane web components -import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover"; import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher"; // plane web hooks import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; diff --git a/apps/web/core/components/issues/peek-overview/loader.tsx b/apps/web/core/components/issues/peek-overview/loader.tsx index 0fe64ec4cd..73240f758c 100644 --- a/apps/web/core/components/issues/peek-overview/loader.tsx +++ b/apps/web/core/components/issues/peek-overview/loader.tsx @@ -2,7 +2,8 @@ import { FC } from "react"; import { MoveRight } from "lucide-react"; -import { Loader, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Loader } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; diff --git a/apps/web/core/components/issues/peek-overview/properties.tsx b/apps/web/core/components/issues/peek-overview/properties.tsx index 25a33a221c..2017de0597 100644 --- a/apps/web/core/components/issues/peek-overview/properties.tsx +++ b/apps/web/core/components/issues/peek-overview/properties.tsx @@ -6,7 +6,7 @@ import { Signal, Tag, Triangle, LayoutPanelTop, CalendarClock, CalendarCheck2, U // i18n import { useTranslation } from "@plane/i18n"; // ui icons -import { DiceIcon, DoubleCircleIcon, ContrastIcon } from "@plane/ui"; +import { DiceIcon, DoubleCircleIcon, ContrastIcon } from "@plane/propel/icons"; import { cn, getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils"; // components import { DateDropdown } from "@/components/dropdowns/date"; diff --git a/apps/web/core/components/issues/peek-overview/root.tsx b/apps/web/core/components/issues/peek-overview/root.tsx index a44bfa1c9d..926b5430c1 100644 --- a/apps/web/core/components/issues/peek-overview/root.tsx +++ b/apps/web/core/components/issues/peek-overview/root.tsx @@ -64,7 +64,7 @@ export const IssuePeekOverview: FC = observer((props) => fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { setError(false); - await fetchIssue(workspaceSlug, projectId, issueId, is_draft ? "DRAFT" : "DEFAULT"); + await fetchIssue(workspaceSlug, projectId, issueId); } catch (error) { setError(true); console.error("Error fetching the parent issue", error); diff --git a/apps/web/core/components/issues/peek-overview/view.tsx b/apps/web/core/components/issues/peek-overview/view.tsx index e06a483975..bbae72db33 100644 --- a/apps/web/core/components/issues/peek-overview/view.tsx +++ b/apps/web/core/components/issues/peek-overview/view.tsx @@ -110,16 +110,16 @@ export const IssueView: FC = observer((props) => { const peekOverviewIssueClassName = cn( !embedIssue - ? "fixed z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300" + ? "absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300" : `w-full h-full`, !embedIssue && { - "top-2 bottom-2 right-2 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek", + "top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek", "size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal", "inset-0 m-4 absolute": peekMode === "full-screen", } ); - const shouldUsePortal = !embedIssue && peekMode === "full-screen"; + const shouldUsePortal = !embedIssue; const portalContainer = document.getElementById("full-screen-portal") as HTMLElement; diff --git a/apps/web/core/components/issues/relations/issue-list-item.tsx b/apps/web/core/components/issues/relations/issue-list-item.tsx index 7099683feb..52ce7f967f 100644 --- a/apps/web/core/components/issues/relations/issue-list-item.tsx +++ b/apps/web/core/components/issues/relations/issue-list-item.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { X, Pencil, Trash, Link as LinkIcon } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { EIssueServiceType, TIssue, TIssueServiceType } from "@plane/types"; -import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; +import { ControlLink, CustomMenu } from "@plane/ui"; import { generateWorkItemLink } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/select/base.tsx b/apps/web/core/components/issues/select/base.tsx index 8c48cf78e8..07ee8d9179 100644 --- a/apps/web/core/components/issues/select/base.tsx +++ b/apps/web/core/components/issues/select/base.tsx @@ -2,8 +2,9 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, Component, Plus, Search, Tag } from "lucide-react"; +import { Check, Component, Loader, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; +import { getRandomLabelColor } from "@plane/constants"; // plane imports import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; @@ -26,7 +27,7 @@ export type TWorkItemLabelSelectBaseProps = { onChange: (value: string[]) => void; onDropdownOpen?: () => void; placement?: Placement; - setIsOpen: React.Dispatch>; + createLabel?: (data: Partial) => Promise; tabIndex?: number; value: string[]; }; @@ -43,7 +44,7 @@ export const WorkItemLabelSelectBase: React.FC = onChange, onDropdownOpen, placement, - setIsOpen, + createLabel, tabIndex, value, } = props; @@ -55,6 +56,7 @@ export const WorkItemLabelSelectBase: React.FC = const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); // plane hooks const { t } = useTranslation(); // store hooks @@ -88,6 +90,26 @@ export const WorkItemLabelSelectBase: React.FC = onChange(val); }; + const searchInputKeyDown = async (e: React.KeyboardEvent) => { + const q = query.trim(); + if (q !== "" && e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setQuery(""); + return; + } + if ( + q !== "" && + e.key === "Enter" && + !e.nativeEvent.isComposing && + createLabelEnabled && + filteredOptions.length === 0 && + !submitting + ) { + e.preventDefault(); + await handleAddLabel(q); + } + }; const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); const handleOnClick = (e: React.MouseEvent) => { @@ -104,6 +126,23 @@ export const WorkItemLabelSelectBase: React.FC = } }, [isDropdownOpen, isMobile]); + const handleAddLabel = async (labelName: string) => { + if (!createLabel || submitting) return; + const name = labelName.trim(); + if (!name) return; + setSubmitting(true); + try { + const existing = labelsList.find((l) => l.name.toLowerCase() === name.toLowerCase()); + const idToAdd = existing ? existing.id : (await createLabel({ name, color: getRandomLabelColor() })).id; + onChange(Array.from(new Set([...value, idToAdd]))); + setQuery(""); + } catch (e) { + console.error("Failed to create label", e); + } finally { + setSubmitting(false); + } + }; + return ( = onChange={(event) => setQuery(event.target.value)} placeholder={t("search")} displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
@@ -242,22 +282,31 @@ export const WorkItemLabelSelectBase: React.FC =
); }) + ) : submitting ? ( + + ) : createLabelEnabled ? ( +

{ + if (!query.length) return; + handleAddLabel(query); + }} + className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`} + > + {/* TODO: translate here */} + {query.length ? ( + <> + + Add "{query}" to labels + + ) : ( + t("label.create.type") + )} +

) : (

{t("no_matching_results")}

) ) : (

{t("loading")}

)} - {createLabelEnabled && ( - - )}
diff --git a/apps/web/core/components/issues/select/dropdown.tsx b/apps/web/core/components/issues/select/dropdown.tsx index be66d709ab..08d792b77f 100644 --- a/apps/web/core/components/issues/select/dropdown.tsx +++ b/apps/web/core/components/issues/select/dropdown.tsx @@ -1,8 +1,11 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, IIssueLabel } from "@plane/types"; // hooks import { useLabel } from "@/hooks/store/use-label"; +import { useUserPermissions } from "@/hooks/store/user"; // local imports import { TWorkItemLabelSelectBaseProps, WorkItemLabelSelectBase } from "./base"; @@ -15,21 +18,35 @@ export const IssueLabelSelect: React.FC = observer((p // router const { workspaceSlug } = useParams(); // store hooks - const { getProjectLabelIds, getLabelById, fetchProjectLabels } = useLabel(); + const { allowPermissions } = useUserPermissions(); + const { getProjectLabelIds, getLabelById, fetchProjectLabels, createLabel } = useLabel(); // derived values const projectLabelIds = getProjectLabelIds(projectId); + const canCreateLabel = + projectId && + allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug?.toString(), projectId); + const onDropdownOpen = () => { if (projectLabelIds === undefined && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug.toString(), projectId); }; + const handleCreateLabel = (data: Partial) => { + if (!workspaceSlug || !projectId) { + throw new Error("Workspace slug or project ID is missing"); + } + return createLabel(workspaceSlug.toString(), projectId, data); + }; + return ( ); }); diff --git a/apps/web/core/components/issues/workspace-draft/draft-issue-block.tsx b/apps/web/core/components/issues/workspace-draft/draft-issue-block.tsx index ef31ad8fcc..2a984fa5cc 100644 --- a/apps/web/core/components/issues/workspace-draft/draft-issue-block.tsx +++ b/apps/web/core/components/issues/workspace-draft/draft-issue-block.tsx @@ -4,8 +4,9 @@ import { omit } from "lodash"; import { observer } from "mobx-react"; import { Copy, Pencil, SquareStackIcon, Trash2 } from "lucide-react"; // plane utils +import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType, TWorkspaceDraftIssue } from "@plane/types"; -import { Row, TContextMenuItem, Tooltip } from "@plane/ui"; +import { Row, TContextMenuItem } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; @@ -152,7 +153,7 @@ export const DraftIssueBlock: FC = observer((props) => {
- +

{issue.name}

diff --git a/apps/web/core/components/labels/create-label-modal.tsx b/apps/web/core/components/labels/create-label-modal.tsx deleted file mode 100644 index 413429c8a1..0000000000 --- a/apps/web/core/components/labels/create-label-modal.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; - -import React, { useEffect } from "react"; -import { observer } from "mobx-react"; -import { TwitterPicker } from "react-color"; -import { Controller, useForm } from "react-hook-form"; -import { ChevronDown } from "lucide-react"; -import { Dialog, Popover, Transition } from "@headlessui/react"; -// plane imports -import { ETabIndices, LABEL_COLOR_OPTIONS, getRandomLabelColor } from "@plane/constants"; -// types -import type { IIssueLabel, IState } from "@plane/types"; -// ui -import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; -// helpers -import { getTabIndex } from "@plane/utils"; -// hooks - -import { usePlatformOS } from "@/hooks/use-platform-os"; - -// types -type Props = { - createLabel: (data: Partial) => Promise; - handleClose: () => void; - isOpen: boolean; - onSuccess?: (response: IIssueLabel) => void; -}; - -const defaultValues: Partial = { - name: "", - color: "rgb(var(--color-text-200))", -}; - -export const CreateLabelModal: React.FC = observer((props) => { - const { createLabel, handleClose, isOpen, onSuccess } = props; - // store hooks - const { isMobile } = usePlatformOS(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - watch, - control, - reset, - setValue, - setFocus, - } = useForm({ - defaultValues, - }); - - const { getIndex } = getTabIndex(ETabIndices.CREATE_LABEL, isMobile); - - /** - * For setting focus on name input - */ - useEffect(() => { - setFocus("name"); - }, [setFocus, isOpen]); - - useEffect(() => { - if (isOpen) setValue("color", getRandomLabelColor()); - }, [setValue, isOpen]); - - const onClose = () => { - handleClose(); - reset(defaultValues); - }; - - const onSubmit = async (formData: IIssueLabel) => { - await createLabel(formData) - .then((res) => { - onClose(); - if (onSuccess) onSuccess(res); - }) - .catch((error) => { - setToast({ - title: "Error!", - type: TOAST_TYPE.ERROR, - message: error?.detail ?? "Something went wrong. Please try again later.", - }); - reset(formData); - }); - }; - - return ( - - - -
- - -
-
- - - { - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - > -
- - Create Label - -
- - {({ open, close }) => ( - <> - - {watch("color") && watch("color") !== "" && ( - - )} - - - - - ( - { - onChange(value.hex); - close(); - }} - /> - )} - /> - - - - )} - -
- ( - - )} - /> -
-
-
-
- - -
- -
-
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/labels/index.ts b/apps/web/core/components/labels/index.ts index 9195f8649b..7fe388345f 100644 --- a/apps/web/core/components/labels/index.ts +++ b/apps/web/core/components/labels/index.ts @@ -1,4 +1,3 @@ -export * from "./create-label-modal"; export * from "./create-update-label-inline"; export * from "./delete-label-modal"; export * from "./project-setting-label-group"; diff --git a/apps/web/core/components/license/modal/card/base-paid-plan-card.tsx b/apps/web/core/components/license/modal/card/base-paid-plan-card.tsx index 2f4b53356e..8d2cd01c53 100644 --- a/apps/web/core/components/license/modal/card/base-paid-plan-card.tsx +++ b/apps/web/core/components/license/modal/card/base-paid-plan-card.tsx @@ -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 = observer((props) => { @@ -31,11 +30,10 @@ export const BasePaidPlanCard: FC = observer((props) => extraFeatures, renderPriceContent, renderActionButton, - isSelfHosted, } = props; // states const [selectedPlan, setSelectedPlan] = useState("month"); - const basePlan = getBaseSubscriptionName(planVariant, isSelfHosted); + const basePlan = getBaseSubscriptionName(planVariant); const upgradeCardVariantStyle = getUpgradeCardVariantStyle(planVariant); // Plane details const planeName = getSubscriptionName(planVariant); diff --git a/apps/web/core/components/license/modal/card/plan-upgrade.tsx b/apps/web/core/components/license/modal/card/plan-upgrade.tsx index 6b88b7b493..0c9abc011a 100644 --- a/apps/web/core/components/license/modal/card/plan-upgrade.tsx +++ b/apps/web/core/components/license/modal/card/plan-upgrade.tsx @@ -107,7 +107,6 @@ export const PlanUpgradeCard: FC = observer((props) => { isTrialAllowed={isTrialAllowed} /> )} - isSelfHosted={isSelfHosted} /> ); }); diff --git a/apps/web/core/components/license/modal/card/talk-to-sales.tsx b/apps/web/core/components/license/modal/card/talk-to-sales.tsx index 41541562a2..792ef501c3 100644 --- a/apps/web/core/components/license/modal/card/talk-to-sales.tsx +++ b/apps/web/core/components/license/modal/card/talk-to-sales.tsx @@ -107,7 +107,6 @@ export const TalkToSalesCard: FC = observer((props) => { extraFeatures={extraFeatures} renderPriceContent={renderPriceContent} renderActionButton={renderActionButton} - isSelfHosted={isSelfHosted} /> ); }); diff --git a/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx index fb57f9f412..7cdf13e2ac 100644 --- a/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx +++ b/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import Image from "next/image"; import { useTranslation } from "@plane/i18n"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@plane/propel/tabs"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IIssueFilterOptions, IIssueFilters, @@ -13,8 +14,7 @@ import { TModulePlotType, TStateGroups, } from "@plane/types"; -import { Avatar, StateGroupIcon } from "@plane/ui"; - +import { Avatar } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; // components import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; diff --git a/apps/web/core/components/modules/analytics-sidebar/root.tsx b/apps/web/core/components/modules/analytics-sidebar/root.tsx index a99bfb57fc..ab402b97ff 100644 --- a/apps/web/core/components/modules/analytics-sidebar/root.tsx +++ b/apps/web/core/components/modules/analytics-sidebar/root.tsx @@ -16,9 +16,10 @@ import { } from "@plane/constants"; // plane types import { useTranslation } from "@plane/i18n"; +import { LayersIcon, ModuleStatusIcon } from "@plane/propel/icons"; import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // plane ui -import { Loader, LayersIcon, CustomSelect, ModuleStatusIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui"; +import { Loader, CustomSelect, TOAST_TYPE, setToast, TextArea } from "@plane/ui"; // components // helpers import { getDate, renderFormattedPayloadDate } from "@plane/utils"; diff --git a/apps/web/core/components/modules/applied-filters/status.tsx b/apps/web/core/components/modules/applied-filters/status.tsx index 15a2e8b6ee..d5c10eea10 100644 --- a/apps/web/core/components/modules/applied-filters/status.tsx +++ b/apps/web/core/components/modules/applied-filters/status.tsx @@ -5,7 +5,7 @@ import { X } from "lucide-react"; // ui import { MODULE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ModuleStatusIcon } from "@plane/ui"; +import { ModuleStatusIcon } from "@plane/propel/icons"; // constants type Props = { diff --git a/apps/web/core/components/modules/dropdowns/filters/root.tsx b/apps/web/core/components/modules/dropdowns/filters/root.tsx index d2537868ac..df2f52dbf0 100644 --- a/apps/web/core/components/modules/dropdowns/filters/root.tsx +++ b/apps/web/core/components/modules/dropdowns/filters/root.tsx @@ -4,8 +4,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Search, X } from "lucide-react"; // plane imports +import { TModuleStatus } from "@plane/propel/icons"; import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; -import { TModuleStatus } from "@plane/ui"; // components import { FilterOption } from "@/components/issues/issue-layouts/filters"; import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "@/components/modules"; diff --git a/apps/web/core/components/modules/dropdowns/filters/status.tsx b/apps/web/core/components/modules/dropdowns/filters/status.tsx index f1aa3b14dd..09ee9e494b 100644 --- a/apps/web/core/components/modules/dropdowns/filters/status.tsx +++ b/apps/web/core/components/modules/dropdowns/filters/status.tsx @@ -4,9 +4,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { MODULE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { ModuleStatusIcon } from "@plane/propel/icons"; import { TModuleStatus } from "@plane/types"; // components -import { ModuleStatusIcon } from "@plane/ui"; import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; type Props = { diff --git a/apps/web/core/components/modules/gantt-chart/blocks.tsx b/apps/web/core/components/modules/gantt-chart/blocks.tsx index 937aa3f3bb..6fc10df6d2 100644 --- a/apps/web/core/components/modules/gantt-chart/blocks.tsx +++ b/apps/web/core/components/modules/gantt-chart/blocks.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { useParams } from "next/navigation"; // ui import { MODULE_STATUS } from "@plane/constants"; -import { Tooltip, ModuleStatusIcon } from "@plane/ui"; +import { ModuleStatusIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; // components import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants"; import { getBlockViewDetails } from "@/components/issues/issue-layouts/utils"; @@ -45,7 +46,7 @@ export const ModuleGanttBlock: React.FC = observer((props) => {
{message}
} - position="top-left" + position="top-start" >
= observer((props) => { } buttonClassName="!border-[0.5px] !border-custom-border-300 !shadow-none !rounded-md" input - optionsClassName="w-full" > {ORGANIZATION_SIZE.map((item) => ( diff --git a/apps/web/core/components/onboarding/header.tsx b/apps/web/core/components/onboarding/header.tsx index efa6b4c515..bdba2cc00d 100644 --- a/apps/web/core/components/onboarding/header.tsx +++ b/apps/web/core/components/onboarding/header.tsx @@ -4,8 +4,9 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { ChevronLeft } from "lucide-react"; // plane imports +import { PlaneLockup } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { EOnboardingSteps, TOnboardingStep } from "@plane/types"; -import { PlaneLockup, Tooltip } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useUser } from "@/hooks/store/user"; @@ -62,7 +63,7 @@ export const OnboardingHeader: FC = observer((props) => { return (
- +
Promise; + getRedirectionLink: (pageId?: string) => string; }; type Props = { @@ -54,7 +56,11 @@ type Props = { isNavigationPaneOpen: boolean; page: TPageInstance; webhookConnectionParams: TWebhookConnectionQueryParams; + projectId: string; workspaceSlug: string; + storeType: EPageStoreType; + + extendedEditorProps: TExtendedEditorExtensionsConfig; }; export const PageEditorBody: React.FC = observer((props) => { @@ -67,8 +73,11 @@ export const PageEditorBody: React.FC = observer((props) => { handlers, isNavigationPaneOpen, page, + storeType, webhookConnectionParams, + projectId, workspaceSlug, + extendedEditorProps, } = props; // store hooks const { data: currentUser } = useUser(); @@ -83,17 +92,15 @@ export const PageEditorBody: React.FC = observer((props) => { editor: { editorRef, updateAssetsList }, } = page; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; - // issue-embed - const { issueEmbedProps } = useIssueEmbed({ - fetchEmbedSuggestions: handlers.fetchEntity, - workspaceSlug, - }); // use editor mention const { fetchMentions } = useEditorMention({ searchEntity: handlers.fetchEntity, }); // editor flaggings - const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug); + const { document: documentEditorExtensions } = useEditorFlagging({ + workspaceSlug, + storeType, + }); // page filters const { fontSize, fontStyle, isFullWidth } = usePageFilters(); // translation @@ -115,7 +122,7 @@ export const PageEditorBody: React.FC = observer((props) => { isOpen={isOpen} onClose={onClose} workspaceId={workspaceId} - workspaceSlug={workspaceSlug?.toString() ?? ""} + workspaceSlug={workspaceSlug} /> ), [editorRef, workspaceId, workspaceSlug] @@ -202,7 +209,7 @@ export const PageEditorBody: React.FC = observer((props) => { )}
- + = observer((props) => { renderComponent: (props) => , getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }), }} - embedHandler={{ - issue: issueEmbedProps, - }} realtimeConfig={realtimeConfig} serverHandler={serverHandler} user={userConfig} @@ -240,6 +244,7 @@ export const PageEditorBody: React.FC = observer((props) => { menu: getAIMenu, }} onAssetChange={updateAssetsList} + extendedEditorProps={extendedEditorProps} />
diff --git a/apps/web/core/components/pages/editor/header/root.tsx b/apps/web/core/components/pages/editor/header/root.tsx index 17092d8c56..85e8546c0f 100644 --- a/apps/web/core/components/pages/editor/header/root.tsx +++ b/apps/web/core/components/pages/editor/header/root.tsx @@ -11,6 +11,7 @@ import { PageEditorHeaderLogoPicker } from "./logo-picker"; type Props = { page: TPageInstance; + projectId: string; }; export const PageEditorHeaderRoot: React.FC = observer((props) => { diff --git a/apps/web/core/components/pages/editor/page-root.tsx b/apps/web/core/components/pages/editor/page-root.tsx index d43c319c1a..3159691e13 100644 --- a/apps/web/core/components/pages/editor/page-root.tsx +++ b/apps/web/core/components/pages/editor/page-root.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; -import { useSearchParams } from "next/navigation"; // plane imports import type { EditorRefApi } from "@plane/editor"; import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; @@ -9,19 +8,20 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; // plane web import -import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; +import { PageModals } from "@/plane-web/components/pages"; +import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages"; +import { EPageStoreType } from "@/plane-web/hooks/store"; // store import type { TPageInstance } from "@/store/pages/base-page"; // local imports import { - PAGE_NAVIGATION_PANE_TAB_KEYS, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PageNavigationPaneRoot, } from "../navigation-pane"; import { PageVersionsOverlay } from "../version"; import { PagesVersionEditor } from "../version/editor"; -import { PageEditorBody, TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body"; +import { PageEditorBody, type TEditorBodyConfig, type TEditorBodyHandlers } from "./editor-body"; import { PageEditorToolbarRoot } from "./toolbar"; export type TPageRootHandlers = { @@ -29,7 +29,7 @@ export type TPageRootHandlers = { fetchAllVersions: (pageId: string) => Promise; fetchDescriptionBinary: () => Promise; fetchVersionDetails: (pageId: string, versionId: string) => Promise; - getRedirectionLink: (pageId: string) => string; + restoreVersion: (pageId: string, versionId: string) => Promise; updateDescription: (document: TDocumentPayload) => Promise; } & TEditorBodyHandlers; @@ -39,12 +39,14 @@ type TPageRootProps = { config: TPageRootConfig; handlers: TPageRootHandlers; page: TPageInstance; + storeType: EPageStoreType; webhookConnectionParams: TWebhookConnectionQueryParams; + projectId: string; workspaceSlug: string; }; export const PageRoot = observer((props: TPageRootProps) => { - const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props; + const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props; // states const [editorReady, setEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); @@ -52,8 +54,6 @@ export const PageRoot = observer((props: TPageRootProps) => { const editorRef = useRef(null); // router const router = useAppRouter(); - // search params - const searchParams = useSearchParams(); // derived values const { isContentEditable, @@ -66,7 +66,6 @@ export const PageRoot = observer((props: TPageRootProps) => { hasConnectionFailed, updatePageDescription: handlers.updateDescription, }); - // update query params const { updateQueryParams } = useQueryParams(); const handleEditorReady = useCallback( @@ -85,10 +84,31 @@ export const PageRoot = observer((props: TPageRootProps) => { }, 0); }, [isContentEditable, setEditorRef]); - const handleRestoreVersion = useCallback(async (descriptionHTML: string) => { - editorRef.current?.clearEditor(); - editorRef.current?.setEditorValue(descriptionHTML); - }, []); + // Get extensions and navigation logic from hook + const { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen } = + usePagesPaneExtensions({ + page, + editorRef, + }); + + // Get extended editor extensions configuration + const extendedEditorProps = useExtendedEditorProps({ + workspaceSlug, + page, + storeType, + fetchEntity: handlers.fetchEntity, + getRedirectionLink: handlers.getRedirectionLink, + extensionHandlers: editorExtensionHandlers, + projectId, + }); + + const handleRestoreVersion = useCallback( + async (descriptionHTML: string) => { + editorRef.current?.clearEditor(); + editorRef.current?.setEditorValue(descriptionHTML); + }, + [editorRef] + ); // reset editor ref on unmount useEffect( @@ -98,19 +118,6 @@ export const PageRoot = observer((props: TPageRootProps) => { [setEditorRef] ); - const navigationPaneQueryParam = searchParams.get( - PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM - ) as TPageNavigationPaneTab | null; - const isValidNavigationPaneTab = - !!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam); - - const handleOpenNavigationPane = useCallback(() => { - const updatedRoute = updateQueryParams({ - paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" }, - }); - router.push(updatedRoute); - }, [router, updateQueryParams]); - const handleCloseNavigationPane = useCallback(() => { const updatedRoute = updateQueryParams({ paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], @@ -127,10 +134,11 @@ export const PageRoot = observer((props: TPageRootProps) => { handleRestore={handleRestoreVersion} pageId={page.id ?? ""} restoreEnabled={isContentEditable} + storeType={storeType} /> { handleEditorReady={handleEditorReady} handleOpenNavigationPane={handleOpenNavigationPane} handlers={handlers} - isNavigationPaneOpen={isValidNavigationPaneTab} + isNavigationPaneOpen={isNavigationPaneOpen} page={page} + projectId={projectId} + storeType={storeType} webhookConnectionParams={webhookConnectionParams} workspaceSlug={workspaceSlug} + extendedEditorProps={extendedEditorProps} />
+
); }); diff --git a/apps/web/core/components/pages/editor/toolbar/root.tsx b/apps/web/core/components/pages/editor/toolbar/root.tsx index dbf1c4cee8..8bddef8053 100644 --- a/apps/web/core/components/pages/editor/toolbar/root.tsx +++ b/apps/web/core/components/pages/editor/toolbar/root.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { PanelRight } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // components import { PageToolbar } from "@/components/pages/editor/toolbar"; diff --git a/apps/web/core/components/pages/editor/toolbar/toolbar.tsx b/apps/web/core/components/pages/editor/toolbar/toolbar.tsx index 8de7d72a9a..34c548ca08 100644 --- a/apps/web/core/components/pages/editor/toolbar/toolbar.tsx +++ b/apps/web/core/components/pages/editor/toolbar/toolbar.tsx @@ -4,7 +4,8 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; -import { CustomMenu, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // constants import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor"; diff --git a/apps/web/core/components/pages/header/archived-badge.tsx b/apps/web/core/components/pages/header/archived-badge.tsx index afe24fd592..34e812eb9a 100644 --- a/apps/web/core/components/pages/header/archived-badge.tsx +++ b/apps/web/core/components/pages/header/archived-badge.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; // plane imports -import { ArchiveIcon } from "@plane/ui"; +import { ArchiveIcon } from "@plane/propel/icons"; import { renderFormattedDate } from "@plane/utils"; // store import type { TPageInstance } from "@/store/pages/base-page"; diff --git a/apps/web/core/components/pages/header/offline-badge.tsx b/apps/web/core/components/pages/header/offline-badge.tsx index c1c7191877..88cac49a37 100644 --- a/apps/web/core/components/pages/header/offline-badge.tsx +++ b/apps/web/core/components/pages/header/offline-badge.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; // plane imports -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // hooks import useOnlineStatus from "@/hooks/use-online-status"; // store diff --git a/apps/web/core/components/pages/list/block-item-action.tsx b/apps/web/core/components/pages/list/block-item-action.tsx index cc3fe0a3eb..bf9b095fef 100644 --- a/apps/web/core/components/pages/list/block-item-action.tsx +++ b/apps/web/core/components/pages/list/block-item-action.tsx @@ -5,7 +5,8 @@ import { observer } from "mobx-react"; import { Earth, Info, Lock, Minus } from "lucide-react"; // plane imports import { PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants"; -import { Avatar, FavoriteStar, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Avatar, FavoriteStar } from "@plane/ui"; import { renderFormattedDate, getFileURL } from "@plane/utils"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/pages/navigation-pane/index.ts b/apps/web/core/components/pages/navigation-pane/index.ts index fc8595eaaf..cbb4ee7b45 100644 --- a/apps/web/core/components/pages/navigation-pane/index.ts +++ b/apps/web/core/components/pages/navigation-pane/index.ts @@ -2,6 +2,7 @@ import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane"; export * from "./root"; +export * from "./types"; export const PAGE_NAVIGATION_PANE_WIDTH = 294; diff --git a/apps/web/core/components/pages/navigation-pane/root.tsx b/apps/web/core/components/pages/navigation-pane/root.tsx index bb37ec3205..04db887bd9 100644 --- a/apps/web/core/components/pages/navigation-pane/root.tsx +++ b/apps/web/core/components/pages/navigation-pane/root.tsx @@ -5,17 +5,20 @@ import { ArrowRightCircle } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@plane/propel/tabs"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // hooks import { useQueryParams } from "@/hooks/use-query-params"; // plane web components import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; // store +import { type EPageStoreType } from "@/plane-web/hooks/store"; import type { TPageInstance } from "@/store/pages/base-page"; // local imports import { TPageRootHandlers } from "../editor/page-root"; import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root"; import { PageNavigationPaneTabsList } from "./tabs-list"; +import { INavigationPaneExtension } from "./types/extensions"; + import { PAGE_NAVIGATION_PANE_TAB_KEYS, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, @@ -28,10 +31,14 @@ type Props = { isNavigationPaneOpen: boolean; page: TPageInstance; versionHistory: Pick; + // Generic extension system for additional navigation pane content + extensions?: INavigationPaneExtension[]; + storeType: EPageStoreType; }; export const PageNavigationPaneRoot: React.FC = observer((props) => { - const { handleClose, isNavigationPaneOpen, page, versionHistory } = props; + const { handleClose, isNavigationPaneOpen, page, versionHistory, extensions = [], storeType } = props; + // navigation const router = useRouter(); const searchParams = useSearchParams(); @@ -42,6 +49,22 @@ export const PageNavigationPaneRoot: React.FC = observer((props) => { PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM ) as TPageNavigationPaneTab | null; const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline"; + const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab); + + // Check if any extension is currently active based on query parameters + const ActiveExtension = extensions.find((extension) => { + const paneTabValue = searchParams.get(PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM); + const hasVersionParam = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM); + + // Extension is active ONLY when paneTab matches AND no regular navigation params are present + return paneTabValue === extension.triggerParam && !hasVersionParam; + }); + + // Don't show tabs when an extension is active + const showNavigationTabs = !ActiveExtension && isNavigationPaneOpen; + + // Use extension width if available, otherwise fall back to default + const paneWidth = ActiveExtension?.width ?? PAGE_NAVIGATION_PANE_WIDTH; // translation const { t } = useTranslation(); @@ -60,10 +83,10 @@ export const PageNavigationPaneRoot: React.FC = observer((props) => { return (
); }); diff --git a/apps/web/core/components/pages/navigation-pane/types/extensions.ts b/apps/web/core/components/pages/navigation-pane/types/extensions.ts new file mode 100644 index 0000000000..7460483b9c --- /dev/null +++ b/apps/web/core/components/pages/navigation-pane/types/extensions.ts @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; +import { EPageStoreType } from "@/plane-web/hooks/store"; +import { TPageInstance } from "@/store/pages/base-page"; + +export interface INavigationPaneExtensionProps { + page: TPageInstance; + extensionData?: T; + storeType: EPageStoreType; +} + +export interface INavigationPaneExtensionComponent { + (props: INavigationPaneExtensionProps): ReactNode; +} + +export interface INavigationPaneExtension { + id: string; + triggerParam: string; + component: INavigationPaneExtensionComponent; + data?: T; + width?: number; +} diff --git a/apps/web/core/components/pages/navigation-pane/types/index.ts b/apps/web/core/components/pages/navigation-pane/types/index.ts new file mode 100644 index 0000000000..fd10e53821 --- /dev/null +++ b/apps/web/core/components/pages/navigation-pane/types/index.ts @@ -0,0 +1,6 @@ +// Export generic extension system interfaces +export type { + INavigationPaneExtensionProps, + INavigationPaneExtensionComponent, + INavigationPaneExtension, +} from "./extensions"; diff --git a/apps/web/core/components/pages/pages-list-main-content.tsx b/apps/web/core/components/pages/pages-list-main-content.tsx index ca7c085b6c..60bd45f532 100644 --- a/apps/web/core/components/pages/pages-list-main-content.tsx +++ b/apps/web/core/components/pages/pages-list-main-content.tsx @@ -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 = observer((props) => { // plane hooks const { t } = useTranslation(); // store hooks - const { loader, isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters } = + const { currentProjectDetails } = useProject(); + const { isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters, loader } = 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); @@ -40,20 +55,56 @@ export const PagesListMainContent: React.FC = observer((props) => { basePath: "/empty-state/onboarding/pages", }); const publicPageResolvedPath = useResolvedAssetPath({ - basePath: "/empty-state/pages/public", + basePath: "/empty-state/wiki/public", }); const privatePageResolvedPath = useResolvedAssetPath({ - basePath: "/empty-state/pages/private", + basePath: "/empty-state/wiki/private", }); const archivedPageResolvedPath = useResolvedAssetPath({ - basePath: "/empty-state/pages/archived", + basePath: "/empty-state/wiki/archived", }); - const resolvedFiltersImage = useResolvedAssetPath({ basePath: "/empty-state/pages/all-filters", extension: "svg" }); + const resolvedFiltersImage = useResolvedAssetPath({ basePath: "/empty-state/wiki/all-filters", extension: "svg" }); const resolvedNameFilterImage = useResolvedAssetPath({ - basePath: "/empty-state/pages/name-filter", + basePath: "/empty-state/wiki/name-filter", extension: "svg", }); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + 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 ; // if no pages exist in the active page type if (!isAnyPageAvailable || pageIds?.length === 0) { @@ -64,12 +115,12 @@ export const PagesListMainContent: React.FC = 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 = 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 = 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, }} /> ); diff --git a/apps/web/core/components/pages/version/editor.tsx b/apps/web/core/components/pages/version/editor.tsx index f09f3724ea..d001072dbb 100644 --- a/apps/web/core/components/pages/version/editor.tsx +++ b/apps/web/core/components/pages/version/editor.tsx @@ -2,17 +2,21 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import type { TDisplayConfig } from "@plane/editor"; -import { TPageVersion } from "@plane/types"; +import type { JSONContent, TPageVersion } from "@plane/types"; +import { isJSONContentEmpty } from "@plane/utils"; import { Loader } from "@plane/ui"; // components import { DocumentEditor } from "@/components/editor/document/editor"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { usePageFilters } from "@/hooks/use-page-filters"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; export type TVersionEditorProps = { activeVersion: string | null; versionDetails: TPageVersion | undefined; + storeType: EPageStoreType; }; export const PagesVersionEditor: React.FC = observer((props) => { @@ -74,7 +78,10 @@ export const PagesVersionEditor: React.FC = observer((props
); - const description = versionDetails?.description_json; + const description = isJSONContentEmpty(versionDetails?.description_json as JSONContent) + ? versionDetails?.description_html + : versionDetails?.description_json; + if (!description) return null; return ( diff --git a/apps/web/core/components/pages/version/main-content.tsx b/apps/web/core/components/pages/version/main-content.tsx index 40c861b698..100fa9b0a9 100644 --- a/apps/web/core/components/pages/version/main-content.tsx +++ b/apps/web/core/components/pages/version/main-content.tsx @@ -6,6 +6,8 @@ import { EyeIcon, TriangleAlert } from "lucide-react"; import { TPageVersion } from "@plane/types"; import { Button, setToast, TOAST_TYPE } from "@plane/ui"; import { renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// helpers +import { EPageStoreType } from "@/plane-web/hooks/store"; // local imports import { TVersionEditorProps } from "./editor"; @@ -17,11 +19,20 @@ type Props = { handleRestore: (descriptionHTML: string) => Promise; pageId: string; restoreEnabled: boolean; + storeType: EPageStoreType; }; export const PageVersionsMainContent: React.FC = observer((props) => { - const { activeVersion, editorComponent, fetchVersionDetails, handleClose, handleRestore, pageId, restoreEnabled } = - props; + const { + activeVersion, + editorComponent, + fetchVersionDetails, + handleClose, + handleRestore, + pageId, + restoreEnabled, + storeType, + } = props; // states const [isRestoring, setIsRestoring] = useState(false); const [isRetrying, setIsRetrying] = useState(false); @@ -107,7 +118,7 @@ export const PageVersionsMainContent: React.FC = observer((props) => { )}
- +
)} diff --git a/apps/web/core/components/pages/version/root.tsx b/apps/web/core/components/pages/version/root.tsx index df2f39942d..4ad00f3cc7 100644 --- a/apps/web/core/components/pages/version/root.tsx +++ b/apps/web/core/components/pages/version/root.tsx @@ -6,6 +6,8 @@ import { TPageVersion } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useQueryParams } from "@/hooks/use-query-params"; +// plane web imports +import { EPageStoreType } from "@/plane-web/hooks/store"; // local imports import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane"; import { TVersionEditorProps } from "./editor"; @@ -17,10 +19,11 @@ type Props = { handleRestore: (descriptionHTML: string) => Promise; pageId: string; restoreEnabled: boolean; + storeType: EPageStoreType; }; export const PageVersionsOverlay: React.FC = observer((props) => { - const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled } = props; + const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled, storeType } = props; // navigation const router = useRouter(); const searchParams = useSearchParams(); @@ -57,6 +60,7 @@ export const PageVersionsOverlay: React.FC = observer((props) => { handleRestore={handleRestore} pageId={pageId} restoreEnabled={restoreEnabled} + storeType={storeType} />
); diff --git a/apps/web/core/components/profile/overview/priority-distribution.tsx b/apps/web/core/components/profile/overview/priority-distribution.tsx index b61018ef35..bf75c91eb0 100644 --- a/apps/web/core/components/profile/overview/priority-distribution.tsx +++ b/apps/web/core/components/profile/overview/priority-distribution.tsx @@ -44,7 +44,7 @@ export const ProfilePriorityDistribution: React.FC = ({ userProfile }) => key: "count", label: "Count", stackId: "bar-one", - fill: (payload) => priorityColors[payload.key as keyof typeof priorityColors], + fill: (payload: any) => priorityColors[payload.key as keyof typeof priorityColors], // TODO: fix types textClassName: "", showPercentage: false, showTopBorderRadius: () => true, diff --git a/apps/web/core/components/profile/overview/stats.tsx b/apps/web/core/components/profile/overview/stats.tsx index cf0f15d3e1..34da37fd32 100644 --- a/apps/web/core/components/profile/overview/stats.tsx +++ b/apps/web/core/components/profile/overview/stats.tsx @@ -6,8 +6,9 @@ import { useParams } from "next/navigation"; // ui import { UserCircle2 } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { CreateIcon, LayerStackIcon } from "@plane/propel/icons"; import { IUserProfileData } from "@plane/types"; -import { CreateIcon, LayerStackIcon, Loader, Card, ECardSpacing, ECardDirection } from "@plane/ui"; +import { Loader, Card, ECardSpacing, ECardDirection } from "@plane/ui"; // types type Props = { diff --git a/apps/web/core/components/profile/preferences/language-timezone.tsx b/apps/web/core/components/profile/preferences/language-timezone.tsx index 94d9c26f55..7ce1ce0673 100644 --- a/apps/web/core/components/profile/preferences/language-timezone.tsx +++ b/apps/web/core/components/profile/preferences/language-timezone.tsx @@ -126,7 +126,6 @@ export const LanguageTimezone = observer(() => { onChange={handleLanguageChange} buttonClassName={"border-none"} className="rounded-md border !border-custom-border-200" - optionsClassName="w-full" input > {SUPPORTED_LANGUAGES.map((item) => ( diff --git a/apps/web/core/components/profile/sidebar.tsx b/apps/web/core/components/profile/sidebar.tsx index 8bc536af5d..b01253546d 100644 --- a/apps/web/core/components/profile/sidebar.tsx +++ b/apps/web/core/components/profile/sidebar.tsx @@ -12,9 +12,10 @@ import { Disclosure, Transition } from "@headlessui/react"; import { useOutsideClickDetector } from "@plane/hooks"; // types import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { IUserProfileProjectSegregation } from "@plane/types"; // plane ui -import { Loader, Tooltip } from "@plane/ui"; +import { Loader } from "@plane/ui"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; diff --git a/apps/web/core/components/project-states/group-item.tsx b/apps/web/core/components/project-states/group-item.tsx index 82a68a4d66..4598631971 100644 --- a/apps/web/core/components/project-states/group-item.tsx +++ b/apps/web/core/components/project-states/group-item.tsx @@ -6,8 +6,8 @@ import { ChevronDown, Plus } from "lucide-react"; // plane imports import { EIconSize, STATE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState, TStateGroups, TStateOperationsCallbacks } from "@plane/types"; -import { StateGroupIcon } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { StateList, StateCreate } from "@/components/project-states"; diff --git a/apps/web/core/components/project-states/options/delete.tsx b/apps/web/core/components/project-states/options/delete.tsx index 9d9b9524b9..5bf104c18b 100644 --- a/apps/web/core/components/project-states/options/delete.tsx +++ b/apps/web/core/components/project-states/options/delete.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { Loader, X } from "lucide-react"; // plane imports import { STATE_TRACKER_EVENTS, STATE_TRACKER_ELEMENTS } from "@plane/constants"; +import { Tooltip } from "@plane/propel/tooltip"; import { IState, TStateOperationsCallbacks } from "@plane/types"; -import { AlertModalCore, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/project-states/state-item-title.tsx b/apps/web/core/components/project-states/state-item-title.tsx index 7bfe7fc6f3..80027cee7d 100644 --- a/apps/web/core/components/project-states/state-item-title.tsx +++ b/apps/web/core/components/project-states/state-item-title.tsx @@ -3,8 +3,8 @@ import { observer } from "mobx-react"; import { GripVertical, Pencil } from "lucide-react"; // plane imports import { EIconSize, STATE_TRACKER_ELEMENTS } from "@plane/constants"; +import { StateGroupIcon } from "@plane/propel/icons"; import { IState, TStateOperationsCallbacks } from "@plane/types"; -import { StateGroupIcon } from "@plane/ui"; // local imports import { useProjectState } from "@/hooks/store/use-project-state"; import { StateDelete, StateMarksAsDefault } from "./options"; diff --git a/apps/web/core/components/project/applied-filters/root.tsx b/apps/web/core/components/project/applied-filters/root.tsx index d5ff01b084..ec2607fb5c 100644 --- a/apps/web/core/components/project/applied-filters/root.tsx +++ b/apps/web/core/components/project/applied-filters/root.tsx @@ -3,8 +3,9 @@ import { X } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; -import { EHeaderVariant, Header, Tag, Tooltip } from "@plane/ui"; +import { EHeaderVariant, Header, Tag } from "@plane/ui"; import { replaceUnderscoreIfSnakeCase } from "@plane/utils"; // local imports import { AppliedAccessFilters } from "./access"; diff --git a/apps/web/core/components/project/card.tsx b/apps/web/core/components/project/card.tsx index 6bc3df6d6c..a87d0828b3 100644 --- a/apps/web/core/components/project/card.tsx +++ b/apps/web/core/components/project/card.tsx @@ -8,12 +8,12 @@ import { ArchiveRestoreIcon, Check, ExternalLink, LinkIcon, Lock, Settings, Tras // plane imports import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants"; import { useLocalStorage } from "@plane/hooks"; +import { Tooltip } from "@plane/propel/tooltip"; import type { IProject } from "@plane/types"; import { Avatar, AvatarGroup, Button, - Tooltip, TOAST_TYPE, setToast, setPromiseToast, diff --git a/apps/web/core/components/project/create/common-attributes.tsx b/apps/web/core/components/project/create/common-attributes.tsx index 2586f8be60..2f3fefe952 100644 --- a/apps/web/core/components/project/create/common-attributes.tsx +++ b/apps/web/core/components/project/create/common-attributes.tsx @@ -5,7 +5,8 @@ import { Info } from "lucide-react"; import { ETabIndices } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { Input, TextArea, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Input, TextArea } from "@plane/ui"; import { cn, projectIdentifierSanitizer, getTabIndex } from "@plane/utils"; // plane utils // helpers @@ -111,7 +112,7 @@ const ProjectCommonAttributes: React.FC = (props) => { isMobile={isMobile} tooltipContent={t("project_id_tooltip_content")} className="text-sm" - position="right-top" + position="right-start" > diff --git a/apps/web/core/components/project/create/header.tsx b/apps/web/core/components/project/create/header.tsx index 5abd4cf3da..b3a71bd025 100644 --- a/apps/web/core/components/project/create/header.tsx +++ b/apps/web/core/components/project/create/header.tsx @@ -4,14 +4,14 @@ import { X } from "lucide-react"; // plane imports import { ETabIndices } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker"; // plane types import { IProject } from "@plane/types"; // plane ui -import { CustomEmojiIconPicker, EmojiIconPickerTypes, Logo } from "@plane/ui"; -import { convertHexEmojiToDecimal, getFileURL, getTabIndex } from "@plane/utils"; +import { getFileURL, getTabIndex } from "@plane/utils"; // components +import { Logo } from "@/components/common/logo"; import { ImagePickerPopover } from "@/components/core/image-picker-popover"; -// helpers // plane web imports import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select"; @@ -66,7 +66,8 @@ const ProjectCreateHeader: React.FC = (props) => { name="logo_props" control={control} render={({ field: { value, onChange } }) => ( - setIsOpen(val)} className="flex items-center justify-center" @@ -81,8 +82,7 @@ const ProjectCreateHeader: React.FC = (props) => { if (val?.type === "emoji") logoValue = { - value: convertHexEmojiToDecimal(val.value.unified), - url: val.value.imageUrl, + value: val.value, }; else if (val?.type === "icon") logoValue = val.value; diff --git a/apps/web/core/components/project/form.tsx b/apps/web/core/components/project/form.tsx index f4eabe9283..1e35f65dd3 100644 --- a/apps/web/core/components/project/form.tsx +++ b/apps/web/core/components/project/form.tsx @@ -6,19 +6,11 @@ import { Info, Lock } from "lucide-react"; import { NETWORK_CHOICES, PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // plane imports +import { EmojiPicker } from "@plane/propel/emoji-icon-picker"; +import { Tooltip } from "@plane/propel/tooltip"; import { IProject, IWorkspace } from "@plane/types"; -import { - Button, - CustomSelect, - Input, - TextArea, - TOAST_TYPE, - setToast, - CustomEmojiIconPicker, - EmojiIconPickerTypes, - Tooltip, -} from "@plane/ui"; -import { renderFormattedDate, convertHexEmojiToDecimal, getFileURL } from "@plane/utils"; +import { Button, CustomSelect, Input, TextArea, TOAST_TYPE, setToast, EmojiIconPickerTypes } from "@plane/ui"; +import { renderFormattedDate, getFileURL } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; import { ImagePickerPopover } from "@/components/core/image-picker-popover"; @@ -203,20 +195,21 @@ export const ProjectDetailsForm: FC = (props) => { control={control} name="logo_props" render={({ field: { value, onChange } }) => ( - setIsOpen(val)} className="flex items-center justify-center" buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/10" label={} - onChange={(val) => { + // TODO: fix types + onChange={(val: any) => { let logoValue = {}; if (val?.type === "emoji") logoValue = { - value: convertHexEmojiToDecimal(val.value.unified), - url: val.value.imageUrl, + value: val.value, }; else if (val?.type === "icon") logoValue = val.value; @@ -352,7 +345,7 @@ export const ProjectDetailsForm: FC = (props) => { isMobile={isMobile} tooltipContent="Helps you identify work items in the project uniquely. Max 5 characters." className="text-sm" - position="right-top" + position="right-start" > diff --git a/apps/web/core/components/project/send-project-invitation-modal.tsx b/apps/web/core/components/project/send-project-invitation-modal.tsx index bd53340355..bb0b168198 100644 --- a/apps/web/core/components/project/send-project-invitation-modal.tsx +++ b/apps/web/core/components/project/send-project-invitation-modal.tsx @@ -293,7 +293,6 @@ export const SendProjectInvitationModal: React.FC = observer((props) => {
} input - optionsClassName="w-full" > {Object.entries( checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`)) diff --git a/apps/web/core/components/project/settings/features-list.tsx b/apps/web/core/components/project/settings/features-list.tsx index 7fe00b702d..b0f5bf2917 100644 --- a/apps/web/core/components/project/settings/features-list.tsx +++ b/apps/web/core/components/project/settings/features-list.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; // plane imports import { PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { IProject } from "@plane/types"; -import { ToggleSwitch, Tooltip, setPromiseToast } from "@plane/ui"; +import { ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { SettingsHeading } from "@/components/settings/heading"; // helpers diff --git a/apps/web/core/components/project/settings/member-columns.tsx b/apps/web/core/components/project/settings/member-columns.tsx index 37f8a0cbe4..1c31ae1788 100644 --- a/apps/web/core/components/project/settings/member-columns.tsx +++ b/apps/web/core/components/project/settings/member-columns.tsx @@ -168,7 +168,6 @@ export const AccountTypeColumn: React.FC = observer((props) => } buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`} className="rounded-md p-0 w-32" - optionsClassName="w-full" input > {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => ( diff --git a/apps/web/core/components/settings/heading.tsx b/apps/web/core/components/settings/heading.tsx index 620801d33e..b9b332c7a2 100644 --- a/apps/web/core/components/settings/heading.tsx +++ b/apps/web/core/components/settings/heading.tsx @@ -1,4 +1,4 @@ -import { Button } from "@plane/ui"; +import { Button, cn } from "@plane/ui"; type Props = { title: string | React.ReactNode; @@ -10,6 +10,7 @@ type Props = { label: string; onClick: () => void; }; + className?: string; }; export const SettingsHeading = ({ @@ -19,8 +20,14 @@ export const SettingsHeading = ({ appendToRight, customButton, showButton = true, + className, }: Props) => ( -
+
{typeof title === "string" ?

{title}

: title} {description &&
{description}
} diff --git a/apps/web/core/components/settings/sidebar/header.tsx b/apps/web/core/components/settings/sidebar/header.tsx index 0e8a22935a..b52d7e046a 100644 --- a/apps/web/core/components/settings/sidebar/header.tsx +++ b/apps/web/core/components/settings/sidebar/header.tsx @@ -6,7 +6,7 @@ import { WorkspaceLogo } from "@/components/workspace/logo"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; // plane web imports -import { SubscriptionPill } from "@/plane-web/components/common"; +import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; export const SettingsSidebarHeader = observer((props: { customHeader?: React.ReactNode }) => { const { customHeader } = props; diff --git a/apps/web/core/components/sidebar/sidebar-wrapper.tsx b/apps/web/core/components/sidebar/sidebar-wrapper.tsx new file mode 100644 index 0000000000..386f590cd7 --- /dev/null +++ b/apps/web/core/components/sidebar/sidebar-wrapper.tsx @@ -0,0 +1,72 @@ +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// components +import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; +import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown"; +import { HelpMenu } from "@/components/workspace/sidebar/help-menu"; +// hooks +import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useAppRail } from "@/hooks/use-app-rail"; +import useSize from "@/hooks/use-window-size"; +// plane web components +import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge"; + +type TSidebarWrapperProps = { + title: string; + children: React.ReactNode; + quickActions?: React.ReactNode; +}; + +export const SidebarWrapper: FC = observer((props) => { + const { children, title, quickActions } = props; + // store hooks + const { toggleSidebar, sidebarCollapsed } = useAppTheme(); + const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail(); + const windowSize = useSize(); + // refs + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false && window.innerWidth < 768) { + toggleSidebar(); + } + }); + + useEffect(() => { + if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowSize]); + + return ( +
+
+ {/* Workspace switcher and settings */} + {!shouldRenderAppRail && } + + {isAppRailEnabled && ( +
+ {title} +
+ +
+
+ )} + {/* Quick actions */} + {quickActions} +
+
+ {children} +
+ {/* Help Section */} +
+ +
+ {!shouldRenderAppRail && } + {!isAppRailEnabled && } +
+
+
+ ); +}); diff --git a/apps/web/core/components/stickies/action-bar.tsx b/apps/web/core/components/stickies/action-bar.tsx index 91dc50962d..640f615a3e 100644 --- a/apps/web/core/components/stickies/action-bar.tsx +++ b/apps/web/core/components/stickies/action-bar.tsx @@ -6,7 +6,8 @@ import { Plus, StickyNote as StickyIcon, X } from "lucide-react"; // plane hooks import { useOutsideClickDetector } from "@plane/hooks"; // plane ui -import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui"; +import { RecentStickyIcon, StickyNoteIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/stickies/modal/stickies.tsx b/apps/web/core/components/stickies/modal/stickies.tsx index 1cc16bfc6d..c70665e36f 100644 --- a/apps/web/core/components/stickies/modal/stickies.tsx +++ b/apps/web/core/components/stickies/modal/stickies.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Plus, X } from "lucide-react"; // plane ui -import { RecentStickyIcon } from "@plane/ui"; +import { RecentStickyIcon } from "@plane/propel/icons"; // hooks import { useSticky } from "@/hooks/use-stickies"; // components diff --git a/apps/web/core/components/ui/labels-list.tsx b/apps/web/core/components/ui/labels-list.tsx index ec81de0d6c..bf9626b2ff 100644 --- a/apps/web/core/components/ui/labels-list.tsx +++ b/apps/web/core/components/ui/labels-list.tsx @@ -2,8 +2,8 @@ import { FC } from "react"; // ui +import { Tooltip } from "@plane/propel/tooltip"; import { IIssueLabel } from "@plane/types"; -import { Tooltip } from "@plane/ui"; // types import { usePlatformOS } from "@/hooks/use-platform-os"; // hooks diff --git a/apps/web/core/components/views/form.tsx b/apps/web/core/components/views/form.tsx index 9e2be62932..278181079e 100644 --- a/apps/web/core/components/views/form.tsx +++ b/apps/web/core/components/views/form.tsx @@ -8,6 +8,7 @@ import { Layers } from "lucide-react"; import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; // i18n import { useTranslation } from "@plane/i18n"; +import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker"; // types import { EViewAccess, @@ -18,13 +19,8 @@ import { EIssueLayoutTypes, } from "@plane/types"; // ui -import { Button, EmojiIconPicker, EmojiIconPickerTypes, Input, TextArea } from "@plane/ui"; -import { - convertHexEmojiToDecimal, - getComputedDisplayFilters, - getComputedDisplayProperties, - getTabIndex, -} from "@plane/utils"; +import { Button, Input, TextArea } from "@plane/ui"; +import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; import { @@ -163,7 +159,8 @@ export const ProjectViewForm: React.FC = observer((props) => {
- setIsOpen(val)} className="flex items-center justify-center flex-shrink0" @@ -179,13 +176,13 @@ export const ProjectViewForm: React.FC = observer((props) => { } - onChange={(val) => { + // TODO: fix types + onChange={(val: any) => { let logoValue = {}; if (val?.type === "emoji") logoValue = { - value: convertHexEmojiToDecimal(val.value.unified), - url: val.value.imageUrl, + value: val.value, }; else if (val?.type === "icon") logoValue = val.value; diff --git a/apps/web/core/components/views/quick-actions.tsx b/apps/web/core/components/views/quick-actions.tsx index d46191a4f2..9c5fbefcba 100644 --- a/apps/web/core/components/views/quick-actions.tsx +++ b/apps/web/core/components/views/quick-actions.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; // types import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; import { IProjectView } from "@plane/types"; @@ -13,6 +12,7 @@ import { copyUrlToClipboard, cn } from "@plane/utils"; import { captureClick } from "@/helpers/event-tracker.helper"; // hooks import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useViewMenuItems } from "@/plane-web/components/views/helper"; import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish"; // local imports import { DeleteProjectViewModal } from "./delete-view-modal"; @@ -54,34 +54,18 @@ export const ViewQuickActions: React.FC = observer((props) => { }); const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank"); - const MENU_ITEMS: TContextMenuItem[] = [ - { - key: "edit", - action: () => setCreateUpdateViewModal(true), - title: "Edit", - icon: Pencil, - shouldRender: isOwner, - }, - { - key: "open-new-tab", - action: handleOpenInNewTab, - title: "Open in new tab", - icon: ExternalLink, - }, - { - key: "copy-link", - action: handleCopyText, - title: "Copy link", - icon: Link, - }, - { - key: "delete", - action: () => setDeleteViewModal(true), - title: "Delete", - icon: Trash2, - shouldRender: isOwner || isAdmin, - }, - ]; + const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({ + isOwner, + isAdmin, + setDeleteViewModal, + setCreateUpdateViewModal, + handleOpenInNewTab, + handleCopyText, + isLocked: view.is_locked, + workspaceSlug, + projectId, + viewId: view.id, + }); if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu); diff --git a/apps/web/core/components/views/update-view-component.tsx b/apps/web/core/components/views/update-view-component.tsx index 9b199e0f52..54b6ae47d3 100644 --- a/apps/web/core/components/views/update-view-component.tsx +++ b/apps/web/core/components/views/update-view-component.tsx @@ -1,6 +1,5 @@ import { SetStateAction, useEffect, useState } from "react"; import { Button } from "@plane/ui"; -import { LockedComponent } from "../icons/locked-component"; type Props = { isLocked: boolean; @@ -14,16 +13,8 @@ type Props = { }; export const UpdateViewComponent = (props: Props) => { - const { - isLocked, - areFiltersEqual, - isOwner, - isAuthorizedUser, - setIsModalOpen, - handleUpdateView, - lockedTooltipContent, - trackerElement, - } = props; + const { isLocked, areFiltersEqual, isOwner, isAuthorizedUser, setIsModalOpen, handleUpdateView, trackerElement } = + props; const [isUpdating, setIsUpdating] = useState(false); @@ -54,24 +45,19 @@ export const UpdateViewComponent = (props: Props) => { return (
- {isLocked ? ( - - ) : ( - !areFiltersEqual && - isAuthorizedUser && ( - <> - - {isOwner && <>{updateButton}} - - ) + {!isLocked && !areFiltersEqual && isAuthorizedUser && ( + <> + + {isOwner && <>{updateButton}} + )}
); diff --git a/apps/web/core/components/views/view-list-item-action.tsx b/apps/web/core/components/views/view-list-item-action.tsx index bf08cf0456..d423998bb9 100644 --- a/apps/web/core/components/views/view-list-item-action.tsx +++ b/apps/web/core/components/views/view-list-item-action.tsx @@ -5,8 +5,9 @@ import { Earth, Lock } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants"; import { useLocalStorage } from "@plane/hooks"; +import { Tooltip } from "@plane/propel/tooltip"; import { EViewAccess, IProjectView } from "@plane/types"; -import { Tooltip, FavoriteStar } from "@plane/ui"; +import { FavoriteStar } from "@plane/ui"; import { calculateTotalFilters, getPublishViewLink } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; diff --git a/apps/web/core/components/web-hooks/form/secret-key.tsx b/apps/web/core/components/web-hooks/form/secret-key.tsx index bea3fe7664..b1e1f33d79 100644 --- a/apps/web/core/components/web-hooks/form/secret-key.tsx +++ b/apps/web/core/components/web-hooks/form/secret-key.tsx @@ -7,9 +7,10 @@ import { useParams } from "next/navigation"; // icons import { Copy, Eye, EyeOff, RefreshCw } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { Tooltip } from "@plane/propel/tooltip"; import { IWebhook } from "@plane/types"; // ui -import { Button, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { csvDownload, copyTextToClipboard } from "@plane/utils"; // helpers // hooks diff --git a/apps/web/core/components/workspace-notifications/sidebar/filters/menu/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/filters/menu/root.tsx index 405eac7d92..dfbf82d3cb 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/filters/menu/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/filters/menu/root.tsx @@ -6,7 +6,8 @@ import { ListFilter } from "lucide-react"; // plane imports import { ENotificationFilterType, FILTER_TYPE_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { PopoverMenu, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { PopoverMenu } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports diff --git a/apps/web/core/components/workspace-notifications/sidebar/header/options/menu-option/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/header/options/menu-option/root.tsx index d7a298145f..fde618e70b 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/header/options/menu-option/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/header/options/menu-option/root.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { Check, CheckCircle, Clock } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { ArchiveIcon } from "@plane/propel/icons"; import { TNotificationFilter } from "@plane/types"; -import { ArchiveIcon, PopoverMenu } from "@plane/ui"; +import { PopoverMenu } from "@plane/ui"; // hooks import { useWorkspaceNotifications } from "@/hooks/store/notifications"; // local imports diff --git a/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx index 397252fe59..91a2652858 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/header/options/root.tsx @@ -9,7 +9,8 @@ import { NOTIFICATION_TRACKER_EVENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Spinner, Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { Spinner } from "@plane/ui"; // helpers import { captureSuccess } from "@/helpers/event-tracker.helper"; // hooks diff --git a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx index 0c6a8ab216..b5134495c5 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx @@ -6,7 +6,8 @@ import { ArchiveRestore } from "lucide-react"; // plane imports import { NOTIFICATION_TRACKER_ELEMENTS, NOTIFICATION_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ArchiveIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { ArchiveIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // hooks diff --git a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx index e1b5d79179..3aa2a33ff0 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx @@ -1,7 +1,7 @@ "use client"; import { FC, ReactNode } from "react"; -import { Tooltip } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; // helpers import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx index 3896431574..24d7d09c67 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx @@ -195,7 +195,6 @@ export const NotificationSnoozeModal: FC = (props) => )}
} - optionsClassName="w-full" input >
diff --git a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx index 32eced44d1..fc67e2609a 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx @@ -7,7 +7,8 @@ import { Popover, Transition } from "@headlessui/react"; // plane imports import { NOTIFICATION_SNOOZE_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Tooltip, setToast, TOAST_TYPE } from "@plane/ui"; +import { Tooltip } from "@plane/propel/tooltip"; +import { setToast, TOAST_TYPE } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useWorkspaceNotifications } from "@/hooks/store/notifications"; diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index b1e2fac5c6..a36d8cc88f 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -228,7 +228,6 @@ export const CreateWorkspaceForm: FC = observer((props) => { } buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" input - optionsClassName="w-full" > {ORGANIZATION_SIZE.map((item) => ( diff --git a/apps/web/core/components/workspace/invite-modal/fields.tsx b/apps/web/core/components/workspace/invite-modal/fields.tsx index e74e8104cf..0d0337e7bf 100644 --- a/apps/web/core/components/workspace/invite-modal/fields.tsx +++ b/apps/web/core/components/workspace/invite-modal/fields.tsx @@ -83,7 +83,6 @@ export const InvitationFields = observer((props: TInvitationFieldsProps) => { value={value} label={{ROLE[value]}} onChange={onChange} - optionsClassName="w-full" className="flex-grow w-24" input > diff --git a/apps/web/core/components/workspace/settings/member-columns.tsx b/apps/web/core/components/workspace/settings/member-columns.tsx index 60f36bff5c..56275f2497 100644 --- a/apps/web/core/components/workspace/settings/member-columns.tsx +++ b/apps/web/core/components/workspace/settings/member-columns.tsx @@ -146,7 +146,6 @@ export const AccountTypeColumn: React.FC = observer((props) => } buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`} className="rounded-md p-0 w-32" - optionsClassName="w-full" input > {Object.keys(ROLE).map((item) => ( diff --git a/apps/web/core/components/workspace/settings/workspace-details.tsx b/apps/web/core/components/workspace/settings/workspace-details.tsx index a54dfca87a..8073601b0b 100644 --- a/apps/web/core/components/workspace/settings/workspace-details.tsx +++ b/apps/web/core/components/workspace/settings/workspace-details.tsx @@ -241,7 +241,6 @@ export const WorkspaceDetails: FC = observer(() => { ORGANIZATION_SIZE.find((c) => c === value) ?? t("workspace_settings.settings.general.errors.company_size.select_a_range") } - optionsClassName="w-full" buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" input disabled={!isAdmin} diff --git a/apps/web/core/components/workspace/sidebar/dropdown-item.tsx b/apps/web/core/components/workspace/sidebar/dropdown-item.tsx index f15c0b3e49..f9e0ea28f9 100644 --- a/apps/web/core/components/workspace/sidebar/dropdown-item.tsx +++ b/apps/web/core/components/workspace/sidebar/dropdown-item.tsx @@ -3,15 +3,14 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { Check, Settings, UserPlus } from "lucide-react"; -// plane imports import { Menu } from "@headlessui/react"; +// plane imports import { EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { IWorkspace } from "@plane/types"; import { cn, getFileURL, getUserRole } from "@plane/utils"; -// helpers // plane web imports -import { SubscriptionPill } from "@/plane-web/components/common/subscription"; +import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; type TProps = { workspace: IWorkspace; diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx index bc9f1e9d93..f9f5c881ff 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx @@ -20,8 +20,10 @@ import { Disclosure, Transition } from "@headlessui/react"; // plane imports import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; +import { FavoriteFolderIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import { IFavorite, InstructionType } from "@plane/types"; -import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } from "@plane/ui"; +import { CustomMenu, DropIndicator, DragHandle } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks @@ -179,7 +181,7 @@ export const FavoriteFolder: React.FC = (props) => { tooltipContent={ favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange" } - position="top-right" + position="top-end" disabled={isDragging} > +
+
*/} +
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+ + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx new file mode 100644 index 0000000000..425bc7572f --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx @@ -0,0 +1,204 @@ +import { + shift, + flip, + useDismiss, + useFloating, + useInteractions, + autoUpdate, + useClick, + useRole, + FloatingOverlay, + FloatingPortal, +} from "@floating-ui/react"; +import type { Editor } from "@tiptap/core"; +import { Ellipsis } from "lucide-react"; +import { useCallback, useState } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectColumn, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedColumns } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getColDragMarker, + getDropMarker, + hideDragMarker, + hideDropMarker, + updateColDragMarker, + updateColDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { ColumnOptionsDropdown } from "./dropdown"; +import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils"; + +export type ColumnDragHandleProps = { + col: number; + editor: Editor; +}; + +export const ColumnDragHandle: React.FC = (props) => { + const { col, editor } = props; + // states + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // floating ui + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + open: isDropdownOpen, + onOpenChange: setIsDropdownOpen, + whileElementsMounted: autoUpdate, + }); + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectColumn(table, col, editor.state.tr)); + + // drag column + const tableWidthPx = getTableWidthPx(table, editor); + const columns = getTableColumnNodesInfo(table, editor); + + let dropIndex = col; + const startLeft = columns[col].left ?? 0; + const startX = e.clientX; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null; + + const handleFinish = () => { + if (!dropMarker || !dragMarker) return; + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + if (col !== dropIndex) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedColumns(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + }; + + let pseudoColumn: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent) => { + if (!dropMarker || !dragMarker) return; + const currentLeft = startLeft + moveEvent.clientX - startX; + dropIndex = calculateColumnDropIndex(col, columns, currentLeft); + + if (!pseudoColumn) { + pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table); + const tableHeightPx = getTableHeightPx(table, editor); + if (pseudoColumn) { + pseudoColumn.style.height = `${tableHeightPx}px`; + } + } + + const dragMarkerWidthPx = columns[col].width; + const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx)); + const dropMarkerLeftPx = + dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width; + + updateColDropMarker({ + element: dropMarker, + left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1, + width: DROP_MARKER_THICKNESS, + }); + updateColDragMarker({ + element: dragMarker, + left: dragMarkerLeftPx, + width: dragMarkerWidthPx, + pseudoColumn, + }); + }; + + try { + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in ColumnDragHandle:", error); + handleFinish(); + } + }, + [col, editor] + ); + + return ( + <> +
+ +
+ {isDropdownOpen && ( + + {/* Backdrop */} + + +
+ setIsDropdownOpen(false)} /> +
+
+ )} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx new file mode 100644 index 0000000000..562f918cd5 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/dropdown.tsx @@ -0,0 +1,100 @@ +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react"; +// extensions +import { findTable, getSelectedColumns } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { duplicateColumns } from "../actions"; +import { TableDragHandleDropdownColorSelector } from "../color-selector"; + +const DROPDOWN_ITEMS: { + key: string; + label: string; + icon: LucideIcon; + action: (editor: Editor) => void; +}[] = [ + { + key: "insert-left", + label: "Insert left", + icon: ArrowLeft, + action: (editor) => editor.chain().focus().addColumnBefore().run(), + }, + { + key: "insert-right", + label: "Insert right", + icon: ArrowRight, + action: (editor) => editor.chain().focus().addColumnAfter().run(), + }, + { + key: "duplicate", + label: "Duplicate", + icon: Copy, + action: (editor) => { + const table = findTable(editor.state.selection); + if (!table) return; + + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedColumns = getSelectedColumns(editor.state.selection, tableMap); + tr = duplicateColumns(table, selectedColumns, tr); + editor.view.dispatch(tr); + }, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: X, + action: (editor) => editor.chain().focus().clearSelectedCells().run(), + }, + { + key: "delete", + label: "Delete", + icon: Trash2, + action: (editor) => editor.chain().focus().deleteColumn().run(), + }, +]; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const ColumnOptionsDropdown: React.FC = (props) => { + const { editor, onClose } = props; + + return ( + <> + +
+ + {DROPDOWN_ITEMS.map((item) => ( + + ))} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts new file mode 100644 index 0000000000..d25591e475 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/plugin.ts @@ -0,0 +1,92 @@ +import type { Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ReactRenderer } from "@tiptap/react"; +// extensions +import { + findTable, + getTableCellWidgetDecorationPos, + haveTableRelatedChanges, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { ColumnDragHandle, ColumnDragHandleProps } from "./drag-handle"; + +type TableColumnDragHandlePluginState = { + decorations?: DecorationSet; + // track table structure to detect changes + tableWidth?: number; + tableNodePos?: number; +}; + +const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin"); + +export const TableColumnDragHandlePlugin = (editor: Editor): Plugin => + new Plugin({ + key: TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY, + state: { + init: () => ({}), + apply(tr, prev, oldState, newState) { + const table = findTable(newState.selection); + if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) { + return table !== undefined ? prev : {}; + } + + const tableMap = TableMap.get(table.node); + + // Check if table structure changed (width or position) + const tableStructureChanged = prev.tableWidth !== tableMap.width || prev.tableNodePos !== table.pos; + + let isStale = tableStructureChanged; + + // Only do position-based stale check if structure hasn't changed + if (!isStale) { + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + for (let col = 0; col < tableMap.width; col++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, col); + if (mapped?.find(pos, pos + 1)?.length !== 1) { + isStale = true; + break; + } + } + } + + if (!isStale) { + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + return { + decorations: mapped, + tableWidth: tableMap.width, + tableNodePos: table.pos, + }; + } + + // recreate all decorations + const decorations: Decoration[] = []; + + for (let col = 0; col < tableMap.width; col++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, col); + + const dragHandleComponent = new ReactRenderer(ColumnDragHandle, { + props: { + col, + editor, + } satisfies ColumnDragHandleProps, + editor, + }); + + decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + } + + return { + decorations: DecorationSet.create(newState.doc, decorations), + tableWidth: tableMap.width, + tableNodePos: table.pos, + }; + }, + }, + props: { + decorations(state) { + return TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations; + }, + }, + }); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts new file mode 100644 index 0000000000..f88f90fdcd --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/column/utils.ts @@ -0,0 +1,150 @@ +import type { Editor } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +// extensions +import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils"; + +type TableColumn = { + left: number; + width: number; +}; + +/** + * @description Calculate the index where the dragged column should be dropped. + * @param {number} col - The column index. + * @param {TableColumn[]} columns - The columns. + * @param {number} left - The left position of the dragged column. + * @returns {number} The index where the dragged column should be dropped. + */ +export const calculateColumnDropIndex = (col: number, columns: TableColumn[], left: number): number => { + const currentColumnLeft = columns[col].left; + const currentColumnRight = currentColumnLeft + columns[col].width; + + const draggedColumnLeft = left; + const draggedColumnRight = draggedColumnLeft + columns[col].width; + + const isDraggingToLeft = draggedColumnLeft < currentColumnLeft; + const isDraggingToRight = draggedColumnRight > currentColumnRight; + + const isFirstColumn = col === 0; + const isLastColumn = col === columns.length - 1; + + if ((isFirstColumn && isDraggingToLeft) || (isLastColumn && isDraggingToRight)) { + return col; + } + + const firstColumn = columns[0]; + if (isDraggingToLeft && draggedColumnLeft <= firstColumn.left) { + return 0; + } + + const lastColumn = columns[columns.length - 1]; + if (isDraggingToRight && draggedColumnRight >= lastColumn.left + lastColumn.width) { + return columns.length - 1; + } + + let dropColumnIndex = col; + if (isDraggingToRight) { + const findHoveredColumn = columns.find((p, index) => { + if (index === col) return false; + const currentColumnCenter = p.left + p.width / 2; + const currentColumnEdge = p.left + p.width; + const nextColumn = columns[index + 1] as TableColumn | undefined; + const nextColumnCenter = nextColumn ? nextColumn.width / 2 : 0; + + return draggedColumnRight >= currentColumnCenter && draggedColumnRight < currentColumnEdge + nextColumnCenter; + }); + if (findHoveredColumn) { + dropColumnIndex = columns.indexOf(findHoveredColumn); + } + } + + if (isDraggingToLeft) { + const findHoveredColumn = columns.find((p, index) => { + if (index === col) return false; + const currentColumnCenter = p.left + p.width / 2; + const prevColumn = columns[index - 1] as TableColumn | undefined; + const prevColumnLeft = prevColumn ? prevColumn.left : 0; + const prevColumnCenter = prevColumn ? prevColumn.width / 2 : 0; + + return draggedColumnLeft <= currentColumnCenter && draggedColumnLeft > prevColumnLeft + prevColumnCenter; + }); + if (findHoveredColumn) { + dropColumnIndex = columns.indexOf(findHoveredColumn); + } + } + + return dropColumnIndex; +}; + +/** + * @description Get the node information of the columns in the table- their offset left and width. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {TableColumn[]} The information of the columns in the table. + */ +export const getTableColumnNodesInfo = (table: TableNodeLocation, editor: Editor): TableColumn[] => { + const result: TableColumn[] = []; + let leftPx = 0; + + const tableMap = TableMap.get(table.node); + if (!tableMap || tableMap.height === 0 || tableMap.width === 0) { + return result; + } + + for (let col = 0; col < tableMap.width; col++) { + const cellPos = tableMap.map[col]; + if (cellPos === undefined) continue; + + const dom = editor.view.domAtPos(table.start + cellPos + 1); + if (dom.node instanceof HTMLElement) { + if (col === 0) { + leftPx = dom.node.offsetLeft; + } + result.push({ + left: dom.node.offsetLeft - leftPx, + width: dom.node.offsetWidth, + }); + } + } + return result; +}; + +/** + * @description Construct a pseudo column from the selected cells for drag preview. + * @param {Editor} editor - The editor instance. + * @param {Selection} selection - The selection. + * @param {TableNodeLocation} table - The table node location. + * @returns {HTMLElement | undefined} The pseudo column. + */ +export const constructColumnDragPreview = ( + editor: Editor, + selection: Selection, + table: TableNodeLocation +): HTMLElement | undefined => { + if (!isCellSelection(selection)) return; + + const tableMap = TableMap.get(table.node); + const selectedColRect = getSelectedRect(selection, tableMap); + const activeColCells = tableMap.cellsInRect(selectedColRect); + + const { tableElement, tableBodyElement } = constructDragPreviewTable(); + + activeColCells.forEach((cellPos) => { + const resolvedCellPos = table.start + cellPos + 1; + const cellElement = editor.view.domAtPos(resolvedCellPos).node; + if (cellElement instanceof HTMLElement) { + const { clonedCellElement } = cloneTableCell(cellElement); + clonedCellElement.style.height = cellElement.getBoundingClientRect().height + "px"; + const tableRowElement = document.createElement("tr"); + tableRowElement.appendChild(clonedCellElement); + tableBodyElement.appendChild(tableRowElement); + } + }); + + updateCellContentVisibility(editor, true); + + return tableElement; +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts new file mode 100644 index 0000000000..db2095e112 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/marker-utils.ts @@ -0,0 +1,106 @@ +export const DROP_MARKER_CLASS = "table-drop-marker"; +export const COL_DRAG_MARKER_CLASS = "table-col-drag-marker"; +export const ROW_DRAG_MARKER_CLASS = "table-row-drag-marker"; + +export const DROP_MARKER_THICKNESS = 2; + +export const getDropMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${DROP_MARKER_CLASS}`); + +export const hideDropMarker = (element: HTMLElement): void => { + if (!element.classList.contains("hidden")) { + element.classList.add("hidden"); + } +}; + +export const updateColDropMarker = ({ + element, + left, + width, +}: { + element: HTMLElement; + left: number; + width: number; +}) => { + element.style.height = "100%"; + element.style.width = `${width}px`; + element.style.top = "0"; + element.style.left = `${left}px`; + element.classList.remove("hidden"); +}; + +export const updateRowDropMarker = ({ + element, + top, + height, +}: { + element: HTMLElement; + top: number; + height: number; +}) => { + element.style.width = "100%"; + element.style.height = `${height}px`; + element.style.left = "0"; + element.style.top = `${top}px`; + element.classList.remove("hidden"); +}; + +export const getColDragMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${COL_DRAG_MARKER_CLASS}`); + +export const getRowDragMarker = (tableElement: HTMLElement): HTMLElement | null => + tableElement.querySelector(`.${ROW_DRAG_MARKER_CLASS}`); + +export const hideDragMarker = (element: HTMLElement): void => { + if (!element.classList.contains("hidden")) { + element.classList.add("hidden"); + } +}; + +export const updateColDragMarker = ({ + element, + left, + width, + pseudoColumn, +}: { + element: HTMLElement; + left: number; + width: number; + pseudoColumn: HTMLElement | undefined; +}) => { + element.style.left = `${left}px`; + element.style.width = `${width}px`; + element.classList.remove("hidden"); + if (pseudoColumn) { + /// clear existing content + while (element.firstChild) { + element.removeChild(element.firstChild); + } + // clone and append the pseudo column + element.appendChild(pseudoColumn.cloneNode(true)); + } +}; + +export const updateRowDragMarker = ({ + element, + top, + height, + pseudoRow, +}: { + element: HTMLElement; + top: number; + height: number; + pseudoRow: HTMLElement | undefined; +}) => { + element.style.top = `${top}px`; + element.style.height = `${height}px`; + element.classList.remove("hidden"); + if (pseudoRow) { + /// clear existing content + while (element.firstChild) { + element.removeChild(element.firstChild); + } + // clone and append the pseudo row + element.appendChild(pseudoRow.cloneNode(true)); + } +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx new file mode 100644 index 0000000000..7c0f1449a1 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx @@ -0,0 +1,203 @@ +import { + autoUpdate, + flip, + FloatingOverlay, + FloatingPortal, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +import type { Editor } from "@tiptap/core"; +import { Ellipsis } from "lucide-react"; +import { useCallback, useState } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// extensions +import { + findTable, + getTableHeightPx, + getTableWidthPx, + isCellSelection, + selectRow, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { moveSelectedRows } from "../actions"; +import { + DROP_MARKER_THICKNESS, + getDropMarker, + getRowDragMarker, + hideDragMarker, + hideDropMarker, + updateRowDragMarker, + updateRowDropMarker, +} from "../marker-utils"; +import { updateCellContentVisibility } from "../utils"; +import { RowOptionsDropdown } from "./dropdown"; +import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils"; + +export type RowDragHandleProps = { + editor: Editor; + row: number; +}; + +export const RowDragHandle: React.FC = (props) => { + const { editor, row } = props; + // states + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // floating ui + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + middleware: [ + flip({ + fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"], + }), + shift({ + padding: 8, + }), + ], + open: isDropdownOpen, + onOpenChange: setIsDropdownOpen, + whileElementsMounted: autoUpdate, + }); + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const table = findTable(editor.state.selection); + if (!table) return; + + editor.view.dispatch(selectRow(table, row, editor.state.tr)); + + // drag row + const tableHeightPx = getTableHeightPx(table, editor); + const rows = getTableRowNodesInfo(table, editor); + + let dropIndex = row; + const startTop = rows[row].top ?? 0; + const startY = e.clientY; + const tableElement = editor.view.nodeDOM(table.pos); + + const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null; + const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null; + + const handleFinish = (): void => { + if (!dropMarker || !dragMarker) return; + hideDropMarker(dropMarker); + hideDragMarker(dragMarker); + + if (isCellSelection(editor.state.selection)) { + updateCellContentVisibility(editor, false); + } + + if (row !== dropIndex) { + let tr = editor.state.tr; + const selection = editor.state.selection; + if (isCellSelection(selection)) { + const table = findTable(selection); + if (table) { + tr = moveSelectedRows(editor, table, selection, dropIndex, tr); + } + } + editor.view.dispatch(tr); + } + window.removeEventListener("mouseup", handleFinish); + window.removeEventListener("mousemove", handleMove); + }; + + let pseudoRow: HTMLElement | undefined; + + const handleMove = (moveEvent: MouseEvent): void => { + if (!dropMarker || !dragMarker) return; + const cursorTop = startTop + moveEvent.clientY - startY; + dropIndex = calculateRowDropIndex(row, rows, cursorTop); + + if (!pseudoRow) { + pseudoRow = constructRowDragPreview(editor, editor.state.selection, table); + const tableWidthPx = getTableWidthPx(table, editor); + if (pseudoRow) { + pseudoRow.style.width = `${tableWidthPx}px`; + } + } + + const dragMarkerHeightPx = rows[row].height; + const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx)); + const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height; + + updateRowDropMarker({ + element: dropMarker, + top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2, + height: DROP_MARKER_THICKNESS, + }); + updateRowDragMarker({ + element: dragMarker, + top: dragMarkerTopPx, + height: dragMarkerHeightPx, + pseudoRow, + }); + }; + + try { + window.addEventListener("mouseup", handleFinish); + window.addEventListener("mousemove", handleMove); + } catch (error) { + console.error("Error in RowDragHandle:", error); + handleFinish(); + } + }, + [editor, row] + ); + + return ( + <> +
+ +
+ {isDropdownOpen && ( + + {/* Backdrop */} + + +
+ setIsDropdownOpen(false)} /> +
+
+ )} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx new file mode 100644 index 0000000000..09be7415b7 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/dropdown.tsx @@ -0,0 +1,100 @@ +import type { Editor } from "@tiptap/core"; +import { TableMap } from "@tiptap/pm/tables"; +import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react"; +// extensions +import { findTable, getSelectedRows } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { duplicateRows } from "../actions"; +import { TableDragHandleDropdownColorSelector } from "../color-selector"; + +const DROPDOWN_ITEMS: { + key: string; + label: string; + icon: LucideIcon; + action: (editor: Editor) => void; +}[] = [ + { + key: "insert-above", + label: "Insert above", + icon: ArrowUp, + action: (editor) => editor.chain().focus().addRowBefore().run(), + }, + { + key: "insert-below", + label: "Insert below", + icon: ArrowDown, + action: (editor) => editor.chain().focus().addRowAfter().run(), + }, + { + key: "duplicate", + label: "Duplicate", + icon: Copy, + action: (editor) => { + const table = findTable(editor.state.selection); + if (!table) return; + + const tableMap = TableMap.get(table.node); + let tr = editor.state.tr; + const selectedRows = getSelectedRows(editor.state.selection, tableMap); + tr = duplicateRows(table, selectedRows, tr); + editor.view.dispatch(tr); + }, + }, + { + key: "clear-contents", + label: "Clear contents", + icon: X, + action: (editor) => editor.chain().focus().clearSelectedCells().run(), + }, + { + key: "delete", + label: "Delete", + icon: Trash2, + action: (editor) => editor.chain().focus().deleteRow().run(), + }, +]; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export const RowOptionsDropdown: React.FC = (props) => { + const { editor, onClose } = props; + + return ( + <> + +
+ + {DROPDOWN_ITEMS.map((item) => ( + + ))} + + ); +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts new file mode 100644 index 0000000000..92cf0eea9d --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/plugin.ts @@ -0,0 +1,92 @@ +import { type Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ReactRenderer } from "@tiptap/react"; +// extensions +import { + findTable, + getTableCellWidgetDecorationPos, + haveTableRelatedChanges, +} from "@/extensions/table/table/utilities/helpers"; +// local imports +import { RowDragHandle, RowDragHandleProps } from "./drag-handle"; + +type TableRowDragHandlePluginState = { + decorations?: DecorationSet; + // track table structure to detect changes + tableHeight?: number; + tableNodePos?: number; +}; + +const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin"); + +export const TableRowDragHandlePlugin = (editor: Editor): Plugin => + new Plugin({ + key: TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY, + state: { + init: () => ({}), + apply(tr, prev, oldState, newState) { + const table = findTable(newState.selection); + if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) { + return table !== undefined ? prev : {}; + } + + const tableMap = TableMap.get(table.node); + + // Check if table structure changed (height or position) + const tableStructureChanged = prev.tableHeight !== tableMap.height || prev.tableNodePos !== table.pos; + + let isStale = tableStructureChanged; + + // Only do position-based stale check if structure hasn't changed + if (!isStale) { + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + for (let row = 0; row < tableMap.height; row++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); + if (mapped?.find(pos, pos + 1)?.length !== 1) { + isStale = true; + break; + } + } + } + + if (!isStale) { + const mapped = prev.decorations?.map(tr.mapping, tr.doc); + return { + decorations: mapped, + tableHeight: tableMap.height, + tableNodePos: table.pos, + }; + } + + // recreate all decorations + const decorations: Decoration[] = []; + + for (let row = 0; row < tableMap.height; row++) { + const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width); + + const dragHandleComponent = new ReactRenderer(RowDragHandle, { + props: { + editor, + row, + } satisfies RowDragHandleProps, + editor, + }); + + decorations.push(Decoration.widget(pos, () => dragHandleComponent.element)); + } + + return { + decorations: DecorationSet.create(newState.doc, decorations), + tableHeight: tableMap.height, + tableNodePos: table.pos, + }; + }, + }, + props: { + decorations(state) { + return TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations; + }, + }, + }); diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts new file mode 100644 index 0000000000..d43d9ae73e --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/row/utils.ts @@ -0,0 +1,149 @@ +import type { Editor } from "@tiptap/core"; +import type { Selection } from "@tiptap/pm/state"; +import { TableMap } from "@tiptap/pm/tables"; +// extensions +import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers"; +// local imports +import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils"; + +type TableRow = { + top: number; + height: number; +}; + +/** + * @description Calculate the index where the dragged row should be dropped. + * @param {number} row - The row index. + * @param {TableRow[]} rows - The rows. + * @param {number} top - The top position of the dragged row. + * @returns {number} The index where the dragged row should be dropped. + */ +export const calculateRowDropIndex = (row: number, rows: TableRow[], top: number): number => { + const currentRowTop = rows[row].top; + const currentRowBottom = currentRowTop + rows[row].height; + + const draggedRowTop = top; + const draggedRowBottom = draggedRowTop + rows[row].height; + + const isDraggingUp = draggedRowTop < currentRowTop; + const isDraggingDown = draggedRowBottom > currentRowBottom; + + const isFirstRow = row === 0; + const isLastRow = row === rows.length - 1; + + if ((isFirstRow && isDraggingUp) || (isLastRow && isDraggingDown)) { + return row; + } + + const firstRow = rows[0]; + if (isDraggingUp && draggedRowTop <= firstRow.top) { + return 0; + } + + const lastRow = rows[rows.length - 1]; + if (isDraggingDown && draggedRowBottom >= lastRow.top + lastRow.height) { + return rows.length - 1; + } + + let dropRowIndex = row; + if (isDraggingDown) { + const findHoveredRow = rows.find((p, index) => { + if (index === row) return false; + const currentRowCenter = p.top + p.height / 2; + const currentRowEdge = p.top + p.height; + const nextRow = rows[index + 1] as TableRow | undefined; + const nextRowCenter = nextRow ? nextRow.height / 2 : 0; + + return draggedRowBottom >= currentRowCenter && draggedRowBottom < currentRowEdge + nextRowCenter; + }); + if (findHoveredRow) { + dropRowIndex = rows.indexOf(findHoveredRow); + } + } + + if (isDraggingUp) { + const findHoveredRow = rows.find((p, index) => { + if (index === row) return false; + const currentRowCenter = p.top + p.height / 2; + const prevRow = rows[index - 1] as TableRow | undefined; + const prevRowTop = prevRow ? prevRow.top : 0; + const prevRowCenter = prevRow ? prevRow.height / 2 : 0; + + return draggedRowTop <= currentRowCenter && draggedRowTop > prevRowTop + prevRowCenter; + }); + if (findHoveredRow) { + dropRowIndex = rows.indexOf(findHoveredRow); + } + } + + return dropRowIndex; +}; + +/** + * @description Get the node information of the rows in the table- their offset top and height. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {TableRow[]} The information of the rows in the table. + */ +export const getTableRowNodesInfo = (table: TableNodeLocation, editor: Editor): TableRow[] => { + const result: TableRow[] = []; + let topPx = 0; + + const tableMap = TableMap.get(table.node); + if (!tableMap || tableMap.height === 0 || tableMap.width === 0) { + return result; + } + + for (let row = 0; row < tableMap.height; row++) { + const cellPos = tableMap.map[row * tableMap.width]; + if (cellPos === undefined) continue; + const dom = editor.view.domAtPos(table.start + cellPos); + if (dom.node instanceof HTMLElement) { + const heightPx = dom.node.offsetHeight; + result.push({ + top: topPx, + height: heightPx, + }); + topPx += heightPx; + } + } + return result; +}; + +/** + * @description Construct a pseudo column from the selected cells for drag preview. + * @param {Editor} editor - The editor instance. + * @param {Selection} selection - The selection. + * @param {TableNodeLocation} table - The table node location. + * @returns {HTMLElement | undefined} The pseudo column. + */ +export const constructRowDragPreview = ( + editor: Editor, + selection: Selection, + table: TableNodeLocation +): HTMLElement | undefined => { + if (!isCellSelection(selection)) return; + + const tableMap = TableMap.get(table.node); + const selectedRowRect = getSelectedRect(selection, tableMap); + const activeRowCells = tableMap.cellsInRect(selectedRowRect); + + const { tableElement, tableBodyElement } = constructDragPreviewTable(); + + const tableRowElement = document.createElement("tr"); + tableBodyElement.appendChild(tableRowElement); + + activeRowCells.forEach((cellPos) => { + const resolvedCellPos = table.start + cellPos + 1; + const cellElement = editor.view.domAtPos(resolvedCellPos).node; + if (cellElement instanceof HTMLElement) { + const { clonedCellElement } = cloneTableCell(cellElement); + clonedCellElement.style.width = cellElement.getBoundingClientRect().width + "px"; + tableRowElement.appendChild(clonedCellElement); + } + }); + + updateCellContentVisibility(editor, true); + + return tableElement; +}; diff --git a/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts new file mode 100644 index 0000000000..591d435760 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/drag-handles/utils.ts @@ -0,0 +1,60 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +import { CORE_EDITOR_META } from "@/constants/meta"; + +/** + * @description Construct a pseudo table element which will act as a parent for column and row drag previews. + * @returns {HTMLTableElement} The pseudo table. + */ +export const constructDragPreviewTable = (): { + tableElement: HTMLTableElement; + tableBodyElement: HTMLTableSectionElement; +} => { + const tableElement = document.createElement("table"); + tableElement.classList.add("table-drag-preview"); + tableElement.classList.add("bg-custom-background-100"); + tableElement.style.opacity = "0.9"; + const tableBodyElement = document.createElement("tbody"); + tableElement.appendChild(tableBodyElement); + + return { tableElement, tableBodyElement }; +}; + +/** + * @description Clone a table cell element. + * @param {HTMLElement} cellElement - The cell element to clone. + * @returns {HTMLElement} The cloned cell element. + */ +export const cloneTableCell = ( + cellElement: HTMLElement +): { + clonedCellElement: HTMLElement; +} => { + const clonedCellElement = cellElement.cloneNode(true) as HTMLElement; + clonedCellElement.style.setProperty("visibility", "visible", "important"); + + const widgetElement = clonedCellElement.querySelectorAll(".ProseMirror-widget"); + widgetElement.forEach((widget) => widget.remove()); + + return { clonedCellElement }; +}; + +/** + * @description This function updates the `hideContent` attribute of the table cells and headers. + * @param {Editor} editor - The editor instance. + * @param {boolean} hideContent - Whether to hide the content. + * @returns {boolean} Whether the content visibility was updated. + */ +export const updateCellContentVisibility = (editor: Editor, hideContent: boolean): boolean => + editor + .chain() + .focus() + .setMeta(CORE_EDITOR_META.ADD_TO_HISTORY, false) + .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, { + hideContent, + }) + .updateAttributes(CORE_EXTENSIONS.TABLE_HEADER, { + hideContent, + }) + .run(); diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts index 97cd2d09f7..75f3964166 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/plugin.ts @@ -1,6 +1,7 @@ -import { type Editor } from "@tiptap/core"; +import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; // local imports +import { COL_DRAG_MARKER_CLASS, DROP_MARKER_CLASS, ROW_DRAG_MARKER_CLASS } from "../drag-handles/marker-utils"; import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils"; const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert"); @@ -25,6 +26,13 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { tableInfo.rowButtonElement = rowButton; } + // Create and add drag marker if it doesn't exist + if (!tableInfo.dragMarkerContainerElement) { + const dragMarker = createMarkerContainer(); + tableElement.appendChild(dragMarker); + tableInfo.dragMarkerContainerElement = dragMarker; + } + tableMap.set(tableElement, tableInfo); }; @@ -32,6 +40,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { const tableInfo = tableMap.get(tableElement); tableInfo?.columnButtonElement?.remove(); tableInfo?.rowButtonElement?.remove(); + tableInfo?.dragMarkerContainerElement?.remove(); tableMap.delete(tableElement); }; @@ -64,6 +73,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { return new Plugin({ key: TABLE_INSERT_PLUGIN_KEY, + view() { setTimeout(updateAllTables, 0); @@ -85,3 +95,33 @@ export const TableInsertPlugin = (editor: Editor): Plugin => { }, }); }; + +const createMarkerContainer = (): HTMLElement => { + const el = document.createElement("div"); + el.className = "table-drag-marker-container"; + el.contentEditable = "false"; + el.appendChild(createDropMarker()); + el.appendChild(createColDragMarker()); + el.appendChild(createRowDragMarker()); + return el; +}; + +const createDropMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.className = DROP_MARKER_CLASS; + return el; +}; + +const createColDragMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.className = `${COL_DRAG_MARKER_CLASS} hidden`; + + return el; +}; + +const createRowDragMarker = (): HTMLElement => { + const el = document.createElement("div"); + el.className = `${ROW_DRAG_MARKER_CLASS} hidden`; + + return el; +}; diff --git a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts index c8dc5f4794..8e6526c4e1 100644 --- a/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts +++ b/packages/editor/src/core/extensions/table/plugins/insert-handlers/utils.ts @@ -1,6 +1,6 @@ import type { Editor } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { addColumn, removeColumn, addRow, removeRow, TableMap } from "@tiptap/pm/tables"; +import { addColumn, removeColumn, addRow, removeRow, TableMap, type TableRect } from "@tiptap/pm/tables"; // local imports import { isCellEmpty } from "../../table/utilities/helpers"; @@ -17,6 +17,7 @@ export type TableInfo = { tablePos: number; columnButtonElement?: HTMLElement; rowButtonElement?: HTMLElement; + dragMarkerContainerElement?: HTMLElement; }; export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => { @@ -274,7 +275,7 @@ const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => { const lastColumnIndex = tableMapData.width; const tr = editor.state.tr; - const rect = { + const rect: TableRect = { map: tableMapData, tableStart: tablePos, table: tableNode, @@ -346,7 +347,7 @@ const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => { const lastRowIndex = tableMapData.height; const tr = editor.state.tr; - const rect = { + const rect: TableRect = { map: tableMapData, tableStart: tablePos, table: tableNode, diff --git a/packages/editor/src/core/extensions/table/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index fd6cc6bb04..9aa284cd17 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -47,6 +47,9 @@ export const TableCell = Node.create({ textColor: { default: null, }, + hideContent: { + default: false, + }, }; }, @@ -107,7 +110,8 @@ export const TableCell = Node.create({ return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, + class: node.attrs.hideContent ? "content-hidden" : "", + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor};`, }), 0, ]; diff --git a/packages/editor/src/core/extensions/table/table-header.ts b/packages/editor/src/core/extensions/table/table-header.ts index 9f7f90d02d..635fb7ee7a 100644 --- a/packages/editor/src/core/extensions/table/table-header.ts +++ b/packages/editor/src/core/extensions/table/table-header.ts @@ -17,7 +17,7 @@ export const TableHeader = Node.create({ }; }, - content: "paragraph+", + content: "block+", addAttributes() { return { @@ -39,6 +39,9 @@ export const TableHeader = Node.create({ background: { default: "none", }, + hideContent: { + default: false, + }, }; }, @@ -54,7 +57,8 @@ export const TableHeader = Node.create({ return [ "th", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}`, + class: node.attrs.hideContent ? "content-hidden" : "", + style: `background-color: ${node.attrs.background};`, }), 0, ]; diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts deleted file mode 100644 index 5cd3506d31..0000000000 --- a/packages/editor/src/core/extensions/table/table/table-controls.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { findParentNode } from "@tiptap/core"; -import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state"; -import { DecorationSet, Decoration } from "@tiptap/pm/view"; -// constants -import { CORE_EXTENSIONS } from "@/constants/extension"; - -const key = new PluginKey("tableControls"); - -export function tableControls() { - return new Plugin({ - key, - state: { - init() { - return new TableControlsState(); - }, - apply(tr, prev) { - return prev.apply(tr); - }, - }, - props: { - handleTripleClickOn(view, pos, node, nodePos, event) { - if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) { - event.preventDefault(); - const $pos = view.state.doc.resolve(pos); - const line = $pos.parent; - const linePos = $pos.start(); - const start = linePos; - const end = linePos + line.nodeSize - 1; - const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end)); - view.dispatch(tr); - return true; - } - return false; - }, - handleDOMEvents: { - mousemove: (view, event) => { - const pluginState = key.getState(view.state); - - if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) { - return view.dispatch( - view.state.tr.setMeta(key, { - setHoveredTable: null, - setHoveredCell: null, - }) - ); - } - - const pos = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return; - - const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)( - TextSelection.create(view.state.doc, pos.pos) - ); - const cell = findParentNode((node) => - [CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS) - )(TextSelection.create(view.state.doc, pos.pos)); - - if (!table || !cell) return; - - if (pluginState.values.hoveredCell?.pos !== cell.pos) { - return view.dispatch( - view.state.tr.setMeta(key, { - setHoveredTable: table, - setHoveredCell: cell, - }) - ); - } - }, - }, - decorations: (state) => { - const pluginState = key.getState(state); - if (!pluginState) { - return null; - } - - const { hoveredTable, hoveredCell } = pluginState.values; - const docSize = state.doc.content.size; - if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) { - const decorations = [ - Decoration.node( - hoveredTable.pos, - hoveredTable.pos + hoveredTable.node.nodeSize, - {}, - { - hoveredTable, - hoveredCell, - } - ), - ]; - - return DecorationSet.create(state.doc, decorations); - } - - return null; - }, - }, - }); -} - -class TableControlsState { - values; - - constructor(props = {}) { - this.values = { - hoveredTable: null, - hoveredCell: null, - ...props, - }; - } - - apply(tr: Transaction) { - const actions = tr.getMeta(key); - - if (actions?.setHoveredTable !== undefined) { - this.values.hoveredTable = actions.setHoveredTable; - } - - if (actions?.setHoveredCell !== undefined) { - this.values.hoveredCell = actions.setHoveredCell; - } - - return this; - } -} diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index bab16f7e72..2462fa817d 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -7,6 +7,7 @@ import { addRowBefore, CellSelection, columnResizing, + deleteCellSelection, deleteTable, fixTables, goToNextCell, @@ -17,12 +18,13 @@ import { toggleHeader, toggleHeaderCell, } from "@tiptap/pm/tables"; -import { Decoration } from "@tiptap/pm/view"; +import type { Decoration } from "@tiptap/pm/view"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; // local imports +import { TableColumnDragHandlePlugin } from "../plugins/drag-handles/column/plugin"; +import { TableRowDragHandlePlugin } from "../plugins/drag-handles/row/plugin"; import { TableInsertPlugin } from "../plugins/insert-handlers/plugin"; -import { tableControls } from "./table-controls"; import { TableView } from "./table-view"; import { createTable } from "./utilities/create-table"; import { deleteColumnOrTable } from "./utilities/delete-column"; @@ -57,6 +59,7 @@ declare module "@tiptap/core" { toggleHeaderColumn: () => ReturnType; toggleHeaderRow: () => ReturnType; toggleHeaderCell: () => ReturnType; + clearSelectedCells: () => ReturnType; mergeOrSplit: () => ReturnType; setCellAttribute: (name: string, value: any) => ReturnType; goToNextCell: () => ReturnType; @@ -174,6 +177,10 @@ export const Table = Node.create({ () => ({ state, dispatch }) => toggleHeaderCell(state, dispatch), + clearSelectedCells: + () => + ({ state, dispatch }) => + deleteCellSelection(state, dispatch), mergeOrSplit: () => ({ state, dispatch }) => { @@ -254,10 +261,10 @@ export const Table = Node.create({ }, addNodeView() { - return ({ editor, getPos, node, decorations }) => { + return ({ editor, node, decorations, getPos }) => { const { cellMinWidth } = this.options; - return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number); + return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos); }; }, @@ -268,8 +275,9 @@ export const Table = Node.create({ tableEditing({ allowTableNodeSelection: this.options.allowTableNodeSelection, }), - tableControls(), TableInsertPlugin(this.editor), + TableColumnDragHandlePlugin(this.editor), + TableRowDragHandlePlugin(this.editor), ]; if (isResizable) { diff --git a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts index f90f1a294c..211dc2c42a 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/helpers.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/helpers.ts @@ -1,6 +1,7 @@ +import { type Editor, findParentNode } from "@tiptap/core"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { Selection } from "@tiptap/pm/state"; -import { CellSelection } from "@tiptap/pm/tables"; +import type { EditorState, Selection, Transaction } from "@tiptap/pm/state"; +import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables"; // constants import { CORE_EXTENSIONS } from "@/constants/extension"; @@ -35,3 +36,184 @@ export const isCellEmpty = (cell: ProseMirrorNode | null): boolean => { return !hasContent; }; + +export type TableNodeLocation = { + pos: number; + start: number; + node: ProseMirrorNode; +}; + +/** + * @description Find the table node location from the selection. + * @param {Selection} selection - The selection. + * @returns {TableNodeLocation | undefined} The table node location. + */ +export const findTable = (selection: Selection): TableNodeLocation | undefined => + findParentNode((node) => node.type.spec.tableRole === "table")(selection); + +/** + * @description Check if the selection has table related changes. + * @param {Editor} editor - The editor instance. + * @param {TableNodeLocation | undefined} table - The table node location. + * @param {EditorState} oldState - The old editor state. + * @param {EditorState} newState - The new editor state. + * @param {Transaction} tr - The transaction. + * @returns {boolean} True if the selection has table related changes, false otherwise. + */ +export const haveTableRelatedChanges = ( + editor: Editor, + table: TableNodeLocation | undefined, + oldState: EditorState, + newState: EditorState, + tr: Transaction +): table is TableNodeLocation => + editor.isEditable && table !== undefined && (tr.docChanged || !newState.selection.eq(oldState.selection)); + +/** + * @description Get the selected rect from the cell selection. + * @param {CellSelection} selection - The cell selection. + * @param {TableMap} map - The table map. + * @returns {Rect} The selected rect. + */ +export const getSelectedRect = (selection: CellSelection, map: TableMap): Rect => { + const start = selection.$anchorCell.start(-1); + return map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start); +}; + +/** + * @description Get the selected columns from the cell selection. + * @param {Selection} selection - The selection. + * @param {TableMap} map - The table map. + * @returns {number[]} The selected columns. + */ +export const getSelectedColumns = (selection: Selection, map: TableMap): number[] => { + if (isCellSelection(selection) && selection.isColSelection()) { + const selectedRect = getSelectedRect(selection, map); + return [...Array(selectedRect.right - selectedRect.left).keys()].map((idx) => idx + selectedRect.left); + } + + return []; +}; + +/** + * @description Get the selected rows from the cell selection. + * @param {Selection} selection - The selection. + * @param {TableMap} map - The table map. + * @returns {number[]} The selected rows. + */ +export const getSelectedRows = (selection: Selection, map: TableMap): number[] => { + if (isCellSelection(selection) && selection.isRowSelection()) { + const selectedRect = getSelectedRect(selection, map); + return [...Array(selectedRect.bottom - selectedRect.top).keys()].map((idx) => idx + selectedRect.top); + } + + return []; +}; + +/** + * @description Check if the rect is selected. + * @param {Rect} rect - The rect. + * @param {CellSelection} selection - The cell selection. + * @returns {boolean} True if the rect is selected, false otherwise. + */ +export const isRectSelected = (rect: Rect, selection: CellSelection): boolean => { + const map = TableMap.get(selection.$anchorCell.node(-1)); + const cells = map.cellsInRect(rect); + const selectedCells = map.cellsInRect(getSelectedRect(selection, map)); + + return cells.every((cell) => selectedCells.includes(cell)); +}; + +/** + * @description Check if the column is selected. + * @param {number} columnIndex - The column index. + * @param {Selection} selection - The selection. + * @returns {boolean} True if the column is selected, false otherwise. + */ +export const isColumnSelected = (columnIndex: number, selection: Selection): boolean => { + if (!isCellSelection(selection)) return false; + + const { height } = TableMap.get(selection.$anchorCell.node(-1)); + const rect = { left: columnIndex, right: columnIndex + 1, top: 0, bottom: height }; + return isRectSelected(rect, selection); +}; + +/** + * @description Check if the row is selected. + * @param {number} rowIndex - The row index. + * @param {Selection} selection - The selection. + * @returns {boolean} True if the row is selected, false otherwise. + */ +export const isRowSelected = (rowIndex: number, selection: Selection): boolean => { + if (isCellSelection(selection)) { + const { width } = TableMap.get(selection.$anchorCell.node(-1)); + const rect = { left: 0, right: width, top: rowIndex, bottom: rowIndex + 1 }; + return isRectSelected(rect, selection); + } + + return false; +}; + +/** + * @description Select the column. + * @param {TableNodeLocation} table - The table node location. + * @param {number} index - The column index. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const selectColumn = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => { + const { map } = TableMap.get(table.node); + + const anchorCell = table.start + map[index]; + const $anchor = tr.doc.resolve(anchorCell); + + return tr.setSelection(CellSelection.colSelection($anchor)); +}; + +/** + * @description Select the row. + * @param {TableNodeLocation} table - The table node location. + * @param {number} index - The row index. + * @param {Transaction} tr - The transaction. + * @returns {Transaction} The updated transaction. + */ +export const selectRow = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => { + const { map, width } = TableMap.get(table.node); + + const anchorCell = table.start + map[index * width]; + const $anchor = tr.doc.resolve(anchorCell); + + return tr.setSelection(CellSelection.rowSelection($anchor)); +}; + +/** + * @description Get the position of the cell widget decoration. + * @param {TableNodeLocation} table - The table node location. + * @param {TableMap} map - The table map. + * @param {number} index - The index. + * @returns {number} The position of the cell widget decoration. + */ +export const getTableCellWidgetDecorationPos = (table: TableNodeLocation, map: TableMap, index: number): number => + table.start + map.map[index] + 1; + +/** + * @description Get the height of the table in pixels. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {number} The height of the table in pixels. + */ +export const getTableHeightPx = (table: TableNodeLocation, editor: Editor): number => { + const dom = editor.view.domAtPos(table.start); + return dom.node.parentElement?.offsetHeight ?? 0; +}; + +/** + * @description Get the width of the table in pixels. + * @param {TableNodeLocation} table - The table node location. + * @param {Editor} editor - The editor instance. + * @returns {number} The width of the table in pixels. + */ +export const getTableWidthPx = (table: TableNodeLocation, editor: Editor): number => { + const dom = editor.view.domAtPos(table.start); + return dom.node.parentElement?.offsetWidth ?? 0; +}; diff --git a/packages/editor/src/core/extensions/trailing-node.ts b/packages/editor/src/core/extensions/trailing-node.ts new file mode 100644 index 0000000000..27e3e85ebf --- /dev/null +++ b/packages/editor/src/core/extensions/trailing-node.ts @@ -0,0 +1,69 @@ +import { Extension } from "@tiptap/core"; +import { NodeType, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +function nodeEqualsType({ types, node }: { types: NodeType[]; node: ProseMirrorNode | null }) { + // TODO: check this logic, might be wrong + // @ts-expect-error - logic might be wrong + return (Array.isArray(types) && types.includes(node?.type)) || node?.type === types; +} + +export interface TrailingNodeOptions { + node: string; + notAfter: string[]; +} + +export const TrailingNode = Extension.create({ + name: "trailingNode", + + addOptions() { + return { + node: CORE_EXTENSIONS.PARAGRAPH, + notAfter: [CORE_EXTENSIONS.PARAGRAPH], + }; + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name); + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter((node) => this.options.notAfter.includes(node.name)); + + return [ + new Plugin({ + key: plugin, + appendTransaction: (_, __, state) => { + const { doc, tr, schema } = state; + const shouldInsertNodeAtEnd = plugin.getState(state); + const endPosition = doc.content.size; + const type = schema.nodes[this.options.node]; + + if (!shouldInsertNodeAtEnd) { + return; + } + + // eslint-disable-next-line consistent-return + return tr.insert(endPosition, type.create()); + }, + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value; + } + + const lastNode = tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index cd08f5cbda..514ee4019c 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -20,7 +20,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => editable, editorClassName = "", editorProps = {}, - embedHandler, + extendedEditorProps, extensions = [], fileHandler, flaggedExtensions, @@ -81,6 +81,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => const editor = useEditor({ disabledExtensions, + extendedEditorProps, id, editable, editorProps, @@ -98,7 +99,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => ...extensions, ...DocumentEditorAdditionalExtensions({ disabledExtensions, - embedConfig: embedHandler, + extendedEditorProps, fileHandler, flaggedExtensions, isEditable: editable, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index b5c6383cd7..1285a6a7e2 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -20,6 +20,7 @@ export const useEditor = (props: TEditorHookProps) => { editorClassName = "", editorProps = {}, enableHistory, + extendedEditorProps, extensions = [], fileHandler, flaggedExtensions, @@ -54,9 +55,10 @@ export const useEditor = (props: TEditorHookProps) => { }, extensions: [ ...CoreEditorExtensions({ - editable, disabledExtensions, + editable, enableHistory, + extendedEditorProps, fileHandler, flaggedExtensions, isTouchDevice, diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 607837e4e5..bf04ba45ad 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -16,11 +16,12 @@ const generalSelectors = [ "blockquote", "h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block", "[data-type=horizontalRule]", - "table", + "table:not(.table-drag-preview)", ".issue-embed", ".image-component", ".image-upload-component", ".editor-callout-component", + ".editor-embed-component", ].join(", "); const maxScrollSpeed = 20; @@ -65,9 +66,7 @@ const isScrollable = (node: HTMLElement | SVGElement) => { }); }; -const getScrollParent = (node: HTMLElement | SVGElement | null): Element | null => { - if (!node) return null; - +export const getScrollParent = (node: HTMLElement | SVGElement) => { if (scrollParentCache.has(node)) { return scrollParentCache.get(node); } @@ -92,7 +91,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { for (const elem of elements) { // Check for table wrapper first - if (elem.matches("table")) { + if (elem.matches("table:not(.table-drag-preview)")) { return elem; } @@ -105,6 +104,11 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { continue; } + // Skip elements inside .editor-embed-component + if (elem.closest(".editor-embed-component") && !elem.matches(".editor-embed-component")) { + continue; + } + // apply general selector if (elem.matches(generalSelectors)) { return elem; @@ -173,7 +177,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp scrollableParent.scrollBy({ top: currentScrollSpeed }); } - scrollAnimationFrame = requestAnimationFrame(scroll); + scrollAnimationFrame = requestAnimationFrame(scroll) as unknown as null; } const handleClick = (event: MouseEvent, view: EditorView) => { @@ -381,8 +385,9 @@ const handleNodeSelection = ( let draggedNodePos = nodePosAtDOM(node, view, options); if (draggedNodePos == null || draggedNodePos < 0) return; - // Handle blockquote separately - if (node.matches("blockquote")) { + if (node.matches("table")) { + draggedNodePos = draggedNodePos - 2; + } else if (node.matches("blockquote")) { draggedNodePos = nodePosAtDOMForBlockQuotes(node, view); if (draggedNodePos === null || draggedNodePos === undefined) return; } else { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 2d04b96770..6c72541377 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -2,8 +2,15 @@ import type { Content, Extensions, JSONContent, RawCommands } from "@tiptap/core import type { MarkType, NodeType } from "@tiptap/pm/model"; import type { Selection } from "@tiptap/pm/state"; import type { EditorProps, EditorView } from "@tiptap/pm/view"; +import type { NodeViewProps as TNodeViewProps } from "@tiptap/react"; // extension types import type { TTextAlign } from "@/extensions"; +// plane editor imports +import type { + IEditorPropsExtended, + TExtendedEditorCommands, + ICollaborativeDocumentEditorPropsExtended, +} from "@/plane-editor/types/editor-extended"; // types import type { IMarking, @@ -48,7 +55,9 @@ export type TEditorCommands = | "text-align" | "callout" | "attachment" - | "emoji"; + | "emoji" + | "external-embed" + | TExtendedEditorCommands; export type TCommandExtraProps = { image: { @@ -154,6 +163,7 @@ export type IEditorProps = { placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; value?: string | null; + extendedEditorProps: IEditorPropsExtended; }; export type ILiteTextEditorProps = IEditorProps; @@ -167,15 +177,14 @@ export type ICollaborativeDocumentEditorProps = Omit & { aiHandler?: TAIHandler; - embedHandler: TEmbedConfig; user?: TUserDetails; value: Content; }; @@ -191,3 +200,5 @@ export type EditorEvents = { destroy: never; ready: { height: number }; }; + +export type NodeViewProps = TNodeViewProps; diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts index 6e8dd1ee2b..a48480c9c2 100644 --- a/packages/editor/src/core/types/hook.ts +++ b/packages/editor/src/core/types/hook.ts @@ -8,6 +8,7 @@ type TCoreHookProps = Pick< | "disabledExtensions" | "editorClassName" | "editorProps" + | "extendedEditorProps" | "extensions" | "flaggedExtensions" | "handleEditorReady" @@ -50,7 +51,4 @@ export type TCollaborativeEditorHookProps = TCoreHookProps & | "placeholder" | "tabIndex" > & - Pick< - ICollaborativeDocumentEditorProps, - "dragDropEnabled" | "embedHandler" | "realtimeConfig" | "serverHandler" | "user" - >; + Pick; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index cfa67ba973..d3a12bfb5c 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -8,5 +8,6 @@ export * from "./extensions"; export * from "./hook"; export * from "./mention"; export * from "./slash-commands-suggestion"; -export * from "@/plane-editor/types"; export * from "./document-collaborative-events"; + +export * from "@/plane-editor/types"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 10d7bb4113..3cf3b6fcef 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,11 +1,3 @@ -// styles -// import "./styles/tailwind.css"; -import "./styles/variables.css"; -import "./styles/editor.css"; -import "./styles/table.css"; -import "./styles/github-dark.css"; -import "./styles/drag-drop.css"; - // editors export { CollaborativeDocumentEditorWithRef, @@ -26,3 +18,6 @@ export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; // types export * from "@/types"; + +// additional exports +export { TrailingNode } from "./core/extensions/trailing-node"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index e4aabde9ec..fca94f0a8b 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -339,9 +339,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-1-padding-bottom); - font-size: var(--font-size-h1); - line-height: var(--line-height-h1); - font-weight: 600; + font-size: var(--font-size-h1) !important; + line-height: var(--line-height-h1) !important; + font-weight: 600 !important; } .prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -350,9 +350,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-2-padding-bottom); - font-size: var(--font-size-h2); - line-height: var(--line-height-h2); - font-weight: 600; + font-size: var(--font-size-h2) !important; + line-height: var(--line-height-h2) !important; + font-weight: 600 !important; } .prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -361,9 +361,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-3-padding-bottom); - font-size: var(--font-size-h3); - line-height: var(--line-height-h3); - font-weight: 600; + font-size: var(--font-size-h3) !important; + line-height: var(--line-height-h3) !important; + font-weight: 600 !important; } .prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -372,9 +372,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-4-padding-bottom); - font-size: var(--font-size-h4); - line-height: var(--line-height-h4); - font-weight: 600; + font-size: var(--font-size-h4) !important; + line-height: var(--line-height-h4) !important; + font-weight: 600 !important; } .prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -383,9 +383,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-5-padding-bottom); - font-size: var(--font-size-h5); - line-height: var(--line-height-h5); - font-weight: 600; + font-size: var(--font-size-h5) !important; + line-height: var(--line-height-h5) !important; + font-weight: 600 !important; } .prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -394,9 +394,9 @@ p.editor-paragraph-block { } padding-bottom: var(--heading-6-padding-bottom); - font-size: var(--font-size-h6); - line-height: var(--line-height-h6); - font-weight: 600; + font-size: var(--font-size-h6) !important; + line-height: var(--line-height-h6) !important; + font-weight: 600 !important; } .prose :where(p.editor-paragraph-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -418,8 +418,8 @@ p.editor-paragraph-block { } } - font-size: var(--font-size-regular); - line-height: var(--line-height-regular); + font-size: var(--font-size-regular) !important; + line-height: var(--line-height-regular) !important; } p.editor-paragraph-block + p.editor-paragraph-block { @@ -500,3 +500,9 @@ span[data-name][data-type="emoji"] img { max-width: 1.25em; max-height: 1.25em; } + +/* touch device styles */ +.touch-select-none { + user-select: none; + -webkit-user-select: none; +} diff --git a/packages/editor/src/styles/index.css b/packages/editor/src/styles/index.css new file mode 100644 index 0000000000..e38c01c283 --- /dev/null +++ b/packages/editor/src/styles/index.css @@ -0,0 +1,5 @@ +@import "./variables.css"; +@import "./editor.css"; +@import "./table.css"; +@import "./github-dark.css"; +@import "./drag-drop.css"; diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index ba5834abb9..c2c013d77b 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -25,7 +25,7 @@ } /* Selected cell outline */ - &.selectedCell { + &.selectedCell:not(.content-hidden) { user-select: none; &::after { @@ -54,6 +54,30 @@ } } /* End selected cell outline */ + + .table-col-handle-container, + .table-row-handle-container { + & > button { + opacity: 0; + } + } + + &:hover { + .table-col-handle-container, + .table-row-handle-container { + & > button { + opacity: 1; + } + } + } + + .ProseMirror-widget + * { + padding-top: 0 !important; + } + + &.content-hidden > * { + visibility: hidden; + } } th { @@ -67,6 +91,34 @@ background-color: rgba(var(--color-background-90)); } } + + .table-drop-marker { + background-color: rgba(var(--color-primary-100)); + position: absolute; + z-index: 10; + + &.hidden { + display: none; + } + } + + .table-col-drag-marker, + .table-row-drag-marker { + position: absolute; + z-index: 10; + + &.hidden { + display: none; + } + } + + .table-col-drag-marker { + top: -9px; + } + + .table-row-drag-marker { + left: 0; + } } /* Selected status */ diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsdown.config.ts similarity index 50% rename from packages/editor/tsup.config.ts rename to packages/editor/tsdown.config.ts index 1089c00b12..348f1fd7a7 100644 --- a/packages/editor/tsup.config.ts +++ b/packages/editor/tsdown.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts", "src/lib.ts"], @@ -6,9 +6,6 @@ export default defineConfig({ format: ["esm", "cjs"], dts: true, clean: true, - external: ["react", "react-dom"], - injectStyle: true, - splitting: true, - treeshake: true, - minify: true, + sourcemap: true, + copy: ["src/styles"], }); diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 30049d361e..e93786acd7 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -5,6 +5,7 @@ const project = resolve(process.cwd(), "tsconfig.json"); /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["prettier", "plugin:@typescript-eslint/recommended"], + parser: "@typescript-eslint/parser", plugins: ["react", "react-hooks", "@typescript-eslint", "import"], globals: { React: true, @@ -43,10 +44,10 @@ module.exports = { "@typescript-eslint/no-unused-vars": [ "warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, ], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-useless-empty-export": "error", diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js index 0685b8f814..115178ec39 100644 --- a/packages/eslint-config/next.js +++ b/packages/eslint-config/next.js @@ -3,6 +3,8 @@ const project = resolve(process.cwd(), "tsconfig.json"); module.exports = { extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"], + parser: "@typescript-eslint/parser", + plugins: ["react", "@typescript-eslint", "import"], globals: { React: "readonly", JSX: "readonly", @@ -11,7 +13,6 @@ module.exports = { node: true, browser: true, }, - plugins: ["react", "@typescript-eslint", "import"], settings: { "import/resolver": { typescript: { @@ -42,10 +43,10 @@ module.exports = { "@typescript-eslint/no-unused-vars": [ "warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, ], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-useless-empty-export": "error", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 29683df8e8..fa15fe57a1 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -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:" } } diff --git a/packages/eslint-config/server.js b/packages/eslint-config/server.js index 38a08d4aba..ca3ef91562 100644 --- a/packages/eslint-config/server.js +++ b/packages/eslint-config/server.js @@ -3,6 +3,7 @@ const project = resolve(process.cwd(), "tsconfig.json"); module.exports = { extends: ["prettier", "plugin:@typescript-eslint/recommended"], + parser: "@typescript-eslint/parser", env: { node: true, es6: true, @@ -25,10 +26,33 @@ module.exports = { "@typescript-eslint/no-unused-vars": [ "warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, ], - } + "import/order": [ + "warn", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroups: [ + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, }; diff --git a/packages/hooks/.eslintrc.js b/packages/hooks/.eslintrc.js index b11b7bb6d6..ba0e590e8e 100644 --- a/packages/hooks/.eslintrc.js +++ b/packages/hooks/.eslintrc.js @@ -1,6 +1,4 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, extends: ["@plane/eslint-config/library.js"], - parser: "@typescript-eslint/parser", }; diff --git a/packages/hooks/package.json b/packages/hooks/package.json index ee130ca1cf..0c3bb7b8f6 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -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, @@ -11,8 +11,8 @@ "dist/**" ], "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", + "build": "tsdown", + "dev": "tsdown --watch", "check:lint": "eslint . --max-warnings 6", "check:types": "tsc --noEmit", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", @@ -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:", + "tsdown": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/hooks/src/use-local-storage.tsx b/packages/hooks/src/use-local-storage.tsx index 0aa8bfcc59..cb59b6d9e6 100644 --- a/packages/hooks/src/use-local-storage.tsx +++ b/packages/hooks/src/use-local-storage.tsx @@ -5,7 +5,7 @@ export const getValueFromLocalStorage = (key: string, defaultValue: any) => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; - } catch (error) { + } catch (_error) { window.localStorage.removeItem(key); return defaultValue; } @@ -16,7 +16,7 @@ export const setValueIntoLocalStorage = (key: string, value: any) => { try { window.localStorage.setItem(key, JSON.stringify(value)); return true; - } catch (error) { + } catch (_error) { return false; } }; diff --git a/packages/decorators/tsup.config.ts b/packages/hooks/tsdown.config.ts similarity index 56% rename from packages/decorators/tsup.config.ts rename to packages/hooks/tsdown.config.ts index 6ca0429534..1a894869b4 100644 --- a/packages/decorators/tsup.config.ts +++ b/packages/hooks/tsdown.config.ts @@ -1,12 +1,11 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts"], outDir: "dist", format: ["esm", "cjs"], + external: ["react", "react-dom"], dts: true, + sourcemap: true, clean: true, - minify: true, - external: ["express", "ws"], - treeshake: true, }); diff --git a/packages/hooks/tsup.config.ts b/packages/hooks/tsup.config.ts deleted file mode 100644 index 6566c82ef9..0000000000 --- a/packages/hooks/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - outDir: "dist", - format: ["esm", "cjs"], - dts: true, - clean: true, - minify: true, - splitting: true, - treeshake: true, - external: ["react"], -}); diff --git a/packages/i18n/.eslintrc.js b/packages/i18n/.eslintrc.js index 558b8f76ed..ba0e590e8e 100644 --- a/packages/i18n/.eslintrc.js +++ b/packages/i18n/.eslintrc.js @@ -1,9 +1,4 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, extends: ["@plane/eslint-config/library.js"], - parser: "@typescript-eslint/parser", - parserOptions: { - project: true, - }, }; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 564414e63f..ccb0d4880a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,13 +1,16 @@ { "name": "@plane/i18n", - "version": "0.28.0", + "version": "1.0.0", "license": "AGPL-3.0", "description": "I18n shared across multiple apps internally", "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "scripts": { - "check:lint": "eslint . --max-warnings 0", + "dev": "tsdown --watch", + "build": "tsdown", + "check:lint": "eslint . --max-warnings 2", "check:types": "tsc --noEmit", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", "fix:lint": "eslint . --fix", @@ -17,16 +20,18 @@ "dependencies": { "@plane/utils": "workspace:*", "intl-messageformat": "^10.7.11", - "mobx": "^6.13.5", - "mobx-react": "^9.1.0", - "lodash": "^4.17.21" + "mobx": "catalog:", + "mobx-react": "catalog:", + "lodash": "catalog:", + "react": "catalog:" }, "devDependencies": { "@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": "catalog:", + "@types/react": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index a93747a899..b2049544e6 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -2,3 +2,5 @@ export * from "./constants"; export * from "./context"; export * from "./hooks"; export * from "./types"; +export * from "./store"; +export * from "./locales"; diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 307289bb16..aa25abc5df 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -882,7 +882,8 @@ "in_progress": "Probíhá", "planned": "Plánováno", "paused": "Pozastaveno", - "no_of": "Počet {entity}" + "no_of": "Počet {entity}", + "resolved": "Vyřešeno" }, "chart": { "x_axis": "Osa X", @@ -916,6 +917,10 @@ "add": { "success": "{entity} úspěšně přidána", "failed": "Chyba při přidávání {entity}" + }, + "remove": { + "success": "{entity} úspěšně odebrána", + "failed": "Chyba při odebírání {entity}" } }, "epic": { @@ -2486,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane se nespustil. To může být způsobeno tím, že se jeden nebo více služeb Plane nepodařilo spustit.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logů, abyste si byli jisti." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 3c086c8cf6..fc404e6369 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -882,7 +882,8 @@ "in_progress": "In Bearbeitung", "planned": "Geplant", "paused": "Pausiert", - "no_of": "Anzahl {entity}" + "no_of": "Anzahl {entity}", + "resolved": "Gelöst" }, "chart": { "x_axis": "X-Achse", @@ -916,6 +917,10 @@ "add": { "success": "{entity} erfolgreich hinzugefügt", "failed": "Fehler beim Hinzufügen von {entity}" + }, + "remove": { + "success": "{entity} erfolgreich entfernt", + "failed": "Fehler beim Entfernen von {entity}" } }, "epic": { @@ -2485,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane ist nicht gestartet. Dies könnte daran liegen, dass einer oder mehrere Plane-Services nicht starten konnten.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wählen Sie View Logs aus setup.sh und Docker-Logs, um sicherzugehen." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 6d77eb3410..39087d43a0 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -725,7 +725,8 @@ "apply": "Apply", "applying": "Applying", "overview": "Overview", - "no_of": "No. of {entity}" + "no_of": "No. of {entity}", + "resolved": "Resolved" }, "chart": { "x_axis": "X-axis", @@ -759,6 +760,10 @@ "add": { "success": "{entity} added successfully", "failed": "Error adding {entity}" + }, + "remove": { + "success": "{entity} removed successfully", + "failed": "Error removing {entity}" } }, "epic": { @@ -2362,7 +2367,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane didn't start up. This could be because one or more Plane services failed to start.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choose View Logs from setup.sh and Docker logs to be sure." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 0f17e8f8ae..1621a1c8e9 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -885,7 +885,8 @@ "in_progress": "En progreso", "planned": "Planificado", "paused": "Pausado", - "no_of": "N.º de {entity}" + "no_of": "N.º de {entity}", + "resolved": "Resuelto" }, "chart": { "x_axis": "Eje X", @@ -919,6 +920,10 @@ "add": { "success": "{entity} agregado correctamente", "failed": "Error al agregar {entity}" + }, + "remove": { + "success": "{entity} eliminado correctamente", + "failed": "Error al eliminar {entity}" } }, "epic": { @@ -2488,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane no se inició. Esto podría deberse a que uno o más servicios de Plane fallaron al iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Selecciona View Logs desde setup.sh y los logs de Docker para estar seguro." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index a078526fec..cf6fdd87a3 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -883,7 +883,8 @@ "in_progress": "En cours", "planned": "Planifié", "paused": "En pause", - "no_of": "Nº de {entity}" + "no_of": "Nº de {entity}", + "resolved": "Résolu" }, "chart": { "x_axis": "Axe X", @@ -917,6 +918,10 @@ "add": { "success": "{entity} ajouté avec succès", "failed": "Erreur lors de l'ajout de {entity}" + }, + "remove": { + "success": "{entity} supprimé avec succès", + "failed": "Erreur lors de la suppression de {entity}" } }, "epic": { @@ -2486,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane n'a pas démarré. Cela pourrait être dû au fait qu'un ou plusieurs services Plane ont échoué à démarrer.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choisissez View Logs depuis setup.sh et les logs Docker pour en être sûr." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 01f6d14241..9fd407bdeb 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -882,7 +882,8 @@ "in_progress": "Sedang berlangsung", "planned": "Direncanakan", "paused": "Dijedaikan", - "no_of": "Jumlah {entity}" + "no_of": "Jumlah {entity}", + "resolved": "Terselesaikan" }, "chart": { "x_axis": "Sumbu-X", @@ -916,6 +917,10 @@ "add": { "success": "{entity} berhasil ditambahkan", "failed": "Terjadi kesalahan saat menambahkan {entity}" + }, + "remove": { + "success": "{entity} berhasil dihapus", + "failed": "Terjadi kesalahan saat menghapus {entity}" } }, "epic": { @@ -2481,7 +2486,6 @@ "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Pilih View Logs dari setup.sh dan log Docker untuk memastikan." }, "no_of": "Jumlah {entity}", - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/index.ts b/packages/i18n/src/locales/index.ts new file mode 100644 index 0000000000..86ecc6bd5f --- /dev/null +++ b/packages/i18n/src/locales/index.ts @@ -0,0 +1,105 @@ +// Export all locale files to make them accessible from the package root +export { default as enCore } from "./en/core.json"; +export { default as enTranslations } from "./en/translations.json"; +export { default as enAccessibility } from "./en/accessibility.json"; +export { default as enEditor } from "./en/editor.json"; + +// Export locale data for all supported languages +export const locales = { + en: { + core: () => import("./en/core.json"), + translations: () => import("./en/translations.json"), + accessibility: () => import("./en/accessibility.json"), + editor: () => import("./en/editor.json"), + }, + fr: { + translations: () => import("./fr/translations.json"), + accessibility: () => import("./fr/accessibility.json"), + editor: () => import("./fr/editor.json"), + }, + es: { + translations: () => import("./es/translations.json"), + accessibility: () => import("./es/accessibility.json"), + editor: () => import("./es/editor.json"), + }, + ja: { + translations: () => import("./ja/translations.json"), + accessibility: () => import("./ja/accessibility.json"), + editor: () => import("./ja/editor.json"), + }, + "zh-CN": { + translations: () => import("./zh-CN/translations.json"), + accessibility: () => import("./zh-CN/accessibility.json"), + editor: () => import("./zh-CN/editor.json"), + }, + "zh-TW": { + translations: () => import("./zh-TW/translations.json"), + accessibility: () => import("./zh-TW/accessibility.json"), + editor: () => import("./zh-TW/editor.json"), + }, + ru: { + translations: () => import("./ru/translations.json"), + accessibility: () => import("./ru/accessibility.json"), + editor: () => import("./ru/editor.json"), + }, + it: { + translations: () => import("./it/translations.json"), + accessibility: () => import("./it/accessibility.json"), + editor: () => import("./it/editor.json"), + }, + cs: { + translations: () => import("./cs/translations.json"), + accessibility: () => import("./cs/accessibility.json"), + editor: () => import("./cs/editor.json"), + }, + sk: { + translations: () => import("./sk/translations.json"), + accessibility: () => import("./sk/accessibility.json"), + editor: () => import("./sk/editor.json"), + }, + de: { + translations: () => import("./de/translations.json"), + accessibility: () => import("./de/accessibility.json"), + editor: () => import("./de/editor.json"), + }, + ua: { + translations: () => import("./ua/translations.json"), + accessibility: () => import("./ua/accessibility.json"), + editor: () => import("./ua/editor.json"), + }, + pl: { + translations: () => import("./pl/translations.json"), + accessibility: () => import("./pl/accessibility.json"), + editor: () => import("./pl/editor.json"), + }, + ko: { + translations: () => import("./ko/translations.json"), + accessibility: () => import("./ko/accessibility.json"), + editor: () => import("./ko/editor.json"), + }, + "pt-BR": { + translations: () => import("./pt-BR/translations.json"), + accessibility: () => import("./pt-BR/accessibility.json"), + editor: () => import("./pt-BR/editor.json"), + }, + id: { + translations: () => import("./id/translations.json"), + accessibility: () => import("./id/accessibility.json"), + editor: () => import("./id/editor.json"), + }, + ro: { + translations: () => import("./ro/translations.json"), + accessibility: () => import("./ro/accessibility.json"), + editor: () => import("./ro/editor.json"), + }, + "vi-VN": { + translations: () => import("./vi-VN/translations.json"), + accessibility: () => import("./vi-VN/accessibility.json"), + editor: () => import("./vi-VN/editor.json"), + }, + "tr-TR": { + translations: () => import("./tr-TR/translations.json"), + accessibility: () => import("./tr-TR/accessibility.json"), + editor: () => import("./tr-TR/editor.json"), + }, +}; diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index fc457eeb0d..a29e0295d3 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -881,7 +881,8 @@ "in_progress": "In corso", "planned": "Pianificato", "paused": "In pausa", - "no_of": "N. di {entity}" + "no_of": "N. di {entity}", + "resolved": "Risolto" }, "chart": { "x_axis": "Asse X", @@ -915,6 +916,10 @@ "add": { "success": "{entity} aggiunto con successo", "failed": "Errore nell'aggiunta di {entity}" + }, + "remove": { + "success": "{entity} rimosso con successo", + "failed": "Errore nella rimozione di {entity}" } }, "epic": { @@ -2485,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane non si è avviato. Questo potrebbe essere dovuto al fatto che uno o più servizi Plane non sono riusciti ad avviarsi.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Scegli View Logs da setup.sh e dai log Docker per essere sicuro." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index e92e0a182b..bff522b372 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -883,7 +883,8 @@ "in_progress": "進行中", "planned": "計画済み", "paused": "一時停止", - "no_of": "{entity} の数" + "no_of": "{entity} の数", + "resolved": "解決済み" }, "chart": { "x_axis": "エックス アクシス", @@ -917,6 +918,10 @@ "add": { "success": "{entity}を追加しました", "failed": "{entity}の追加中にエラーが発生しました" + }, + "remove": { + "success": "{entity}を削除しました", + "failed": "{entity}の削除中にエラーが発生しました" } }, "epic": { @@ -2486,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Planeが起動しませんでした。これは1つまたは複数のPlaneサービスの起動に失敗したことが原因である可能性があります。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "setup.shとDockerログからView Logsを選択して確認してください。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index 3f253931b7..9a86dab612 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -884,7 +884,8 @@ "in_progress": "진행 중", "planned": "계획된", "paused": "일시 중지됨", - "no_of": "{entity} 수" + "no_of": "{entity} 수", + "resolved": "해결됨" }, "chart": { "x_axis": "X축", @@ -918,6 +919,10 @@ "add": { "success": "{entity}가 성공적으로 추가되었습니다", "failed": "{entity} 추가 중 오류 발생" + }, + "remove": { + "success": "{entity}가 성공적으로 제거되었습니다", + "failed": "{entity} 제거 중 오류 발생" } }, "epic": { @@ -2488,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane이 시작되지 않았습니다. 이는 하나 이상의 Plane 서비스가 시작에 실패했기 때문일 수 있습니다.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "확실히 하려면 setup.sh와 Docker 로그에서 View Logs를 선택하세요." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index fdb2d0958a..a9f5190f6f 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -884,7 +884,8 @@ "in_progress": "W trakcie", "planned": "Zaplanowane", "paused": "Wstrzymane", - "no_of": "Liczba {entity}" + "no_of": "Liczba {entity}", + "resolved": "Rozwiązane" }, "chart": { "x_axis": "Oś X", @@ -918,6 +919,10 @@ "add": { "success": "{entity} dodano pomyślnie", "failed": "Błąd podczas dodawania {entity}" + }, + "remove": { + "success": "{entity} usunięto pomyślnie", + "failed": "Błąd podczas usuwania {entity}" } }, "epic": { @@ -2487,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nie uruchomił się. Może to być spowodowane tym, że jedna lub więcej usług Plane nie mogła się uruchomić.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wybierz View Logs z setup.sh i logów Docker, aby mieć pewność." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index d12918556e..f52a220fc4 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -884,7 +884,8 @@ "in_progress": "Em andamento", "planned": "Planejado", "paused": "Pausado", - "no_of": "Nº de {entity}" + "no_of": "Nº de {entity}", + "resolved": "Resolvido" }, "chart": { "x_axis": "Eixo X", @@ -918,6 +919,10 @@ "add": { "success": "{entity} adicionado com sucesso", "failed": "Erro ao adicionar {entity}" + }, + "remove": { + "success": "{entity} removido com sucesso", + "failed": "Erro ao remover {entity}" } }, "epic": { @@ -2482,7 +2487,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "O Plane não inicializou. Isso pode ser porque um ou mais serviços do Plane falharam ao iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Escolha View Logs do setup.sh e logs do Docker para ter certeza." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index caa1178d11..46c42f683a 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -882,7 +882,8 @@ "in_progress": "În desfășurare", "planned": "Planificat", "paused": "Pauzat", - "no_of": "Nr. de {entity}" + "no_of": "Nr. de {entity}", + "resolved": "Rezolvat" }, "chart": { "x_axis": "axa-X", @@ -916,6 +917,10 @@ "add": { "success": "{entity} a fost adăugată cu succes", "failed": "Eroare la adăugarea {entity}" + }, + "remove": { + "success": "{entity} a fost eliminată cu succes", + "failed": "Eroare la eliminarea {entity}" } }, "epic": { @@ -2480,7 +2485,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nu a pornit. Aceasta ar putea fi din cauza că unul sau mai multe servicii Plane au eșuat să pornească.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Alegeți View Logs din setup.sh și logurile Docker pentru a fi siguri." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 8481b596cb..ac1db05017 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -884,7 +884,8 @@ "in_progress": "В процессе", "planned": "Запланировано", "paused": "На паузе", - "no_of": "Количество {entity}" + "no_of": "Количество {entity}", + "resolved": "Решено" }, "chart": { "x_axis": "Ось X", @@ -918,6 +919,10 @@ "add": { "success": "{entity} успешно добавлен", "failed": "Ошибка добавления {entity}" + }, + "remove": { + "success": "{entity} успешно удален", + "failed": "Ошибка удаления {entity}" } }, "epic": { @@ -2488,8 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустился. Это может быть из-за того, что один или несколько сервисов Plane не смогли запуститься.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Выберите View Logs из setup.sh и логов Docker, чтобы убедиться." }, - "no_of": "Количество {entity}", - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 7c89fc1317..08da7e2dd3 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -884,7 +884,8 @@ "in_progress": "Prebieha", "planned": "Plánované", "paused": "Pozastavené", - "no_of": "Počet {entity}" + "no_of": "Počet {entity}", + "resolved": "Vyriešené" }, "chart": { "x_axis": "Os X", @@ -918,6 +919,10 @@ "add": { "success": "{entity} bola úspešne pridaná", "failed": "Chyba pri pridávaní {entity}" + }, + "remove": { + "success": "{entity} bola úspešne odstránená", + "failed": "Chyba pri odstrávaní {entity}" } }, "epic": { @@ -2487,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane sa nespustil. Toto môže byť spôsobené tým, že sa jedna alebo viac služieb Plane nepodarilo spustiť.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logov, aby ste si boli istí." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index 349e547ad6..8088ff2135 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -886,7 +886,8 @@ "in_progress": "Devam ediyor", "planned": "Planlandı", "paused": "Durduruldu", - "no_of": "{entity} sayısı" + "no_of": "{entity} sayısı", + "resolved": "Çözüldü" }, "chart": { "x_axis": "X ekseni", @@ -920,6 +921,10 @@ "add": { "success": "{entity} başarıyla eklendi", "failed": "{entity} eklenirken hata oluştu" + }, + "remove": { + "success": "{entity} başarıyla kaldırıldı", + "failed": "{entity} kaldırılırken hata oluştu" } }, "epic": { @@ -2467,7 +2472,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane başlatılamadı. Bu, bir veya daha fazla Plane servisinin başlatılamaması nedeniyle olabilir.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Emin olmak için setup.sh ve Docker loglarından View Logs'u seçin." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 1a3ab64f9e..6f9cccd4e1 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -884,7 +884,8 @@ "in_progress": "В процесі", "planned": "Заплановано", "paused": "Призупинено", - "no_of": "Кількість {entity}" + "no_of": "Кількість {entity}", + "resolved": "Вирішено" }, "chart": { "x_axis": "Вісь X", @@ -918,6 +919,10 @@ "add": { "success": "{entity} успішно додано", "failed": "Помилка під час додавання {entity}" + }, + "remove": { + "success": "{entity} успішно видалено", + "failed": "Помилка під час видалення {entity}" } }, "epic": { @@ -2487,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустився. Це може бути через те, що один або декілька сервісів Plane не змогли запуститися.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Виберіть View Logs з setup.sh та логів Docker, щоб переконатися." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 947f10b166..12139dd6c0 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -883,7 +883,8 @@ "in_progress": "Đang tiến hành", "planned": "Đã lên kế hoạch", "paused": "Tạm dừng", - "no_of": "Số lượng {entity}" + "no_of": "Số lượng {entity}", + "resolved": "Đã giải quyết" }, "chart": { "x_axis": "Trục X", @@ -917,6 +918,10 @@ "add": { "success": "Đã thêm {entity} thành công", "failed": "Đã xảy ra lỗi khi thêm {entity}" + }, + "remove": { + "success": "Đã xóa {entity} thành công", + "failed": "Đã xảy ra lỗi khi xóa {entity}" } }, "epic": { @@ -2485,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane không khởi động được. Điều này có thể do một hoặc nhiều dịch vụ Plane không khởi động được.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Chọn View Logs từ setup.sh và log Docker để chắc chắn." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 444936c501..03b35d9f69 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -883,7 +883,8 @@ "in_progress": "进行中", "planned": "已计划", "paused": "暂停", - "no_of": "{entity} 的数量" + "no_of": "{entity} 的数量", + "resolved": "已解决" }, "chart": { "x_axis": "X轴", @@ -917,6 +918,10 @@ "add": { "success": "{entity}添加成功", "failed": "添加{entity}时出错" + }, + "remove": { + "success": "{entity}删除成功", + "failed": "删除{entity}时出错" } }, "epic": { @@ -2467,7 +2472,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能启动。这可能是因为一个或多个 Plane 服务启动失败。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "请选择“查看日志”来查看 setup.sh 和 Docker 日志,以确认问题。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 724c434db0..0b7273b326 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -884,7 +884,8 @@ "planned": "已計劃", "paused": "暫停", "at_risk": "有風險", - "no_of": "{entity} 的數量" + "no_of": "{entity} 的數量", + "resolved": "已解決" }, "chart": { "x_axis": "X 軸", @@ -918,6 +919,10 @@ "add": { "success": "{entity} 新增成功", "failed": "新增 {entity} 時發生錯誤" + }, + "remove": { + "success": "{entity} 刪除成功", + "failed": "刪除 {entity} 時發生錯誤" } }, "epic": { @@ -2488,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能啟動。這可能是因為一個或多個 Plane 服務啟動失敗。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "從 setup.sh 和 Docker 日誌中選擇 View Logs 來確認。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index 2873c87d14..3816c2d997 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -5,7 +5,7 @@ import { makeAutoObservable, runInAction } from "mobx"; // constants import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, ETranslationFiles } from "../constants"; // core translations imports -import coreEn from "../locales/en/core.json"; +import { enCore, locales } from "../locales"; // types import { TLanguage, ILanguageOption, ITranslations } from "../types"; @@ -17,7 +17,7 @@ import { TLanguage, ILanguageOption, ITranslations } from "../types"; export class TranslationStore { // Core translations that are always loaded private coreTranslations: ITranslations = { - en: coreEn, + en: enCore, }; // List of translations for each language private translations: ITranslations = {}; @@ -138,10 +138,24 @@ export class TranslationStore { */ private async importAndMergeFiles(language: TLanguage, files: string[]) { try { - const importPromises = files.map((file) => import(`../locales/${language}/${file}.json`)); + const localeData = locales[language as keyof typeof locales]; + if (!localeData) { + throw new Error(`Locale data not found for language: ${language}`); + } + + // Filter out files that don't exist for this language + const availableFiles = files.filter((file) => { + const fileKey = file as keyof typeof localeData; + return fileKey in localeData; + }); + + const importPromises = availableFiles.map((file) => { + const fileKey = file as keyof typeof localeData; + return localeData[fileKey](); + }); const modules = await Promise.all(importPromises); - const merged = modules.reduce((acc, module) => merge(acc, module.default), {}); + const merged = modules.reduce((acc: any, module: any) => merge(acc, module.default), {}); return { default: merged }; } catch (error) { throw new Error(`Failed to import and merge files for ${language}: ${error}`); diff --git a/packages/i18n/tsdown.config.ts b/packages/i18n/tsdown.config.ts new file mode 100644 index 0000000000..e3281f2b10 --- /dev/null +++ b/packages/i18n/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: ["esm", "cjs"], + dts: true, + external: ["react", "lodash", "mobx", "mobx-react", "intl-messageformat"], + sourcemap: true, +}); diff --git a/packages/logger/.eslintrc.js b/packages/logger/.eslintrc.js index b11b7bb6d6..ba0e590e8e 100644 --- a/packages/logger/.eslintrc.js +++ b/packages/logger/.eslintrc.js @@ -1,6 +1,4 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, extends: ["@plane/eslint-config/library.js"], - parser: "@typescript-eslint/parser", }; diff --git a/packages/logger/package.json b/packages/logger/package.json index e3597faf81..ba944ab94a 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -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, @@ -18,8 +18,8 @@ "dist/**" ], "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", + "build": "tsdown", + "dev": "tsdown --watch", "check:lint": "eslint . --max-warnings 0", "check:types": "tsc --noEmit", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", @@ -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", + "tsdown": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/logger/src/config.ts b/packages/logger/src/config.ts index 84bb98e5f1..f571c87115 100644 --- a/packages/logger/src/config.ts +++ b/packages/logger/src/config.ts @@ -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); diff --git a/packages/logger/src/middleware.ts b/packages/logger/src/middleware.ts index e251a5837d..b1e9e68ccc 100644 --- a/packages/logger/src/middleware.ts +++ b/packages/logger/src/middleware.ts @@ -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, +}); diff --git a/packages/types/tsup.config.ts b/packages/logger/tsdown.config.ts similarity index 69% rename from packages/types/tsup.config.ts rename to packages/logger/tsdown.config.ts index 04ad414258..194593e86c 100644 --- a/packages/types/tsup.config.ts +++ b/packages/logger/tsdown.config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts"], outDir: "dist", format: ["esm", "cjs"], dts: true, + sourcemap: true, clean: true, - minify: true, }); diff --git a/packages/logger/tsup.config.ts b/packages/logger/tsup.config.ts deleted file mode 100644 index 01d63fe278..0000000000 --- a/packages/logger/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - outDir: "dist", - format: ["esm", "cjs"], - dts: true, - clean: true, - minify: true, - splitting: true, - treeshake: true, - external: ["winston", "winston-daily-rotate-file"], -}); diff --git a/packages/propel/.eslintrc.js b/packages/propel/.eslintrc.js index 0ce8953748..059c5445b2 100644 --- a/packages/propel/.eslintrc.js +++ b/packages/propel/.eslintrc.js @@ -1,8 +1,6 @@ -/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, - extends: ["@plane/eslint-config/library.js"], - parser: "@typescript-eslint/parser", + extends: ["@plane/eslint-config/library.js", "plugin:storybook/recommended"], rules: { "import/order": [ "warn", diff --git a/packages/propel/.storybook/main.ts b/packages/propel/.storybook/main.ts new file mode 100644 index 0000000000..2fe48942aa --- /dev/null +++ b/packages/propel/.storybook/main.ts @@ -0,0 +1,20 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +import { join, dirname } from "path"; + +/* + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string) { + return dirname(require.resolve(join(value, "package.json"))); +} +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-designs", "@storybook/addon-docs"], + framework: { + name: getAbsolutePath("@storybook/react-vite"), + options: {}, + }, +}; +export default config; diff --git a/packages/propel/.storybook/manager.ts b/packages/propel/.storybook/manager.ts new file mode 100644 index 0000000000..b0d3a40220 --- /dev/null +++ b/packages/propel/.storybook/manager.ts @@ -0,0 +1,14 @@ +import { addons } from "storybook/manager-api"; +import { create } from "storybook/theming"; + +const planeTheme = create({ + base: "dark", + brandTitle: "Plane UI", + brandUrl: "https://plane.so", + brandImage: "plane-lockup-light.svg", + brandTarget: "_self", +}); + +addons.setConfig({ + theme: planeTheme, +}); diff --git a/packages/propel/.storybook/preview.ts b/packages/propel/.storybook/preview.ts new file mode 100644 index 0000000000..b1402e0bd0 --- /dev/null +++ b/packages/propel/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "@storybook/react-vite"; +import "@plane/tailwind-config/global.css"; +import "../src/styles/react-day-picker.css"; + +const parameters: Preview["parameters"] = { + controls: { + matchers: {}, + }, +}; + +const preview: Preview = { + parameters, + tags: ["autodocs"], +}; +export default preview; diff --git a/packages/propel/package.json b/packages/propel/package.json index 0bf3b7ce45..a4155131ee 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,49 +1,82 @@ { "name": "@plane/propel", - "version": "0.28.0", + "version": "1.0.0", "private": true, "license": "AGPL-3.0", "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", "check:lint": "eslint . --max-warnings 7", "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" + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "exports": { - "./avatar": "./src/avatar/index.ts", - "./charts/*": "./src/charts/*/index.ts", - "./dialog": "./src/dialog/index.ts", - "./menu": "./src/menu/index.ts", - "./table": "./src/table/index.ts", - "./tabs": "./src/tabs/index.ts", - "./popover": "./src/popover/index.ts", - "./command": "./src/command/index.ts", - "./combobox": "./src/combobox/index.ts", - "./tooltip": "./src/tooltip/index.ts", - "./styles/fonts": "./src/styles/fonts/index.css" + "./accordion": "./dist/accordion/index.js", + "./animated-counter": "./dist/animated-counter/index.js", + "./avatar": "./dist/avatar/index.js", + "./button": "./dist/button/index.js", + "./calendar": "./dist/calendar/index.js", + "./card": "./dist/card/index.js", + "./charts/*": "./dist/charts/*/index.js", + "./collapsible": "./dist/collapsible/index.js", + "./combobox": "./dist/combobox/index.js", + "./command": "./dist/command/index.js", + "./context-menu": "./dist/context-menu/index.js", + "./dialog": "./dist/dialog/index.js", + "./emoji-icon-picker": "./dist/emoji-icon-picker/index.js", + "./emoji-reaction": "./dist/emoji-reaction/index.js", + "./emoji-reaction-picker": "./dist/emoji-reaction-picker/index.js", + "./icons": "./dist/icons/index.js", + "./menu": "./dist/menu/index.js", + "./pill": "./dist/pill/index.js", + "./popover": "./dist/popover/index.js", + "./scrollarea": "./dist/scrollarea/index.js", + "./skeleton": "./dist/skeleton/index.js", + "./styles/fonts": "./dist/styles/fonts/index.css", + "./styles/react-day-picker": "./dist/styles/react-day-picker.css", + "./switch": "./dist/switch/index.js", + "./table": "./dist/table/index.js", + "./tabs": "./dist/tabs/index.js", + "./toast": "./dist/toast/index.js", + "./toolbar": "./dist/toolbar/index.js", + "./tooltip": "./dist/tooltip/index.js", + "./utils": "./dist/utils/index.js" }, "dependencies": { "@base-ui-components/react": "^1.0.0-beta.2", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", "@plane/types": "workspace:*", - "@plane/ui": "workspace:*", - "@plane/utils": "workspace:*", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", - "lucide-react": "^0.469.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "recharts": "^2.15.1" + "cmdk": "^1.1.1", + "clsx": "^2.1.1", + "frimousse": "^0.3.0", + "lucide-react": "catalog:", + "react": "catalog:", + "react-day-picker": "9.5.0", + "react-dom": "catalog:", + "recharts": "^2.15.1", + "tailwind-merge": "^3.3.1", + "use-font-face-observer": "^1.3.0" }, "devDependencies": { "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.2.18", - "typescript": "5.8.3" + "@storybook/addon-designs": "10.0.2", + "@storybook/addon-docs": "9.1.2", + "@storybook/react-vite": "9.1.2", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "eslint-plugin-storybook": "9.1.2", + "storybook": "9.1.2", + "tsdown": "catalog:", + "typescript": "catalog:" } } \ No newline at end of file diff --git a/packages/propel/public/plane-lockup-light.svg b/packages/propel/public/plane-lockup-light.svg new file mode 100644 index 0000000000..d319a79eb6 --- /dev/null +++ b/packages/propel/public/plane-lockup-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/propel/src/accordion/accordion.tsx b/packages/propel/src/accordion/accordion.tsx new file mode 100644 index 0000000000..51d519f4ec --- /dev/null +++ b/packages/propel/src/accordion/accordion.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import { Accordion as BaseAccordion } from "@base-ui-components/react"; +import { PlusIcon } from "lucide-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 = ({ + defaultValue = [], + allowMultiple = false, + className = "", + children, +}) => ( + + {children} + +); + +const AccordionItem: React.FC = ({ value, disabled, className = "", children }) => ( + + {children} + +); + +const AccordionTrigger: React.FC = ({ + className = "", + icon =