Compare commits

...

127 Commits

Author SHA1 Message Date
akshat5302 4abf722ddd fix: update proxy service configuration in docker-compose for improved deployment 2025-06-20 17:48:17 +05:30
akshat5302 ed8fef5cd1 feat: add AWS S3 bucket name to docker-compose configuration 2025-06-20 17:39:32 +05:30
akshat5302 bf8935bf51 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2025-06-20 17:36:35 +05:30
akshat5302 c2e6881b4c Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2025-06-20 17:31:04 +05:30
Prateek Shourya 7045a1f2af [WEB-4361] fix: add onChange to collaborative editor #7246 2025-06-20 17:24:49 +05:30
Prateek Shourya f26b4d3d06 [WEB-4359] fix: application crash when creating work item via quick add (#7245) 2025-06-20 15:16:16 +05:30
Prateek Shourya c3c1aef7a9 [WEB-4357] fix: remove trailing slash from asset url #7240 2025-06-19 19:09:59 +05:30
Vipin Chaudhary 24e57009af [WIKI-465] fix : Add new node on click of doc end (#7063)
* fix : handle last node

* fix : handle unexpected node

* remove logs

* feat: handle focus

---------

Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
2025-06-19 17:17:56 +05:30
Anmol Singh Bhatia 2b7a17b484 [WEB-4050] feat: breadcrumbs revamp (#7188)
* chore: project feature enum added

* feat: revamp breadcrumb and add navigation dropdown component

* chore: custom search select component refactoring

* chore: breadcrumb stories added

* chore: switch label and breadcrumb link component refactor

* chore: project navigation helper function added

* chore: common breadcrumb component added

* chore: breadcrumb refactoring

* chore: code refactor

* chore: code refactor

* fix: build error

* fix: nprogress and button tooltip

* chore: code refactor

* chore: workspace view breadcrumb improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
2025-06-19 17:17:14 +05:30
Vamsi Krishna 64fd0b2830 [WEB-4321]chore: workspace views refactor (#7214)
* chore: workspace views reafactor

* chore: resolved coderabbit suggestions

* chore: added project level workspace filter

* chore: added enum for roles

* chore: removed redundant type definition

* chore: optimised the query

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-19 16:26:32 +05:30
Aaryan Khandelwal 8988cf9a85 [WEB-462] refactor: editor props structure (#7233)
* refactor: editor props structure

* chore: add missing prop

* fix: space app build

* chore: export ce types
2025-06-19 16:25:52 +05:30
Aaryan Khandelwal eb5ffebcc6 [WIKI-458] refactor: base page instance for additional properties (#7228)
* refactor: create a super class for base page

* fix: path

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-06-19 16:00:18 +05:30
M. Palanikannan 414010688d [WIKI-384] chore: editor core refactor (#7235)
* fix: extra actions

* chore: page flags
2025-06-19 15:59:38 +05:30
JayashTripathy 171099667e [WEB-4339] fix: projects dropdown shows all projects (#7238)
* fix: projects drop only shows joined project

* refactor: removed unused things from header
2025-06-19 15:57:19 +05:30
Akshita Goyal d65f0e264e [WEB-4327] Chore PAT permissions (#7224)
* chore: improved pat permissions

* fix: err message

* fix: removed permission from backend

* [WEB-4330] refactor: update API token endpoints to use user context instead of workspace slug

- Changed URL patterns for API token endpoints to use "users/api-tokens/" instead of "workspaces/<str:slug>/api-tokens/".
- Refactored ApiTokenEndpoint methods to remove workspace slug parameter and adjust database queries accordingly.
- Added new test cases for API token creation, retrieval, deletion, and updates, including support for bot users and minimal data submissions.

* fix: removed workspace slug from api-tokens

* fix: refactor

* chore: url.py code rabbit suggestion

* fix: APITokenService moved to package

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-06-18 16:08:11 +05:30
Akshita Goyal c7d17d00b7 [WEB-4017] fix: hooks and store refactoring for issue-details (#7107)
* fix: hooks and store splitting for issue-details

* fix: refactoring

* fix: refactoring

* fix: refactor

* fix: css
2025-06-18 15:59:26 +05:30
JayashTripathy 9cdfb2224a [WEB-4160]: Context menu close after clicking on menu item of project #7231 2025-06-18 15:33:06 +05:30
Sangeetha 8129f5f969 [WEB-4340] fix: duplicate assignees in user recents (#7216)
* fix: duplicate assignees in user recents

* chore: optimize filtering logic

* chore: filter with deleted_at field

* chore: tests for IssueRecentSerializer
2025-06-18 15:14:21 +05:30
Prateek Shourya 89b8cdbe6e [WEB-4335] improvement: optimize assignee grouping with improved member scope handling (#7227) 2025-06-17 17:17:04 +05:30
Prateek Shourya 53e6a62a12 fix: move lucide related constants to ui package (#7226)
* fix: move lucide related constants to ui package

* chore: update yarn.lock
2025-06-17 17:06:05 +05:30
Prateek Shourya 75f89c4c12 fix: docker build (#7220)
* fix: docker build

* fix: build
2025-06-17 14:08:50 +05:30
Anmol Singh Bhatia 0983e5f44d [WEB-4281] chore: project error message updated (#7190)
* chore: project error message updated

* fix: error message for project creation

* fix: incorrect error code

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
2025-06-16 17:19:44 +05:30
Prateek Shourya 2014400bed refactor: move web utils to packages (#7145)
* refactor: move web utils to packages

* fix: build and lint errors

* chore: update drag handle plugin

* chore: update table cell type to fix build errors

* fix: build errors

* chore: sync few changes

* fix: build errors

* chore: minor fixes related to duplicate assets imports

* fix: build errors

* chore: minor changes
2025-06-16 17:18:41 +05:30
sriram veeraghanta dffcc6dc10 chore(deps): brace-expansion upgraded to 2.0.2 2025-06-16 17:10:08 +05:30
sriram veeraghanta 640b23fb1b chore(deps): nextjs upgrade to 14.2.30 2025-06-16 17:02:04 +05:30
JayashTripathy e13d8aa4b3 [WEB-4231] Pie chart tooltip #7192 2025-06-16 14:03:07 +05:30
Prateek Shourya cf595de7c7 [WEB-4311] fix: membership data handling and state reversal on error (#7205) 2025-06-16 14:02:47 +05:30
JayashTripathy 0fa9c8b015 [WEB-4323] refactor: Analytics refactor (#7213)
* chore: updated label for epics

* chore: improved export logic

* refactor: move csvConfig to export.ts and clean up export logic

* refactor: remove unused CSV export logic from WorkItemsInsightTable component

* refactor: streamline data handling in InsightTable component for improved rendering

* feat: add translation for "No. of {entity}" and update priority chart y-axis label to use new translation

* refactor: cleaned up some component and added utilitites

* feat: add "at_risk" translation to multiple languages in translations.json files

* refactor: update TrendPiece component to use new status variants for analytics

* fix: adjust TrendPiece component logic for on-track and off-track status

* refactor: use nullish coalescing operator for yAxis.dx in line and scatter charts

* feat: add "at_risk" translation to various languages in translations.json files

* feat: add "no_of" translation to various languages in translations.json files

* feat: update "at_risk" translation in Ukrainian, Vietnamese, and Chinese locales in translations.json files

* refactor: rename insightsFields to ANALYTICS_INSIGHTS_FIELDS and update analytics tab import to use getAnalyticsTabs function

* feat: update AnalyticsWrapper to use i18n for titles and add new translation for "no_of" in Russian

* fix: update yAxis labels and offsets in various charts to use new translation key and improve layout

* feat: define AnalyticsTab interface and refactor getAnalyticsTabs function for improved type safety

* fix: update AnalyticsTab interface to use TAnalyticsTabsBase for improved type safety

* fix: add whitespace-nowrap class to TableHead for improved header layout in DataTable component
2025-06-16 14:01:49 +05:30
Aaryan Khandelwal 6fe0415d66 [WEB-4316] chore: new endpoints to download an asset (#7207)
* chore: new endpoints to download an asset

* chore: add exception handling
2025-06-13 14:41:08 +05:30
sriram veeraghanta ebc2bdcd3a feat: adding build process to logger package using tsup #7210 2025-06-13 01:50:44 +05:30
Aaron 11b222ece8 chore(deps): update TypeScript version across multiple packages to 5.8.3 (#7209) 2025-06-13 01:40:27 +05:30
JayashTripathy c1a078ef3f [WEB-4246] Analytics minor improvements (#7194)
* chore: updated label for epics

* chore: improved export logic

* refactor: move csvConfig to export.ts and clean up export logic

* refactor: remove unused CSV export logic from WorkItemsInsightTable component

* refactor: streamline data handling in InsightTable component for improved rendering

* feat: add translation for "No. of {entity}" and update priority chart y-axis label to use new translation

* refactor: cleaned up some component and added utilitites

* feat: add "at_risk" translation to multiple languages in translations.json files

* refactor: update TrendPiece component to use new status variants for analytics

* fix: adjust TrendPiece component logic for on-track and off-track status

* refactor: use nullish coalescing operator for yAxis.dx in line and scatter charts

* feat: add "at_risk" translation to various languages in translations.json files

* feat: add "no_of" translation to various languages in translations.json files

* feat: update "at_risk" translation in Ukrainian, Vietnamese, and Chinese locales in translations.json files
2025-06-12 21:15:09 +05:30
Akshita Goyal ad11a34efc [WEB-4236] fix: divided settings scroll for sidebar and main content (#7201)
* fix: divided settings scroll for sidebar and main content

* fix: handled icons

* fix: mobile css
2025-06-11 16:11:40 +05:30
Prateek Shourya 9c28db8b7b [WEB-4300] improvement: add allowedProjectIds to create work item modal (#7195) 2025-06-10 20:32:39 +05:30
dependabot[bot] 32d5fea3d3 chore(deps): bump requests (#7193)
Bumps the pip group with 1 update in the /apiserver/requirements directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.2 to 2.32.4
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.2...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 17:39:48 +05:30
Prateek Shourya 6adc721b34 [WEB-4283] fix: update group key handling in issue store utilities for state groups (#7191)
* fix: update group key handling in issue store utilities for state groups

- Introduced a new function to determine the default group key based on the provided groupByKey.
- Updated references to use the new function for improved clarity and maintainability.
- Adjusted the mapping for "state_detail.group" in the ISSUE_GROUP_BY_KEY to ensure consistency.
- Enhanced the getArrayStringArray method to handle group values more effectively.

* refactor: clean up filters constants
2025-06-10 13:56:42 +05:30
Anmol Singh Bhatia 531748dcc3 [WEB-4288] fix: auth page tab index (#7189)
* fix: auth page tab index

* chore: code refactor
2025-06-10 01:47:59 +05:30
Sangmin Ahn 9965f48ba7 fix: prevent prematurely triggered Japanese label creation (#7084) 2025-06-09 16:07:42 +05:30
Saurabh Kumar d15d7549f7 [SILO-303] Add external id and external source in project model #7182 2025-06-09 16:02:09 +05:30
Vamsi Krishna 8fcffd2338 [WEB-4196]fix: sub work item copy link message #7186 2025-06-09 15:46:57 +05:30
Vamsi Krishna 07e937cd8e [WEB-4094]chore: workspace notifications refactor (#7061)
* chore: workspace notifications refactor

* fix: url params

* fix: added null checks to avoid run time errors

* fix: notifications header color fix
2025-06-09 15:33:57 +05:30
Farahat Abdrabouh 1f1b421735 Docs: Correct numeric values in contributing guide #7184 2025-06-09 13:22:07 +05:30
sriram veeraghanta 5a43ec8411 chore: turbo repo version upgrade 2025-06-09 13:20:07 +05:30
sriram veeraghanta c86e7e02bc chore: upgrade tar-fs package to fix vulnerabilities 2025-06-09 13:19:14 +05:30
sriram veeraghanta d91d7a2f60 chore: tar-fs patch upgrade 2025-06-09 12:58:18 +05:30
sriram veeraghanta b3b285b1e5 chore: upgrade django version to 4.2.22 2025-06-09 12:49:26 +05:30
Prateek Shourya 11debee402 fix: build errors related to project member list (#7185) 2025-06-09 00:31:27 +05:30
Vamsi Krishna 1608e4f122 [WEB-3374]feat: added merge date display (#7141)
* feat: added merge date display

* chore: moved formatter ti utils

* chore: removed unwanted props
2025-06-08 23:47:08 +05:30
Vamsi Krishna edeeee1227 [WEB-4063]chore: updated work item email template (#7044)
* chore: updated work item email template

* chore: passed dynamic value for email template

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-08 23:46:12 +05:30
sriram veeraghanta 9ff238816b sync: canary changes to preview 2025-06-06 18:06:51 +05:30
sriram veeraghanta 6bd5caf008 chore: upgrade package version 2025-06-06 17:50:31 +05:30
sriram veeraghanta c021aff58f chore: django version upgrade 2025-06-06 16:04:34 +05:30
sriram veeraghanta 683be55883 chore: upgrade nextjs version 2025-06-06 16:02:56 +05:30
Manish Gupta 970ce8cf26 [INFRA-183] feat: add restore-airgapped script to build workflow (#7170)
* [WEB-4260] chore: add restore-airgapped script to build workflow

* docs: update restore instructions in README for self-hosted and commercial air-gapped versions

* fix: update restore script filename and improve error handling in restore-airgapped script
2025-06-06 15:24:43 +05:30
Manish Gupta cbbe1a4e4d refactor: Enhance backup and restore scripts for container data (#7055)
* refactor: enhance backup and restore scripts for container data management

* fix: ensure proper quoting in backup script to handle paths with spaces

* fix: ensure backup directory is only removed if tar command succeeds

* CodeRabbit fixes
2025-06-06 15:24:43 +05:30
Manish Gupta 6a74677cc9 fix: update API service startup check to use HTTP request instead of logs (#7054) 2025-06-06 15:24:43 +05:30
sriram veeraghanta f6ea4f931d Merge branch 'canary' of github.com:makeplane/plane into preview 2025-06-06 15:23:10 +05:30
Aaryan Khandelwal 950fcfdb40 [WIKI-391] chore: handle deactivated user display name in version history #7171 2025-06-06 15:04:00 +05:30
Bavisetti Narayan 053c895120 [WEB 4252] chore: updated the favicon request for work item link (#7173)
* chore: added the favicon to link

* chore: added none validation for soup
2025-06-06 15:02:00 +05:30
Aaryan Khandelwal 245167e8aa refactor: unused components, hooks, constants (#7157)
* refactor: remove unused dashboard components and fetch keys

* refactor: remove unused hooks and wrappers

* chore: remove unused function
2025-06-06 14:09:56 +05:30
Vamsi Krishna 6be3f0ea73 [WEB-4208]chore: refactored work item quick actions (#7136)
* chore: refactored work item quick actions

* chore: update event handling for menu

* chore: reverted unwanted changes

* fix: update archive copy link

* chore: handled undefined function implementation
2025-06-06 13:21:00 +05:30
JayashTripathy 14d2d69120 [WEB-4230] refactor: Analytics code refacor, Removal of nivo charts dependencies and translations (#7131)
* chore: added code split for the analytics store

* chore: done some refactor

* refactor: update entity keys in analytics and translations

* chore: updated the translations

* refactor: simplify AnalyticsStoreV2 class by removing unnecessary constructor

* feat: add AnalyticsStoreV2 class and interface for enhanced analytics functionality

* feat: enhance WorkItemsModal and analytics store with isEpic functionality

* feat: integrate isEpic state into TotalInsights and WorkItemsModal components

* refactor: remove isEpic state from WorkItemsModalMainContent component

* refactor: removed old  analytics components and related services

* refactor: new analytics

* refactor: removed all nivo chart dependencies

* chore: resolved coderabbit comments

* fix: update processUrl to handle custom-work-items in peek view

* feat: implement CSV export functionality in InsightTable component

* feat: enhance analytics service with filter parameters and improve data handling in InsightTable

* feat: add new translation keys for various statuses across multiple languages

* [WEB-4246] fix: enhance analytics components to include 'isEpic' parameter for improved data fetching

* chore: update yarn.lock to remove deprecated @nivo packages and clean up unused dependencies
2025-06-06 01:53:38 +05:30
Anmol Singh Bhatia 570a9e319e [WEB-4257] chore: user profile setting options updated #7166 2025-06-06 01:47:31 +05:30
Anmol Singh Bhatia 469a027bb6 [WEB-4274] fix: metadata base url warning #7175 2025-06-05 22:51:56 +05:30
Prateek Shourya 8c99a7df88 [WEB-4273] fix: plans comparison scroll issue (#7176) 2025-06-05 22:51:05 +05:30
Prateek Shourya f34f078bd2 [WEB-4272] fix: remove duplicate CommandPalette instances from settings layouts to prevent modal conflicts (#7174) 2025-06-05 20:48:36 +05:30
Anmol Singh Bhatia 0fe2549bc6 [WEB-4256] chore: add og image and update meta tags for social media compatibility (#7165)
* chore: og image added

* chore: meta config for cross-platform support
2025-06-05 19:32:11 +05:30
Prateek Shourya 118964de01 [WEB-4254] fix: ensure user details are available in project member details computation (#7162) 2025-06-05 19:31:07 +05:30
Manish Gupta 9f37f1ef0e [INFRA-183] feat: add restore-airgapped script to build workflow (#7170)
* [WEB-4260] chore: add restore-airgapped script to build workflow

* docs: update restore instructions in README for self-hosted and commercial air-gapped versions

* fix: update restore script filename and improve error handling in restore-airgapped script
2025-06-05 17:27:57 +05:30
Prateek Shourya 986f29d1f2 [WEB-4253] improvement: plan card enhancements (#7168)
* [WEB-4253] improvement: plan card enhancements

* improvement: pricing changes
2025-06-05 14:37:26 +05:30
Aaryan Khandelwal 1113f9fc19 [WIKI-412] regression: drop plugin logic #7161 2025-06-04 19:07:49 +05:30
Prateek Shourya ef3ec7274c [WEB-4253] improvement: minor enhancements to billing page (#7160) 2025-06-04 17:29:45 +05:30
Akshita Goyal a0a45b7916 [WEB-4249] fix: settings header css + cta on error page + project member list (#7159)
* fix: settings header css + cta on error page

* [WEB-4249] fix: filter out inactive workspace members from project member list

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-06-04 16:38:35 +05:30
Aaryan Khandelwal 2792d48288 [WIKI-412] chore: improved rich text editor extensions handling (#7158)
* chore: code split for rich text editor extensions

* chore: update type

* chore: add missing prop
2025-06-04 15:32:54 +05:30
Anmol Singh Bhatia b2ccca0567 [WEB-3931] chore: maintenance page ux copy (#7135)
* chore: maintenance ux copy translation added

* chore: maintenance ux copy updated

* chore: code refactor
2025-06-04 13:37:58 +05:30
Prateek Shourya 2e822b38e4 [WEB-4240] chore: bump local db version to 1.3 #7154 2025-06-04 13:01:29 +05:30
JayashTripathy e570fe404f [WEB-4182] Fix work item links error messages (#7122)
* fix: backend error message toast when getting error

* fix: toast in small screens
2025-06-03 22:18:26 +05:30
Aaryan Khandelwal 48b613ae66 [WIKI-410] chore: editor translation files #7156 2025-06-03 22:13:56 +05:30
Prateek Shourya e70105235b [WEB-4245] improvement: minor enhancements to project members settings page (#7153) 2025-06-03 15:09:54 +05:30
Nikhil 7766e8b5cf [WEB-3998]: clean up imports and remove cache decorators in workspace views to avoid stale data on browser cache #7150 2025-06-03 13:36:52 +05:30
Akshita Goyal 16d63abcdc [WEB-3998] fix: minor empty states changes + refactoring (#7151) 2025-06-02 15:50:57 +05:30
M. Palanikannan 0568b8d583 regression: building utils back to run live server (#7149) 2025-06-02 13:32:34 +05:30
Quang Hung Pham 64da29b0d9 chore: add select all/deselect all functionality when adding existing work item (#7045)
* chore: add select all/deselect all functionality

* chore: update button display logic by CR
2025-06-02 13:30:31 +05:30
Zero King 7c336a65c4 buid: add .venv to .dockerignore (#7146) 2025-05-31 12:32:25 +05:30
sriram veeraghanta 2242a85e5c chore: nextjs upgrade 2025-05-30 21:12:02 +05:30
Aaryan Khandelwal 323920a358 [WIKI-399] fix: add favorite action to page header #7144 2025-05-30 20:58:46 +05:30
Aaryan Khandelwal 151fc8389e [WIKI-181] chore: asset check endpoint added #7140 2025-05-30 20:58:06 +05:30
sriram veeraghanta 0f828fd5e0 chore: core component fixes 2025-05-30 20:57:35 +05:30
Prateek Shourya 67cbe94d4a [WEB-3964] refactor: permission layer (#7094)
* refactor: permission layer

* refactor: add original_role to project member serializer

* chore: minor fixes related to permission layer

* fix: strict type checking while checking user permissions
2025-05-30 19:57:07 +05:30
sriram veeraghanta 322af8c436 [WEB-4223] fix: remove build process from utils package #7138 2025-05-30 18:48:18 +05:30
Sangeetha 41c2aefad4 [WEB-3998] feat: settings page revamp (#6959)
* chore: return workspace name and logo in profile settings api

* chore: remove unwanted fields

* fix: backend

* feat: workspace settings

* feat: workspce settings + layouting

* feat: profile + workspace settings ui

* chore: project settings + refactoring

* routes

* fix: handled no project

* fix: css + build

* feat: profile settings internal screens upgrade

* fix: workspace settings internal screens

* fix: external scrolling allowed

* fix: css

* fix: css

* fix: css

* fix: preferences settings

* fix: css

* fix: mobile interface

* fix: profile redirections

* fix: dark theme

* fix: css

* fix: css

* feat: scroll

* fix: refactor

* fix: bug fixes

* fix: refactor

* fix: css

* fix: routes

* fix: first day of the week

* fix: scrolling

* fix: refactoring

* fix: project -> projects

* fix: refactoring

* fix: refactor

* fix: no authorized view consistency

* fix: folder structure

* fix: revert

* fix: handled redirections

* fix: scroll

* fix: deleted old routes

* fix: empty states

* fix: headings

* fix: settings description

* fix: build

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
2025-05-30 18:47:33 +05:30
sriram veeraghanta 445c819fbd [WEB-4172] feat: Crawl work item links for title and favicon (#7117)
* feat: added a python bg task to crawl work item links for title and description

* fix: return meta_data in the response

* fix: add validation for accessing IP ranges

* fix: remove json.dumps

* fix: handle exception by returning None

* refactor: call find_favicon_url inside fetch_and_encode_favicon function

* chore: type hints

* fix: Handle None

* fix: remove print statementsg

* chore: added favicon and title of links

* fix: return null if no title found

* Update apiserver/plane/bgtasks/work_item_link_task.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: remove exception handling

* fix: reduce timeout seconds

* fix: handle timeout exception

* fix: remove request timeout handling

* feat: add Link icon to issue detail links and update rendering logic

* fix: use logger for exception

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-30 18:44:53 +05:30
Aaryan Khandelwal 046a8a1bcf [WEB-4189] chore: add tailwind container-queries plugin #7125 2025-05-30 18:41:12 +05:30
Akshita Goyal 099a1cc12b [WEB-3863] fix: links error handling #7126 2025-05-30 18:24:01 +05:30
Sangeetha a0a697401b [WEB-3787] fix: project joining date (#7127)
* fix: return project joining date

* fix: added project's joining date

* fix: set created_at as read_only_fields

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2025-05-30 18:23:19 +05:30
Aaryan Khandelwal cb92108bf4 [WEB-4197] chore: auth forms semantics and accessibility #7128 2025-05-30 18:22:20 +05:30
Aaryan Khandelwal 01b685ea57 [WIKI-181] refactor: invalid file handling #7139 2025-05-30 18:18:05 +05:30
Vipin Chaudhary b16a585102 [WIKI-343] [WIKI-312] Fix: html characters (#7049)
* fix: handle symbols and space

* chore: refactor
2025-05-30 18:17:03 +05:30
sriram veeraghanta 461e099bbc release: v0.26.0 #6962 2025-04-28 18:24:37 +05:30
sriram veeraghanta 45e25ce18b release: v0.25.3 #6788 2025-03-21 17:26:55 +05:30
sriram veeraghanta 4d88dbaf49 release: v0.25.2 (#6736) 2025-03-11 16:01:20 +05:30
sriram veeraghanta e61ff879c4 release: v0.25.1
* fix: issue activity for project id validation (#6668)

* fix: work item attachment count mutation (#6670)

* updated the action to modify the release build assets (#6669)

* feat: russian translation (#6666)

* chore: ru translation updated (#6672)

* fix: state drop down refactor

* fix: intake work item creation refactor

* fix: cleanup for deprecated functions

* fix: date range picker on cycles and modules list (#6676)

* fix: Handled workspace switcher closing on click

* fix: replaced date range picker with date picker at some places

* chore: add common translation keys (#6688)

* chore: add missing translation keys

* chore: add russian translation keys

* fix: issue activity task (#6689)

* changed github workflow action ubuntu version to `ubuntu-22.04` (#6683)

* chore: update russian translation (#6682)

* chore: update russian translation

* chore: rename issues to work items in russian translation

* [PE-275] chore: editor line spacing variables (#6678)

* chore: variable editor line spacing

* chore: variable list spacing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* [WEB-3475] fix: cycle dates dropdown (#6690)

* fix: Handled workspace switcher closing on click

* fix: Cycle date picker

* fix: Made onSelect optional in range range component

* fix: module date picker (#6691)

* fix: Handled workspace switcher closing on click

* fix: reverted module date picker changes

* chore: extended sidebar improvement (#6693)

* feat: italian translations (#6692)

* Create translations.json - ITALIAN translation (#6667)

* chore: italian translation updated

* feat: italian translation added

* fix: module end date translation

---------

Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>

* fix: attachment item created by (#6695)

* fix: module flicker issue on property updation (#6699)

* [WEB-3477] fix: mutation issue on moving work items for a manually ended cycle (#6696)

* fix: package version update

* fix: esbuild version fix

* fix: package license repliation

* [WEB-3488] improvement: assignee validation for work item creation (#6701)

* fix: work item assignee update validation (#6704)

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Nikita Mitasov <32384814+ch4og@users.noreply.github.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Akshat Jain <akshatjain9782@gmail.com>
Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-03-05 19:15:33 +05:30
sriram veeraghanta adeb7d977d Merge pull request #6665 from makeplane/canary
fix: package version update
2025-02-24 20:40:25 +05:30
akshat5302 4a7ecfe051 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-12-05 18:24:56 +05:30
Manish Gupta c5e5b99ee7 updated install.sh to not use release assets 2024-09-10 13:45:00 +05:30
Manish Gupta 5184ce608b updated branch-build 2024-09-10 12:44:31 +05:30
Manish Gupta f0ddcd7f05 Merge branch 'preview' of github.com:makeplane/plane into env-update 2024-09-10 12:41:38 +05:30
Akshat Jain 54a83ef5a1 add default value for CERT_EMAIL 2024-09-06 12:27:51 +05:30
Manish Gupta 7d4ec00f91 updated AIP 2024-09-03 15:51:02 +05:30
Manish Gupta 085fc16402 AIO updates for LIVE 2024-09-03 13:04:06 +05:30
Manish Gupta bae525eb29 selfhost fix for live 2024-09-03 12:56:43 +05:30
Manish Gupta 607ad3d5ba Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-09-03 12:30:00 +05:30
Manish Gupta ee50529f55 Update selfhost README 2024-09-03 10:50:00 +05:30
Manish Gupta 7b1df8ffdd updated selfhost README 2024-09-03 10:46:24 +05:30
Manish Gupta c8c7d4384d update install.sh 2024-08-29 19:02:07 +05:30
Manish Gupta e13c5619d5 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-08-29 16:00:58 +05:30
Manish Gupta 78edbc8dd6 updated build.yml 2024-08-29 16:00:00 +05:30
Manish Gupta 1968242c0d added release assets 2024-08-29 15:41:31 +05:30
Akshat Jain 83a6ba83b7 fixed typo changes 2024-08-28 16:47:45 +05:30
Akshat Jain f02e67a200 fixed envs 2024-08-28 16:22:14 +05:30
Akshat Jain 0741a00ed0 Merge branch 'env-update' of https://github.com/makeplane/plane into env-update 2024-08-28 15:32:33 +05:30
Akshat Jain a6f8d140ee fix: handling localhost as APP_DOMAIN 2024-08-28 15:31:51 +05:30
Akshat Jain 3d12305c6e Update variables.env 2024-08-28 15:27:20 +05:30
Akshat Jain da11073894 fix: handling localhost as APP_DOMAIN 2024-08-28 13:57:34 +05:30
Akshat Jain 99ab3386b5 added envs in variables file 2024-08-26 18:55:11 +05:30
Akshat Jain 779a9c0e47 added caddy setup for with or without SSL 2024-08-26 18:33:19 +05:30
Akshat Jain de2cb6baab Separated environment variables for specific app containers. 2024-08-20 12:57:24 +05:30
1166 changed files with 13876 additions and 14991 deletions
+2 -1
View File
@@ -2,6 +2,7 @@
*.pyc
.env
venv
.venv
node_modules/
**/node_modules/
npm-debug.log
@@ -14,4 +15,4 @@ build/
out/
**/out/
dist/
**/dist/
**/dist/
+41 -2
View File
@@ -117,6 +117,44 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- caddy/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
@@ -242,8 +280,8 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./nginx
dockerfile-path: ./nginx/Dockerfile
build-context: ./caddy
dockerfile-path: ./caddy/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
@@ -290,5 +328,6 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
+3 -3
View File
@@ -69,14 +69,14 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
5. Start web apps:
4. Start web apps:
```bash
yarn dev
```
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
+4 -6
View File
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<Breadcrumbs.Item
component={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.BreadcrumbItem
<Breadcrumbs.Item
key={item.title}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
component={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAuthErrorInfo } from "@plane/constants";
import { TAdminAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.0",
"version": "0.26.1",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -31,7 +31,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.28",
"next": "14.2.30",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -50,6 +50,6 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.3.3"
"typescript": "5.8.3"
}
}
+8 -2
View File
@@ -11,7 +11,7 @@ WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --docker
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
@@ -53,7 +53,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=web --filter=space --filter=admin
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
# *****************************************************************************
# STAGE 3: Copy the project and start it
@@ -87,6 +87,8 @@ RUN chmod +x ./api/bin/*
RUN chmod -R 777 ./api/
# NEXTJS BUILDS
COPY --from=installer /app/node_modules ./node_modules/
COPY --from=installer /app/web/next.config.js ./web/
COPY --from=installer /app/web/package.json ./web/
COPY --from=installer /app/web/.next/standalone ./web
@@ -105,6 +107,10 @@ COPY --from=installer /app/admin/.next/standalone ./admin
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
COPY --from=installer /app/admin/public ./admin/admin/public
COPY --from=installer /app/live/package.json ./live/
COPY --from=installer /app/live/dist ./live/dist
# COPY --from=installer /app/live/node_modules ./live/node_modules
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
+8
View File
@@ -45,6 +45,14 @@ http {
proxy_pass http://localhost:3003/god-mode/;
}
location /live/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3004/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
+10
View File
@@ -29,6 +29,16 @@ stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3003,HOSTNAME=0.0.0.0
[program:live]
command=node /app/live/dist/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3004,HOSTNAME=0.0.0.0,API_BASE_URL="http://localhost:8000"
[program:migrator]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-migrator.sh"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.0",
"version": "0.26.1",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+7 -1
View File
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -692,6 +692,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get("created_by", request.user.id)
@@ -719,6 +722,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
+5 -2
View File
@@ -148,10 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project")
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
class ProjectMemberInviteSerializer(BaseSerializer):
+5
View File
@@ -110,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer):
workspace_member__member=obj.id,
workspace_member__is_active=True,
).first()
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
return {
"last_workspace_id": profile.last_workspace_id,
"last_workspace_slug": (
workspace.slug if workspace is not None else ""
),
"last_workspace_name": (
workspace.name if workspace is not None else ""
),
"last_workspace_logo": (logo_asset_url),
"fallback_workspace_id": profile.last_workspace_id,
"fallback_workspace_slug": (
workspace.slug if workspace is not None else ""
+8 -3
View File
@@ -1,7 +1,5 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"workspaces/<str:slug>/api-tokens/",
"users/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
"users/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
+19
View File
@@ -12,6 +12,9 @@ from plane.app.views import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -81,5 +84,21 @@ urlpatterns = [
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
ProjectBulkAssetEndpoint.as_view(),
name="bulk-asset-update",
),
path(
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
name="workspace-asset-download",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
ProjectAssetDownloadEndpoint.as_view(),
name="project-asset-download",
),
]
+3
View File
@@ -106,6 +106,9 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+11 -19
View File
@@ -1,8 +1,10 @@
# Python import
from uuid import uuid4
from typing import Optional
# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
# Module import
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request) -> Response:
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None)
# Check the user type
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, pk=None):
def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
+64
View File
@@ -707,3 +707,67 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
pass
return Response(status=status.HTTP_204_NO_CONTENT)
class AssetCheckEndpoint(BaseAPIView):
"""Endpoint to check if an asset exists."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
asset = FileAsset.all_objects.filter(
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
class ProjectAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
+26 -1
View File
@@ -944,9 +944,33 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
project_member_subquery = ProjectMember.objects.filter(
project_id=OuterRef("project_id"),
member=self.request.user,
is_active=True,
).filter(
Q(role__gt=ROLE.GUEST.value)
| Q(
role=ROLE.GUEST.value, project__guest_view_all_features=True
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("workspace", "project", "state", "parent")
.filter(
Q(Exists(project_member_subquery))
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
)
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
@@ -1014,6 +1038,7 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
+16
View File
@@ -15,6 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import IssueLink
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
from plane.utils.host import base_host
@@ -44,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -55,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -66,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet):
current_instance = json.dumps(
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
)
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
@@ -80,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet):
notification=True,
origin=base_host(request=request, is_app=True),
)
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+8 -2
View File
@@ -341,7 +341,10 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -350,7 +353,10 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT,
)
+7 -1
View File
@@ -168,6 +168,8 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug,
member__is_bot=False,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
@@ -313,7 +315,11 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug, member_id=request.user.id, is_active=True
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).values("project_id", "role")
project_members = {
+12 -2
View File
@@ -3,6 +3,7 @@ import csv
import io
import os
from datetime import date
import uuid
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
@@ -35,6 +36,7 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -157,8 +159,18 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
# Get the workspace
workspace = self.get_object()
self.remove_last_workspace_ids_from_user_settings(workspace.id)
return super().destroy(request, *args, **kwargs)
@@ -166,8 +178,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
@@ -1,5 +1,6 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules
@@ -133,7 +134,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
).update(is_active=False, updated_at=timezone.now())
workspace_member.is_active = False
workspace_member.save()
@@ -194,7 +195,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
).update(is_active=False, updated_at=timezone.now())
# # Deactivate the user
workspace_member.is_active = False
@@ -284,6 +284,7 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
@@ -0,0 +1,177 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
soup = None
title = None
try:
response = requests.get(url, headers=headers, timeout=1)
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except Exception as e:
log_exception(e)
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
if soup is not None:
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
log_exception(e)
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
) -> Dict[str, Optional[str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
logger.warning(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
@@ -0,0 +1,23 @@
# Generated by Django 4.2.21 on 2025-06-06 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0096_user_is_email_valid_user_masked_at'),
]
operations = [
migrations.AddField(
model_name='project',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='project',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
+3
View File
@@ -122,6 +122,9 @@ class Project(BaseModel):
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
@property
def cover_image_url(self):
+45 -3
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User"
"last_name": "User",
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"]
last_name=user_data["last_name"],
)
user.set_password(user_data["password"])
user.save()
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server
@@ -0,0 +1,372 @@
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(
self, session_client, create_user, api_token_data
):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(
self, session_client, create_bot_user, api_token_data
):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(
self, session_client, create_user, create_api_token_for_user
):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert (
"token" not in response.data
) # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(
self, session_client, create_user, create_api_token_for_user
):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,75 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.21
Django==4.2.22
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.2
requests==2.32.4
@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on issue</title>
<title>Updates on {{entity_type}}</title>
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
@@ -37,7 +37,7 @@
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
{% endif %} <!-- Outer update Box start --> {% if data %}
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
<!-- Block Heading -->
@@ -224,7 +224,7 @@
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
</a>
</div>
<!-- Footer -->
@@ -232,7 +232,7 @@
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
+34
View File
@@ -0,0 +1,34 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
}
reverse_proxy /spaces/* space:3000
reverse_proxy /god-mode/* admin:3000
reverse_proxy /live/* live:3000
reverse_proxy /api/* api:8000
reverse_proxy /auth/* api:8000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
reverse_proxy /* web:3000
}
{
email {$CERT_EMAIL:admin@example.com}
acme_ca {$CERT_ACME_CA}
{$CERT_ACME_DNS}
servers {
max_header_size 5MB
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
}
{$SITE_ADDRESS} {
import plane_proxy
}
+9
View File
@@ -0,0 +1,9 @@
FROM makeplane/caddy:latest
COPY ./Caddyfile.template /etc/caddy/Caddyfile
COPY ./caddy.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
if [ "$APP_DOMAIN" == "localhost" ]; then
export SITE_ADDRESS=":${LISTEN_HTTP_PORT}"
elif [ "$SSL" == "true" ]; then
export SITE_ADDRESS="${APP_DOMAIN}:${LISTEN_HTTPS_PORT}"
else
export SITE_ADDRESS="http://${APP_DOMAIN}:${LISTEN_HTTP_PORT}"
fi
exec caddy run --config /etc/caddy/Caddyfile
+46 -7
View File
@@ -58,7 +58,7 @@ Installing plane is a very easy and minimal step process.
### Downloading Latest Release
```
mkdir plane-selfhost
mkdir -p plane-selfhost && cd plane-selfhost
cd plane-selfhost
```
@@ -144,11 +144,15 @@ Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
Before proceeding, we suggest used to review `.env` file and set the values.
Below are the most import keys you must refer to. _<span style="color: #fcba03">You can use any text editor to edit this file</span>_.
> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
> `APP_DOMAIN` - Set the Fully Qualified Domain Name here. (eg. `plane.example.com`)
>
> `LISTEN_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_PORT=8080`)
>
> `LISTEN_SSL_PORT` - This is default set to `443`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_SSL_PORT=8443`)
>
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_PORT/LISTEN_SSL_PORT (eg. `https://plane.example.com:8443` or `http://[IP-ADDRESS]:8080`)
>
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://${APP_DOMAIN},https://${APP_DOMAIN}`. Change this to the FQDN you plan to use along with LISTEN_PORT and LISTEN_SSL_PORT (eg. `http://plane.example.com:8080,https://plane.example.com:8443`)
There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.
@@ -172,6 +176,8 @@ Select a Action you want to perform:
Action [2]: 2
```
> You can also choose to run `./setup.sh start` as direct command.
Expect something like this.
![Downloading docker images](images/download.png)
@@ -207,6 +213,8 @@ Select a Action you want to perform:
Action [2]: 3
```
> You can also choose to run `./setup.sh stop` as direct command.
If all goes well, you must see something like this
![Stop Services](images/stopped.png)
@@ -253,6 +261,8 @@ Select a Action you want to perform:
Action [2]: 4
```
> You can also choose to run `./setup.sh restart` as direct command.
If all goes well, you must see something like this
![Restart Services](images/restart.png)
@@ -297,6 +307,8 @@ Select a Action you want to perform:
Action [2]: 5
```
> You can also choose to run `./setup.sh upgrade` as direct command.
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
You must expect the below message
@@ -465,6 +477,8 @@ Select a Action you want to perform:
Action [2]: 7
```
> You can also choose to run `./setup.sh backup` as direct command.
In response, you can find the backup folder
```bash
@@ -486,7 +500,7 @@ When you want to restore the previously backed-up data, follow the instructions
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
curl -fsSL -o restore.sh https://github.com/makeplane/plane/releases/latest/download/restore.sh
chmod +x restore.sh
```
@@ -529,6 +543,31 @@ When you want to restore the previously backed-up data, follow the instructions
---
### Restore for Commercial Air-Gapped (Docker Compose)
When you want to restore the previously backed-up data on Plane Commercial Air-Gapped version, follow the instructions below.
1. Download the restore script using the command below
```bash
curl -fsSL -o restore-airgapped.sh https://github.com/makeplane/plane/releases/latest/download/restore-airgapped.sh
chmod +x restore-airgapped.sh
```
1. Copy the backup folder and the `restore-airgapped.sh` to `Commercial Airgapped Edition` server
1. Make sure that Plane Commercial (Airgapped) is extracted and ready to get started. In case it is running, you would need to stop that.
1. Execute the command below to restore your data.
```bash
./restore-airgapped.sh <path to backup folder containing *.tar.gz files>
```
1. After restoration, you are ready to start Plane Commercial (Airgapped) will all your previously saved data.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
+7 -1
View File
@@ -17,6 +17,12 @@ services:
context: ./
dockerfile: ./admin/Dockerfile.admin
live:
image: ${DOCKERHUB_USER:-local}/plane-live:${APP_RELEASE:-latest}
build:
context: .
dockerfile: ./live/Dockerfile.live
api:
image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest}
build:
@@ -26,5 +32,5 @@ services:
proxy:
image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}
build:
context: ./nginx
context: ./caddy
dockerfile: ./Dockerfile
+29 -13
View File
@@ -24,9 +24,14 @@ x-aws-s3-env: &aws-s3-env
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
NGINX_PORT: ${NGINX_PORT:-80}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
SSL: ${SSL:-false}
APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
CERT_EMAIL: ${CERT_EMAIL:-admin@example.com}
CERT_ACME_CA: ${CERT_ACME_CA:-}
LISTEN_HTTP_PORT: ${LISTEN_PORT:-80}
LISTEN_HTTPS_PORT: ${LISTEN_SSL_PORT:-443}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-mq-env: &mq-env # RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
@@ -212,22 +217,31 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
ports:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
environment:
<<: *proxy-env
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE_VERSION}
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *proxy-env
ports:
- target: 80
published: ${LISTEN_HTTP_PORT:-80}
protocol: tcp
mode: host
- target: 443
published: ${LISTEN_HTTPS_PORT:-443}
protocol: tcp
mode: host
volumes:
- proxy_config:/config
- proxy_data:/data
depends_on:
- web
- api
- space
- web
- api
- space
- admin
- live
volumes:
pgdata:
@@ -237,4 +251,6 @@ volumes:
logs_worker:
logs_beat-worker:
logs_migrator:
caddy_config:
caddy_data:
rabbitmq_data:
+5 -2
View File
@@ -1,6 +1,7 @@
#!/bin/bash
BRANCH=${BRANCH:-master}
RELEASE_TAG=${RELEASE_TAG:-v0.22-dev}
SCRIPT_DIR=$PWD
SERVICE_FOLDER=plane-app
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
@@ -177,11 +178,13 @@ function syncEnvFile(){
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
fi
done < "$DOCKER_ENV_PATH"
# Replace APP_RELEASE with the latest value
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
fi
echo "Environment variables synced successfully" >&2
}
function buildYourOwnImage(){
function buildYourOwnImage() {
echo "Building images locally..."
export DOCKERHUB_USER="myplane"
@@ -423,7 +426,7 @@ function upgrade() {
stopServices
echo
echo "***** DOWNLOADING STABLE VERSION ****"
echo "***** DOWNLOADING $APP_RELEASE VERSION ****"
install
echo "***** PLEASE VALIDATE AND START SERVICES ****"
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
+set -euo pipefail
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreData() {
echo ""
echo "****************************************************"
echo "We are about to restore your data from the backup files."
echo "****************************************************"
echo ""
# set the backup folder path
BACKUP_FOLDER=${1}
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
read -p "Enter the backup folder path [$BACKUP_FOLDER]: " BACKUP_FOLDER
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
fi
fi
# check if the backup folder exists
if [ ! -d "$BACKUP_FOLDER" ]; then
echo "Error: Backup folder not found at $BACKUP_FOLDER"
exit 1
fi
# check if there are any .tar.gz files in the backup folder
if ! ls "$BACKUP_FOLDER"/*.tar.gz 1> /dev/null 2>&1; then
echo "Error: Backup folder does not contain .tar.gz files"
exit 1
fi
echo ""
echo "Using backup folder: $BACKUP_FOLDER"
echo ""
# ask for current install path
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
read -p "Enter the airgapped instance install path [$AIRGAPPED_INSTALL_PATH]: " AIRGAPPED_INSTALL_PATH
if [ -z "$AIRGAPPED_INSTALL_PATH" ]; then
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
fi
# check if the airgapped instance install path exists
if [ ! -d "$AIRGAPPED_INSTALL_PATH" ]; then
echo "Error: Airgapped instance install path not found at $AIRGAPPED_INSTALL_PATH"
exit 1
fi
echo ""
echo "Using airgapped instance install path: $AIRGAPPED_INSTALL_PATH"
echo ""
# check if the docker-compose.yaml exists
if [ ! -f "$AIRGAPPED_INSTALL_PATH/docker-compose.yml" ]; then
echo "Error: docker-compose.yml not found at $AIRGAPPED_INSTALL_PATH/docker-compose.yml"
exit 1
fi
local dockerServiceStatus
if command -v jq &> /dev/null; then
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped --format=json | jq -r .[0].Status)
else
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped | grep -o "running" | head -n 1)
fi
if [[ $dockerServiceStatus == "running" ]]; then
echo "Plane Airgapped is running. Please STOP the Plane Airgapped before restoring data."
exit 1
fi
CURRENT_USER_ID=$(id -u)
CURRENT_GROUP_ID=$(id -g)
# if the data folder not exists, create it
if [ ! -d "$AIRGAPPED_INSTALL_PATH/data" ]; then
mkdir -p "$AIRGAPPED_INSTALL_PATH/data"
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data"
fi
for BACKUP_FILE in "$BACKUP_FOLDER/*.tar.gz"; do
if [ -e "$BACKUP_FILE" ]; then
# get the basefilename without the extension
BASE_FILE_NAME=$(basename "$BACKUP_FILE" ".tar.gz")
# extract the restoreFile to the airgapped instance install path
echo "Restoring $BASE_FILE_NAME"
rm -rf "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME" || true
tar -xvzf "$BACKUP_FILE" -C "$AIRGAPPED_INSTALL_PATH/data/"
if [ $? -ne 0 ]; then
echo "Error: Failed to extract $BACKUP_FILE"
exit 1
fi
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
if [ $? -ne 0 ]; then
echo "Error: Failed to change ownership of $AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
exit 1
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: $0 /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
print_header
restoreData "$@"
+9 -2
View File
@@ -1,5 +1,6 @@
APP_DOMAIN=localhost
APP_RELEASE=stable
SSL=false
WEB_REPLICAS=1
SPACE_REPLICAS=1
@@ -9,10 +10,11 @@ WORKER_REPLICAS=1
BEAT_WORKER_REPLICAS=1
LIVE_REPLICAS=1
NGINX_PORT=80
LISTEN_PORT=80
LISTEN_SSL_PORT=443
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
API_BASE_URL=http://api:8000
#DB SETTINGS
@@ -30,6 +32,11 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=
# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https
CERT_EMAIL=
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
TRUSTED_PROXIES=0.0.0.0/0
# RabbitMQ Settings
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.26.0",
"version": "0.26.1",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
@@ -58,6 +58,6 @@
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"typescript": "5.3.3"
"typescript": "5.8.3"
}
}
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.26.0",
"version": "0.26.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -24,14 +24,16 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.5.3"
"turbo": "^2.5.4"
},
"resolutions": {
"brace-expansion": "2.0.2",
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0"
"chokidar": "3.6.0",
"tar-fs": "3.0.9"
},
"packageManager": "yarn@1.22.22"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.26.0",
"version": "0.26.1",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
@@ -1,105 +0,0 @@
import { TAnalyticsTabsV2Base } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export const insightsFields: Record<TAnalyticsTabsV2Base, string[]> = {
overview: [
"total_users",
"total_admins",
"total_members",
"total_guests",
"total_projects",
"total_work_items",
"total_cycles",
"total_intake",
],
"work-items": [
"total_work_items",
"started_work_items",
"backlog_work_items",
"un_started_work_items",
"completed_work_items",
],
};
export const ANALYTICS_V2_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_V2_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_V2_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
-81
View File
@@ -1,81 +0,0 @@
// types
import { TXAxisValues, TYAxisValues } from "@plane/types";
export const ANALYTICS_TABS = [
{
key: "scope_and_demand",
i18n_title: "workspace_analytics.tabs.scope_and_demand",
},
{ key: "custom", i18n_title: "workspace_analytics.tabs.custom" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
[
{
value: "state_id",
label: "State name",
},
{
value: "state__group",
label: "State group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__id",
label: "Label",
},
{
value: "assignees__id",
label: "Assignee",
},
{
value: "estimate_point__value",
label: "Estimate point",
},
{
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module_id",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
[
{
value: "issue_count",
label: "Work item Count",
},
{
value: "estimate",
label: "Estimate",
},
];
export const ANALYTICS_DATE_KEYS = [
"completed_at",
"target_date",
"start_date",
"created_at",
];
+178
View File
@@ -0,0 +1,178 @@
import { TAnalyticsTabsBase } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export interface IInsightField {
key: string;
i18nKey: string;
i18nProps?: {
entity?: string;
entityPlural?: string;
[key: string]: any;
};
}
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
overview: [
{
key: "total_users",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.users",
},
},
{
key: "total_admins",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.admins",
},
},
{
key: "total_members",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.members",
},
},
{
key: "total_guests",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.guests",
},
},
{
key: "total_projects",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.projects",
},
},
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.work_items",
},
},
{
key: "total_cycles",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.cycles",
},
},
{
key: "total_intake",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "sidebar.intake",
},
},
],
"work-items": [
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
},
{
key: "started_work_items",
i18nKey: "workspace_analytics.started_work_items",
},
{
key: "backlog_work_items",
i18nKey: "workspace_analytics.backlog_work_items",
},
{
key: "un_started_work_items",
i18nKey: "workspace_analytics.un_started_work_items",
},
{
key: "completed_work_items",
i18nKey: "workspace_analytics.completed_work_items",
},
],
};
export const ANALYTICS_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
+8 -1
View File
@@ -69,7 +69,7 @@ export enum EErrorAlertType {
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
code: EAuthErrorCodes;
title: string;
message: any;
};
@@ -87,6 +87,13 @@ export enum EAdminAuthErrorCodes {
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
+7 -9
View File
@@ -1,28 +1,26 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
@@ -1,4 +1,4 @@
// types
// plane imports
import { TEstimateSystems } from "@plane/types";
export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20;
+4 -3
View File
@@ -1,5 +1,4 @@
export * from "./ai";
export * from "./analytics";
export * from "./auth";
export * from "./chart";
export * from "./endpoints";
@@ -22,7 +21,7 @@ export * from "./module";
export * from "./project";
export * from "./views";
export * from "./themes";
export * from "./inbox";
export * from "./intake";
export * from "./profile";
export * from "./workspace-drafts";
export * from "./label";
@@ -32,5 +31,7 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./analytics-v2";
export * from "./estimates";
export * from "./analytics";
@@ -95,3 +95,32 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [
i18n_label: "common.sort.desc",
},
];
export enum EPastDurationFilters {
TODAY = "today",
YESTERDAY = "yesterday",
LAST_7_DAYS = "last_7_days",
LAST_30_DAYS = "last_30_days",
}
export const PAST_DURATION_FILTER_OPTIONS: {
name: string;
value: string;
}[] = [
{
name: "Today",
value: EPastDurationFilters.TODAY,
},
{
name: "Yesterday",
value: EPastDurationFilters.YESTERDAY,
},
{
name: "Last 7 days",
value: EPastDurationFilters.LAST_7_DAYS,
},
{
name: "Last 30 days",
value: EPastDurationFilters.LAST_30_DAYS,
},
];
+1 -39
View File
@@ -136,45 +136,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: [
"state",
"cycle",
"module",
"state_detail.group",
"priority",
"labels",
"assignees",
"created_by",
null,
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
},
draft_issues: {
list: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
kanban: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"],
group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
+18 -11
View File
@@ -1,9 +1,16 @@
// types
import {
TModuleLayoutOptions,
TModuleOrderByOptions,
TModuleStatus,
} from "@plane/types";
import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types";
export const MODULE_STATUS_COLORS: {
[key in TModuleStatus]: string;
} = {
backlog: "#a3a3a2",
planned: "#3f76ff",
paused: "#525252",
completed: "#16a34a",
cancelled: "#ef4444",
"in-progress": "#f39e1f",
};
export const MODULE_STATUS: {
i18n_label: string;
@@ -15,42 +22,42 @@ export const MODULE_STATUS: {
{
i18n_label: "project_modules.status.backlog",
value: "backlog",
color: "#a3a3a2",
color: MODULE_STATUS_COLORS.backlog,
textColor: "text-custom-text-400",
bgColor: "bg-custom-background-80",
},
{
i18n_label: "project_modules.status.planned",
value: "planned",
color: "#3f76ff",
color: MODULE_STATUS_COLORS.planned,
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
},
{
i18n_label: "project_modules.status.in_progress",
value: "in-progress",
color: "#f39e1f",
color: MODULE_STATUS_COLORS["in-progress"],
textColor: "text-amber-500",
bgColor: "bg-amber-50",
},
{
i18n_label: "project_modules.status.paused",
value: "paused",
color: "#525252",
color: MODULE_STATUS_COLORS.paused,
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",
},
{
i18n_label: "project_modules.status.completed",
value: "completed",
color: "#16a34a",
color: MODULE_STATUS_COLORS.completed,
textColor: "text-green-600",
bgColor: "bg-green-100",
},
{
i18n_label: "project_modules.status.cancelled",
value: "cancelled",
color: "#ef4444",
color: MODULE_STATUS_COLORS.cancelled,
textColor: "text-red-500",
bgColor: "bg-red-50",
},
+7 -7
View File
@@ -72,23 +72,23 @@ export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
unit_amount: 15600,
recurring: "year",
currency: "usd",
workspace_amount: 0,
workspace_amount: 15600,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
unit_amount: 1500,
recurring: "month",
currency: "usd",
workspace_amount: 0,
workspace_amount: 1500,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: false,
is_active: true,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
@@ -141,8 +141,8 @@ export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Rec
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
month: "https://app.plane.so/upgrade/business/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/business/self-hosted?plan=year",
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
+61 -30
View File
@@ -1,39 +1,53 @@
export const PROFILE_SETTINGS = {
profile: {
key: "profile",
i18n_label: "profile.actions.profile",
href: `/settings/account`,
highlight: (pathname: string) => pathname === "/settings/account/",
},
security: {
key: "security",
i18n_label: "profile.actions.security",
href: `/settings/account/security`,
highlight: (pathname: string) => pathname === "/settings/account/security/",
},
activity: {
key: "activity",
i18n_label: "profile.actions.activity",
href: `/settings/account/activity`,
highlight: (pathname: string) => pathname === "/settings/account/activity/",
},
preferences: {
key: "preferences",
i18n_label: "profile.actions.preferences",
href: `/settings/account/preferences`,
highlight: (pathname: string) => pathname === "/settings/account/preferences",
},
notifications: {
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/settings/account/notifications`,
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
},
"api-tokens": {
key: "api-tokens",
i18n_label: "profile.actions.api-tokens",
href: `/settings/account/api-tokens`,
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
},
};
export const PROFILE_ACTION_LINKS: {
key: string;
i18n_label: string;
href: string;
highlight: (pathname: string) => boolean;
}[] = [
{
key: "profile",
i18n_label: "profile.actions.profile",
href: `/profile`,
highlight: (pathname: string) => pathname === "/profile/",
},
{
key: "security",
i18n_label: "profile.actions.security",
href: `/profile/security`,
highlight: (pathname: string) => pathname === "/profile/security/",
},
{
key: "activity",
i18n_label: "profile.actions.activity",
href: `/profile/activity`,
highlight: (pathname: string) => pathname === "/profile/activity/",
},
{
key: "appearance",
i18n_label: "profile.actions.appearance",
href: `/profile/appearance`,
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
},
{
key: "notifications",
i18n_label: "profile.actions.notifications",
href: `/profile/notifications`,
highlight: (pathname: string) => pathname === "/profile/notifications/",
},
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["api-tokens"],
];
export const PROFILE_VIEWER_TAB = [
@@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [
},
];
export const PREFERENCE_OPTIONS: {
id: string;
title: string;
description: string;
}[] = [
{
id: "theme",
title: "theme",
description: "select_or_customize_your_interface_color_scheme",
},
{
id: "start_of_week",
title: "First day of the week",
description: "This will change how all calendars in your app look.",
},
];
/**
* @description The start of the week for the user
* @enum {number}
+9
View File
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
network: 2,
project_lead: null,
};
export enum EProjectFeatureKey {
WORK_ITEMS = "work_items",
CYCLES = "cycles",
MODULES = "modules",
VIEWS = "views",
PAGES = "pages",
INTAKE = "intake",
}
+52
View File
@@ -0,0 +1,52 @@
import { PROFILE_SETTINGS } from ".";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
ADMINISTRATION = "administration",
FEATURES = "features",
DEVELOPER = "developer",
}
export enum PROFILE_SETTINGS_CATEGORY {
YOUR_PROFILE = "your profile",
DEVELOPER = "developer",
}
export enum PROJECT_SETTINGS_CATEGORY {
PROJECTS = "projects",
}
export const WORKSPACE_SETTINGS_CATEGORIES = [
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROFILE_SETTINGS_CATEGORIES = [
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
];
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
export const GROUPED_WORKSPACE_SETTINGS = {
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
WORKSPACE_SETTINGS["general"],
WORKSPACE_SETTINGS["members"],
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
};
export const GROUPED_PROFILE_SETTINGS = {
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
],
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
};
+1
View File
@@ -1,4 +1,5 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
-8
View File
@@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = {
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
@@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: {
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["webhooks"],
WORKSPACE_SETTINGS["api-tokens"],
];
export const ROLE = {
+1 -1
View File
@@ -28,7 +28,7 @@
"@types/reflect-metadata": "^0.1.0",
"@types/ws": "^8.5.10",
"tsup": "8.4.0",
"typescript": "^5.3.3"
"typescript": "5.8.3"
},
"peerDependencies": {
"express": ">=4.21.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.26.0",
"version": "0.26.1",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -82,7 +82,7 @@
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "8.4.0",
"typescript": "5.3.3"
"typescript": "5.8.3"
},
"keywords": [
"editor",
@@ -1,13 +1,13 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions, TFileHandler } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
const {} = props;
return [];
};
@@ -1,12 +1,15 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
const {} = props;
return [];
};
@@ -1,36 +1,39 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
import type { TEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TUserDetails } from "@/types";
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
};
const extensionRegistry: ExtensionConfig[] = [
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({}),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
},
];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
};
@@ -0,0 +1,42 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { IEditorProps, TExtensions } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -0,0 +1,31 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { IReadOnlyEditorProps, TExtensions } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [];
export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -1,11 +1,9 @@
// extensions
import { TSlashCommandAdditionalOption } from "@/extensions";
import type { TSlashCommandAdditionalOption } from "@/extensions";
// types
import { TExtensions } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
};
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const {
onChange,
onTransaction,
aiHandler,
bubbleMenuEnabled = true,
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
onTransaction,
placeholder,
realtimeConfig,
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
// types
import { TAIHandler, TDisplayConfig } from "@/types";
type IPageRenderer = {
type Props = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
@@ -15,7 +15,7 @@ type IPageRenderer = {
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
export const PageRenderer = (props: Props) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
import React, { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: TReadOnlyFileHandler;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
const {
containerClassName,
disabledExtensions,
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// Check if the last node is a not paragraph
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
// If last node is not a paragraph, insert a new paragraph at the end
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
// Check if its last node and add new node
if (lastNode) {
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
}
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
id,
initialValue,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditor } from "@/types";
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
const LiteTextEditor = (props: ILiteTextEditor) => {
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(
() => [
...externalExtensions,
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
],
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
// components
import { ReadOnlyEditorWrapper } from "@/components/editors";
// types
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -15,7 +15,9 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -25,7 +27,9 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
@@ -3,12 +3,21 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension, SlashCommands } from "@/extensions";
import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
import { EditorRefApi, IRichTextEditorProps } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
} = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -17,17 +26,15 @@ const RichTextEditor = (props: IRichTextEditor) => {
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
}),
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -36,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -1,11 +1,32 @@
import { forwardRef } from "react";
import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
const getExtensions = useCallback(() => {
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
});
return extensions;
}, [disabledExtensions, fileHandler, flaggedExtensions]);
return (
<ReadOnlyEditorWrapper
{...props}
extensions={getExtensions()}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
);
});
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
@@ -86,6 +86,10 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
[editor]
);
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
alert(message);
}, []);
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
@@ -94,18 +98,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onInvalidFile: handleInvalidFile,
onUpload,
});
const handleInvalidFile = useCallback((_error: EFileError, message: string) => {
alert(message);
}, []);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
onInvalidFile: handleInvalidFile,
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -140,11 +138,8 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return;
}
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
filesList,
maxFileSize,
onInvalidFile: (_error, message) => alert(message),
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -37,20 +37,27 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import type { IEditorProps } from "@/types";
type TArguments = {
disabledExtensions: TExtensions[];
type TArguments = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
> & {
enableHistory: boolean;
fileHandler: TFileHandler;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
const {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const extensions = [
StarterKit.configure({
@@ -170,12 +177,14 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension,
CustomCalloutExtension,
UtilityExtension({
isEditable: editable,
disabledExtensions,
fileHandler,
isEditable: editable,
}),
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
fileHandler,
}),
];
@@ -31,16 +31,12 @@ import { isValidHttpUrl } from "@/helpers/common";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
mentionHandler: TReadOnlyMentionHandler;
};
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const extensions = [
StarterKit.configure({
@@ -127,11 +123,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
UtilityExtension({
isEditable: false,
disabledExtensions,
fileHandler,
isEditable: false,
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
}),
];
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -290,6 +290,7 @@ export const getSlashCommandFilteredSections =
...(externalAdditionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
flaggedExtensions,
}),
]?.forEach((item) => {
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
@@ -7,7 +7,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -106,9 +106,8 @@ const renderItems = () => {
};
};
export type TExtensionProps = {
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions?: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" {
interface Commands {
@@ -23,14 +23,14 @@ export interface UtilityExtensionStorage {
uploadInProgress: boolean;
}
type Props = {
type Props = Pick<IEditorProps, "disabledExtensions"> & {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const UtilityExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
const { restore: restoreImageFn } = fileHandler;
const { disabledExtensions, fileHandler, isEditable } = props;
const { restore } = fileHandler;
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
name: "utility",
@@ -45,12 +45,15 @@ export const UtilityExtension = (props: Props) => {
}),
...codemark({ markType: this.editor.schema.marks.code }),
MarkdownClipboardPlugin(this.editor),
DropHandlerPlugin(this.editor),
DropHandlerPlugin({
disabledExtensions,
editor: this.editor,
}),
];
},
onCreate() {
restorePublicImages(this.editor, restoreImageFn);
restorePublicImages(this.editor, restore);
},
addStorage() {
@@ -0,0 +1,89 @@
// plane imports
import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
import { TEditorAssetType } from "@plane/types/src/enums";
// local imports
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
/**
* @description function to extract all image assets from HTML content
* @param htmlContent
* @returns {string[]} array of image asset sources
*/
export const extractImageAssetsFromHTMLContent = (htmlContent: string): string[] => {
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// get all image components
const imageComponents = doc.querySelectorAll("image-component");
// collect all unique image sources
const imageSources = new Set<string>();
// extract sources from image components
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) imageSources.add(src);
});
return Array.from(imageSources);
};
/**
* @description function to replace image assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced image assets
*/
export const replaceImageAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent, assetMap } = props;
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// replace sources in image components
const imageComponents = doc.querySelectorAll("image-component");
imageComponents.forEach((component) => {
const oldSrc = component.getAttribute("src");
if (oldSrc && assetMap[oldSrc]) {
component.setAttribute("src", assetMap[oldSrc]);
}
});
// serialize the document back into a string
return doc.body.innerHTML;
};
export const getEditorContentWithReplacedImageAssets = async (props: {
descriptionHTML: string;
entityId: string;
entityType: TEditorAssetType;
projectId: string | undefined;
variant: "rich" | "document";
duplicateAssetService: (params: TDuplicateAssetData) => Promise<TDuplicateAssetResponse>;
}): Promise<TDocumentPayload> => {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
if (imageAssets.length !== 0) {
// step 2: duplicate the image assets
const duplicateAssetsResponse = await duplicateAssetService({
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
asset_ids: imageAssets,
});
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
// step 3: replace the image assets in the description
replacedDescription = replaceImageAssetsInHTMLContent({
htmlContent: descriptionHTML,
assetMap: duplicateAssetsResponse,
});
}
}
// step 4: convert the description to the document payload
const documentPayload = convertHTMLDocumentToAllFormats({
document_html: replacedDescription,
variant,
});
return documentPayload;
};
@@ -3,6 +3,7 @@ import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// extensions
import { TDocumentPayload } from "@plane/types";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
@@ -140,3 +141,50 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
contentHTML,
};
};
type TConvertHTMLDocumentToAllFormatsArgs = {
document_html: string;
variant: "rich" | "document";
};
/**
* @description Converts HTML content to all supported document formats (JSON, HTML, and binary)
* @param {TConvertHTMLDocumentToAllFormatsArgs} args - Arguments containing HTML content and variant type
* @param {string} args.document_html - The HTML content to convert
* @param {"rich" | "document"} args.variant - The type of editor variant to use for conversion
* @returns {TDocumentPayload} Object containing the document in all supported formats
* @throws {Error} If an invalid variant is provided
*/
export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllFormatsArgs): TDocumentPayload => {
const { document_html, variant } = args;
let allFormats: TDocumentPayload;
if (variant === "rich") {
// Convert HTML to binary format for rich text editor
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}
return allFormats;
};
@@ -9,18 +9,20 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TCollaborativeEditorProps } from "@/types";
import { TCollaborativeEditorHookProps } from "@/types";
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
const {
onChange,
onTransaction,
disabledExtensions,
editable,
editorClassName,
editorClassName = "",
editorProps = {},
embedHandler,
extensions,
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -89,18 +91,22 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
Collaboration.configure({
document: provider.document,
}),
...(extensions ?? []),
...extensions,
...DocumentEditorAdditionalExtensions({
disabledExtensions,
issueEmbedConfig: embedHandler?.issue,
embedConfig: embedHandler,
fileHandler,
flaggedExtensions,
provider,
userDetails: user,
}),
],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
mentionHandler,
onChange,
onTransaction,
placeholder,
provider,
+13 -43
View File
@@ -1,13 +1,12 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -18,49 +17,19 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
// props
import { CoreEditorProps } from "@/props";
// types
import type {
TDocumentEventsServer,
EditorRefApi,
TEditorCommands,
TFileHandler,
TExtensions,
TMentionHandler,
} from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
export interface CustomEditorProps {
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
disabledExtensions: TExtensions[];
extensions?: Extensions;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
initialValue?: string;
mentionHandler: TMentionHandler;
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
autofocus?: boolean;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = (props: CustomEditorProps) => {
export const useEditor = (props: TEditorHookProps) => {
const {
autofocus = false,
disabledExtensions,
editable = true,
editorClassName,
editorClassName = "",
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id = "",
@@ -69,10 +38,9 @@ export const useEditor = (props: CustomEditorProps) => {
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
provider,
autofocus = false,
} = props;
const editor = useTiptapEditor(
@@ -81,6 +49,7 @@ export const useEditor = (props: CustomEditorProps) => {
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreEditorProps({
editorClassName,
@@ -93,6 +62,7 @@ export const useEditor = (props: CustomEditorProps) => {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
@@ -119,7 +89,7 @@ export const useEditor = (props: CustomEditorProps) => {
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
if (!editor.isDestroyed && !isUploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
editor.commands.setContent(value, false, { preserveWhitespace: true });
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
@@ -153,7 +123,7 @@ export const useEditor = (props: CustomEditorProps) => {
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor?.state.selection) {
@@ -9,11 +9,11 @@ import { TEditorCommands } from "@/types";
type TUploaderArgs = {
acceptedMimeTypes: string[];
editorCommand: (file: File) => Promise<string>;
editorCommand: (file: File) => Promise<string | undefined>;
handleProgressStatus?: (isUploading: boolean) => void;
loadFileFromFileSystem?: (file: string) => void;
maxFileSize: number;
onInvalidFile: (error: EFileError, message: string) => void;
onInvalidFile: (error: EFileError, file: File, message: string) => void;
onUpload: (url: string, file: File) => void;
};
@@ -38,7 +38,7 @@ export const useUploader = (args: TUploaderArgs) => {
acceptedMimeTypes,
file,
maxFileSize,
onError: onInvalidFile,
onError: (error, message) => onInvalidFile(error, file, message),
});
if (!isValid) {
handleProgressStatus?.(false);
@@ -60,7 +60,7 @@ export const useUploader = (args: TUploaderArgs) => {
};
reader.readAsDataURL(file);
}
const url: string = await editorCommand(file);
const url = await editorCommand(file);
if (!url) {
throw new Error("Something went wrong while uploading the file.");
@@ -89,17 +89,14 @@ export const useUploader = (args: TUploaderArgs) => {
};
type TDropzoneArgs = {
acceptedMimeTypes: string[];
editor: Editor;
maxFileSize: number;
onInvalidFile: (error: EFileError, message: string) => void;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
export const useDropZone = (args: TDropzoneArgs) => {
const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args;
const { editor, pos, type, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -126,22 +123,21 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
const filesList = e.dataTransfer.files;
if (filesList.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes,
editor,
filesList,
maxFileSize,
onInvalidFile,
pos,
type,
uploader,
});
},
[acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader]
[editor, pos, type, uploader]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);
@@ -156,11 +152,8 @@ export const useDropZone = (args: TDropzoneArgs) => {
};
type TMultipleFileArgs = {
acceptedMimeTypes: string[];
editor: Editor;
filesList: FileList;
maxFileSize: number;
onInvalidFile: (error: EFileError, message: string) => void;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
@@ -168,35 +161,18 @@ type TMultipleFileArgs = {
// Upload the first file and insert the remaining ones for uploading multiple files
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
const { acceptedMimeTypes, editor, filesList, maxFileSize, onInvalidFile, pos, type, uploader } = args;
const filteredFiles: File[] = [];
for (let i = 0; i < filesList.length; i += 1) {
const file = filesList.item(i);
if (
file &&
isFileValid({
acceptedMimeTypes,
file,
maxFileSize,
onError: onInvalidFile,
})
) {
filteredFiles.push(file);
}
}
if (filteredFiles.length !== filesList.length) {
console.warn("Some files were invalid and have been ignored.");
}
if (filteredFiles.length === 0) {
const { editor, filesList, pos, type, uploader } = args;
const filesArray = Array.from(filesList);
if (filesArray.length === 0) {
console.error("No files found to upload.");
return;
}
// Upload the first file
const firstFile = filteredFiles[0];
const firstFile = filesArray[0];
uploader(firstFile);
// Insert the remaining files
const remainingFiles = filteredFiles.slice(1);
const remainingFiles = filesArray.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
@@ -1,8 +1,8 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import * as Y from "yjs";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
@@ -11,32 +11,19 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
import type { TReadOnlyEditorHookProps } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
initialValue?: string;
fileHandler: TReadOnlyFileHandler;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
provider?: HocuspocusProvider;
}
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
const {
disabledExtensions,
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorClassName = "",
editorProps = {},
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
provider,
} = props;
@@ -46,6 +33,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreReadOnlyEditorProps({
editorClassName,
@@ -58,8 +46,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
extensions: [
...CoreReadOnlyEditorExtensions({
disabledExtensions,
mentionHandler,
fileHandler,
flaggedExtensions,
mentionHandler,
}),
...extensions,
],
@@ -71,7 +60,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
}, [editor, initialValue]);
useImperativeHandle(forwardedRef, () => ({
@@ -79,7 +68,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
@@ -1,7 +1,6 @@
import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
import { EditorView } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
@@ -417,7 +416,7 @@ const handleNodeSelection = (
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
const { dom, text } = view.serializeForClipboard(slice);
if (event instanceof DragEvent && event.dataTransfer) {
event.dataTransfer.clearData();
+16 -5
View File
@@ -3,10 +3,17 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// types
import { TEditorCommands } from "@/types";
import { TEditorCommands, TExtensions } from "@/types";
export const DropHandlerPlugin = (editor: Editor): Plugin =>
new Plugin({
type Props = {
disabledExtensions?: TExtensions[];
editor: Editor;
};
export const DropHandlerPlugin = (props: Props): Plugin => {
const { disabledExtensions, editor } = props;
return new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
@@ -25,6 +32,7 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
if (acceptedFiles.length) {
const pos = view.state.selection.from;
insertFilesSafely({
disabledExtensions,
editor,
files: acceptedFiles,
initialPos: pos,
@@ -58,6 +66,7 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
if (coordinates) {
const pos = coordinates.pos;
insertFilesSafely({
disabledExtensions,
editor,
files: acceptedFiles,
initialPos: pos,
@@ -71,8 +80,10 @@ export const DropHandlerPlugin = (editor: Editor): Plugin =>
},
},
});
};
type InsertFilesSafelyArgs = {
disabledExtensions?: TExtensions[];
editor: Editor;
event: "insert" | "drop";
files: File[];
@@ -81,7 +92,7 @@ type InsertFilesSafelyArgs = {
};
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
const { editor, event, files, initialPos, type } = args;
const { disabledExtensions, editor, event, files, initialPos, type } = args;
let pos = initialPos;
for (const file of files) {
@@ -100,7 +111,7 @@ export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
}
// insert file depending on the type at the current position
if (fileType === "image") {
if (fileType === "image" && !disabledExtensions?.includes("image")) {
editor.commands.insertImageComponent({
file,
pos,
@@ -1,5 +1,7 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// plane editor imports
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
// types
@@ -32,7 +34,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
transactions.forEach((transaction) => {
// if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically)
if (transaction.getMeta("skipFileDeletion")) return;
if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return;
const removedFiles: TFileNode[] = [];
@@ -1,50 +1,4 @@
import { Extensions } from "@tiptap/core";
import { EditorProps } from "@tiptap/pm/view";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import {
EditorReadOnlyRefApi,
EditorRefApi,
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TUserDetails,
} from "@/types";
export type TServerHandler = {
onConnect?: () => void;
onServerError?: () => void;
};
type TCollaborativeEditorHookProps = {
disabledExtensions: TExtensions[];
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
handleEditorReady?: (value: boolean) => void;
id: string;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
};
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
onTransaction?: () => void;
embedHandler?: TEmbedConfig;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: TReadOnlyMentionHandler;
};

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