Compare commits

...

74 Commits

Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
Vipin Chaudhary 8db95d9ec0 [WIKI-823] fix: callout element emoji search (#8201)
* fix: emoji search input

* fix: enhance keyboard navigation in EmojiPicker component
2025-12-01 13:37:20 +05:30
b-saikrishnakanth 27bf2575bd [WEB-5534] fix: intake state dropdown (#8199) 2025-12-01 12:50:01 +05:30
b-saikrishnakanth 22bb3c5ecc [WEB-5490] fix: intake section filter persists incorrectly across projects (#8187) 2025-12-01 12:49:05 +05:30
Aaryan Khandelwal 6b85d67f6c [WIKI-788] regression: copy markdown option #8200 2025-12-01 12:48:28 +05:30
Prateek Shourya 123f59e74b [WEB-5525] improvement: update WorkspaceMenuRoot to use variant prop for layout adjustments (#8196) 2025-11-28 19:00:25 +05:30
sriram veeraghanta c7bf912cf2 fix: state group choices (#8198) 2025-11-28 18:06:00 +05:30
Aaryan Khandelwal 2980836015 [WEB-5527] fix: extended sidebar (#8197) 2025-11-28 16:54:53 +05:30
Bavisetti Narayan 78fbdde165 [WEB-5282] chore: triage state in intake (#8135)
* chore: traige state in intake

* chore: triage state changes

* feat: implement intake state dropdown component and integrate into issue properties

* chore: added the triage state validation

* chore: added triage state filter

* chore: added workspace filter

* fix: migration file

* chore: added triage group state check

* chore: updated the filters

* chore: updated the filters

* chore: added variables for intake state

* fix: import error

* refactor: improve project intake state retrieval logic and update TriageGroupIcon component

* chore: changed the intake validation logic

* refactor: update intake state types and clean up unused interfaces

* chore: changed the state color

* chore: changed the update serializer

* chore: updated with current instance

* chore: update TriageGroupIcon color to match new intake state group color

* chore: stringified value

* chore: added validation in serializer

* chore: added logger instead of print

* fix: correct component closing syntax in ActiveProjectItem

* chore: updated the migration file

* chore: added noop in migation

---------

Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-11-28 16:16:48 +05:30
b-saikrishnakanth dbc5a6348d fix: disable timezone selection button for non-admin users (#8195) 2025-11-28 16:14:43 +05:30
Aaryan Khandelwal c685042a47 regression: projects breadcrumb in accordion layout (#8194) 2025-11-28 14:05:38 +05:30
Vipin Chaudhary a4de486cf7 [WIKI-811] fix: ensure only non-deleted project pages are retrieved in page queries (#8182)
* fix: ensure soft delete handling for pages in PageViewSet methods

* refactor: streamline query for project IDs in PageDuplicateEndpoint

* refactor: remove soft delete condition from ProjectPage queries in PageViewSet and PageDuplicateEndpoint

* refactor: simplify ProjectPage query in PageViewSet for improved readability

* refactor: replace filter with get for Page queries in PageViewSet and PageDuplicateEndpoint to enhance clarity

* refactor: replace filter with get for Page queries in PagesDescriptionViewSet to improve efficiency
2025-11-27 20:55:50 +05:30
Prateek Shourya 3c84e75350 [WEB-5510] fix: handle null values in context indicator for improved stability (#8190) 2025-11-27 20:53:42 +05:30
Aaryan Khandelwal 39749106a2 regression: sidebar toggle button position (#8186)
* fix: sidebar toggle button position

* chore: remove border radius
2025-11-27 20:53:12 +05:30
Aaryan Khandelwal 9bcb1fa469 [WEB-5515]: comments ordering (#8193)
* fix: comments ordering

* fix: comment timestamp:
2025-11-27 20:51:06 +05:30
Sangeetha c31a225775 [WEB-5506] fix: new navigation pre release bugs (#8181)
* chore: update navigation_project_limit and navigation_control_preference

* chore: set default true for user specific widgets

* chore: use serializer in ProjectMemberPreferenceEndpoint
chore: use serializer in WorkspaceUserPropertiesEndpoint
"

* fix: validate preferences

* fix: status code

* fix: remove saving from validate

* fix: simply validate_preferences

* chore: create WorkspaceUserProperties if it doesn't exist

* fix: create WorksapceUserProperties it not exist

* fix: copy the instance

* Revert "fix: copy the instance"

This reverts commit ddb0384b6d.

* chore: migrate WorkspaceUserPreference to set defaults

* fix: migration file name

* Revert "fix: migration file name"

This reverts commit 80a21dedf1.

* Revert "chore: migrate WorkspaceUserPreference to set defaults"

This reverts commit 25bc583a08.
2025-11-27 18:12:20 +05:30
sriramveeraghanta 73c317f283 chore(deps): adding pacakges to resolutions to resolve vulnerbailities 2025-11-27 16:37:36 +05:30
Aaryan Khandelwal a0da806a79 [WEB-5520] fix: comments UI in space app #8189 2025-11-27 15:58:10 +05:30
Aaryan Khandelwal eddf80aaed [WEB-5511] regression: revamped navigation UI bugs (#8183) 2025-11-26 18:51:03 +05:30
Aaryan Khandelwal 05b1c147a9 [WEB-5506] regression: navigation revamp bugs (#8180) 2025-11-26 16:25:29 +05:30
Prateek Shourya ae7898aaee fix: move X-Frame-Options header to auth page for enhanced security (#8179) 2025-11-26 13:57:46 +05:30
Anmol Singh Bhatia 4806bdf99c [WEB-5170] feat: navigation revamp (#8162) 2025-11-26 12:56:11 +05:30
Prateek Shourya 37c59ef0d1 [WEB-5507] fix: remove module checks in analytics sidebar link handlers (#8177) 2025-11-26 00:02:14 +05:30
Prateek Shourya 3f11183768 [WEB-5508] fix: improve error handling in AuthFormRoot by ensuring safe access to error_code (#8178) 2025-11-25 23:59:21 +05:30
Aaryan Khandelwal d38147b875 fix: build error (#8171) 2025-11-25 19:15:35 +05:30
Prateek Shourya 3436c4f1f5 [WEB-5501] refactor: optimize component structures and improve hooks (#8174)
* [WEB-5501] refactor: optimize component structures and improve hooks

- Updated type definitions in AppProvider to use React.ReactNode for children.
- Enhanced HomePeekOverviewsRoot by using MobX observer and integrating issue detail hook.
- Optimized ContentOverflowWrapper to prevent unnecessary re-renders by adjusting useEffect dependencies.
- Updated DashboardQuickLinks to include necessary dependencies in useCallback.
- Refactored GlobalShortcutsProvider to utilize refs for context and handler management, improving performance.
- Changed useCurrentTime to update every minute instead of every second.
- Refactored outside click hooks to use useCallback for better performance.
- Improved IntercomProvider and PostHogProvider to prevent multiple initializations using refs.

* refactor: simplify conditional rendering in HomePeekOverviewsRoot component

* refactor: improve outside click detection in sidebar and peek overview hooks

* refactor: enhance IntercomProvider and PostHogProvider with hydration state management
2025-11-25 18:52:20 +05:30
sriramveeraghanta 31e8563725 chore(deps): upgrade sentry pacakges 2025-11-25 16:25:07 +05:30
b-saikrishnakanth 5ddfd0e1a9 [WEB-5497] fix: update change email button label for localization consistency #8173 2025-11-25 15:47:14 +05:30
dependabot[bot] ef99f8bbca chore(deps): bump @sentry/node (#8169)
Bumps the npm_and_yarn group with 1 update in the / directory: [@sentry/node](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/node` from 10.24.0 to 10.27.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.24.0...10.27.0)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  dependency-version: 10.27.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-25 13:35:01 +05:30
Jayash Tripathy 2804987fe8 [WEB-5358] fix: analytics enhancements (#8150)
* refactor: enhance analytics page tab functionality and UI

- Introduced state management for selected tabs using useState and useEffect hooks.
- Updated tab handling to improve user experience with onValueChange for Tabs component.
- Refactored AnalyticsFilterActions to remove unused duration selection.
- Added Tooltip to project name in ActiveProjectItem for better visibility.
- Minor styling adjustments in Tabs component for improved layout consistency.

* refactor: improve tab layout in analytics page by adding w-fit

* fix: update styling for ActiveProjectItem component

* refactor: simplify analytics page and introduce useAnalyticsTabs hook

- Removed unused imports and optimized the analytics page component.
- Replaced getAnalyticsTabs with a new custom hook useAnalyticsTabs for better modularity.
- Added useAnalyticsTabs hook to manage analytics tab logic.
- Created new files for useAnalyticsTabs and analytics tabs in the 'ce' and 'ee' directories.

* fix: ensure consistent export in use-analytics-tabs file

* style: format code for better readability in AnalyticsPage component

* fix: add missing newline at end of file in use-analytics-tabs

* chore: remove unused analytics tab components

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:29:34 +05:30
Aaryan Khandelwal 09f4e3d4ae [WIKI-774] refactor: space app mentions #8152
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:26:48 +05:30
Sangeetha 7c8cbc4ead [WEB-4428] fix: duplicate labels with case insensitive (#7388)
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:22:17 +05:30
Bavisetti Narayan ce6299937f [WEB-5430] feat: allow users to change email (#8120)
* feat: change user email

* chore: optimised the logic

* feat: add email change functionality and related modals in profile form

* refactor: format checkEmail method for improved readability

* chore: added rate limit exceeded validation

* feat: implement change email modal with localization support

- Added translation support for the change email modal, including titles, descriptions, and error messages.
- Integrated the useTranslation hook for dynamic text rendering.
- Updated form validation messages to utilize localized strings.
- Enhanced user feedback with localized success and error toast messages.
- Updated button labels and placeholders to reflect localization changes.

* chore: added extra validation in cache key

* fix: format files

---------

Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-24 21:21:52 +05:30
Dheeraj Kumar Ketireddy d6fce114d6 [SILO-663] chore: enhance error handling in ComplexFilterBackend with DRFValidationError (#8090)
* [SILO-663] chore: enhance error handling in ComplexFilterBackend with DRFValidationError

* Log the exception and re-raise it
2025-11-24 21:14:14 +05:30
Prateek Shourya e6d584fde7 [WEB-5489] fix: prevent AppRail hook usage outside context in disabled workspace components (#8164) 2025-11-24 16:03:36 +05:30
sriram veeraghanta 0240e46f04 fix: live server docker build changes (#8166) 2025-11-24 14:08:37 +05:30
guru_sainath e36f4f47f1 [MOB-1199] dev: update index constraints for notifications to optimize query performance (#7870)
* dev: updated the index constraints for notification

* dev: updated migration file with AddIndexConcurrently

* dev: handled indexing for file asset with asset

* bumped migration sequence number

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
2025-11-23 19:41:27 +05:30
Sangeetha ba4e711f9b [WEB-5350] migration: navigation (#8156)
* migration: navigation related fields in workspace_user_properties

* fix: field names

* fix: remove max_length

* fix: create new migration

* fix: typo

* Renamed horizontal preference to tabbed

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
2025-11-21 20:51:47 +05:30
Aaron 0f4309659a [WEB-5478] chore: fix types (#8155) 2025-11-21 20:22:37 +05:30
Sangeetha 5cfb9538df [WEB-5312] migration: work item comments (#8072)
* migration: description field on issue_comment
sync: issue_comment and description

* fix: update if description already exists for the IssueComment

* feat: management command to copy IssueComment to Description

* fix: description creation order

* chore: add while loop

* fix: move write outside loop

* chore: change sync logic
chore: test cases

* chore: removed deleted_at filter and added order_by in management command

* fix: description_id

* migration: added parent_id for IssueComment

* fix: update update_by_id

* fix: use ChangeTrackerMixin in save

* chore: add docstring
fix: remove self.pk check
chore: wrap the description creation logic in transaction.atomic()

* fix: tests

* fix: use super save method

* fix: mulitple if conditions

* fix: update updated_at
2025-11-21 16:15:07 +05:30
Vipin Chaudhary 6a26ce3a2b [WIKI-792] feat: add 'status' attribute to content validator (#8151) 2025-11-21 14:00:29 +05:30
sriramveeraghanta ec9c868f85 chore: file format using prettier 2025-11-21 13:48:01 +05:30
matutinal 03c581433a chore: french localization updates (#8031) 2025-11-21 13:47:07 +05:30
Aaron 2e15e4f786 fix: source map warning during build (#8148)
* [WEB-5473] fix: source map errors

* [WEB-5473] chore: run codemod

* fix: build errors in editor

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-21 13:43:52 +05:30
Aaryan Khandelwal 9611cd1e73 [WIKI-786] refactor: bubble menu #8138 2025-11-21 12:49:30 +05:30
sriram veeraghanta 8307badae5 fix: eslint errors and warnings (#8149) 2025-11-20 19:31:22 +05:30
Aaron 83fdebf64d [WEB-5459] feat(codemods): add function declaration transformer with tests (#8137)
- Add jscodeshift-based codemod to convert arrow function components to function declarations
- Support React.FC, observer-wrapped, and forwardRef components
- Include comprehensive test suite covering edge cases
- Add npm script to run transformer across codebase
- Target only .tsx files in source directories, excluding node_modules and declaration files

* [WEB-5459] chore: updates after running codemod

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-20 17:39:40 +05:30
Aaron 90866fb925 chore: use oxc tooling to speed up prettier (#8117)
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-11-20 17:30:27 +05:30
Aaryan Khandelwal f3031a44f7 [WEB-5429] refactor: conditional hooks (#8114)
* fix: conditional hooks

* chore: remove unwanted file
2025-11-20 16:22:46 +05:30
Aaryan Khandelwal 83679806fd [WIKI-419] chore: new asset duplicate endpoint added (#7172)
* chore: new asset duplicate endpoint added

* chore: change the type in url

* chore: added rate limiting for image duplication endpoint

* chore: added rate limiting per asset id

* chore: added throttle class

* chore: added validations for entity

* chore: added extra validations

* chore: removed the comment

* chore: reverted the frontend code

* chore: added the response key

* feat: handle image duplication for web

* feat: custom image duplication update

* fix: remove paste logic for image

* fix : remove entity validation

* refactor: remove entity id for duplication

* feat: handle duplication in utils

* feat: add asset duplication registry

* chore: update the set attribute method

* fix: add ref for api check

* chore :remove logs

* chore : add entity types types

* refactor: rename duplication success status value

* chore: update attribute to enums

* chore: update variable name

* chore: set uploading state

* chore : update enum name

* chore : update replace command

* chore: fix retry UI

* chore: remove default logic

* refactor: optimize imports in custom image extension files and improve error handling in image duplication

* fix:type error

* Update packages/editor/src/core/extensions/custom-image/components/node-view.tsx

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

* fix: enhance asset duplication handler to ignore HTTP sources

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: VipinDevelops <vipinchaudhary1809@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-20 15:05:01 +05:30
Aaryan Khandelwal d462546055 [WIKI-788] fix: editor markdown copy rules (#8140) 2025-11-20 14:46:12 +05:30
Dheeraj Kumar Ketireddy f510020daa [WEB-5285] feat: add ChangeTrackerMixin to track model field changes and original values #8145 2025-11-20 14:36:55 +05:30
Prateek Shourya 1eaa48c95c [WEB-5458] improvement: restructure layout components to enhance loading (#8141) 2025-11-19 19:11:17 +05:30
sriramveeraghanta d6503232de chore(deps): bump npm vulnerabilities 2025-11-19 14:56:29 +05:30
Jayash Tripathy 88a41479f4 [WEB-5000] feat: enhance exports settings with rich filters (#8124) 2025-11-19 14:31:48 +05:30
Vipin Chaudhary b73cf4f0e2 [WEB-5449] fix: enhance content check for unique ID generation and update issue loader condition (#8132) 2025-11-18 15:27:01 +05:30
Aaryan Khandelwal 2e6225a883 [WEB-5436] feat: work item preview (#8121) 2025-11-18 13:54:26 +05:30
Vipin Chaudhary f34ca18a34 [WIKI-775] fix: remove fallback image from emojis #8130 2025-11-18 13:53:04 +05:30
Prateek Shourya 69323f4164 [WEB-5442] fix: favorites list on workspace switch (#8131) 2025-11-18 13:52:29 +05:30
Jayash Tripathy 06300d1a8e [WEB-5447] feat: add dataPreventOutsideClick prop to reafactor: ComboboxOptions for enhanced click handling #8112 2025-11-17 19:47:48 +05:30
Vipin Chaudhary 6e32cb51b6 [WIKI-770] fix: update editor ref after editor ready #8115 2025-11-17 16:41:30 +05:30
Vipin Chaudhary bd0361de92 [WIKI-480] feat: unique id extension (#8098) 2025-11-17 16:14:20 +05:30
Prateek Shourya ebab5e209f [WEB-5437] fix: remove temporary code for issue display filters to fix work item ordering. (#8125) 2025-11-17 14:28:02 +05:30
Aaryan Khandelwal c65e2c4aab [WIKI-773] refactor: editor mention components and hooks (#8111) 2025-11-17 14:07:37 +05:30
Prateek Shourya c04ae51d20 [WEB-5423] revert: update imports for pragmatic-drag-and-drop to use cjs entry points and add TypeScript error handling (#8118) 2025-11-14 19:04:49 +05:30
Prateek Shourya 19a0ef490b [WEB-5423] fix: typescript errors and add types check step to pull request workflow (#8110) 2025-11-14 16:18:36 +05:30
sriram veeraghanta 4e357c4ad0 [WEB-5404] chore: update next images with html default images (#8101)
* chore: update next images with html default images

* chore: sync related changes

* Update apps/admin/core/components/instance/failure.tsx

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

* Update apps/space/app/not-found.tsx

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

* Update apps/space/core/components/issues/issue-layouts/error.tsx

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

* Update apps/space/core/components/ui/not-found.tsx

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

* chore: replace classname styles in space

* fix: copoilot suggestions

* fix: copilot suggestions

* chore: format files

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 18:33:18 +05:30
Prateek Shourya 80670b2b3f [WEB-5128] refactor: remove local database dependencies and unused code (#8109) 2025-11-13 18:32:15 +05:30
Prateek Shourya 64f90b828b [WEB-5419] chore: add dotenv dependency, hosts and configure environment variables in admin, space, and web apps (#8107)
* chore: add dotenv dependency and configure environment variables in admin, space, and web apps

* chore: allowed multiple hosts in dev mode

* chore: move dotenv to dev deps

* chore: update Vite configuration to set server host to 127.0.0.1 for admin, space, and web apps

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2025-11-13 17:57:47 +05:30
Aaron 95c742244e [WEB-5416] fix: Use proper import syntax (#8105) 2025-11-13 17:41:06 +05:30
sriramveeraghanta 34c0e81d8e chore: update tubro envs and removed plausible script 2025-11-13 15:24:17 +05:30
Vipin Chaudhary 2a2c4921a7 [WIKI-768] fix: ordering and grouping of list-related toolbar and slash commands #8104 2025-11-13 15:20:07 +05:30
Vipin Chaudhary f4c2d519fc [WIKI-718] feat: add full width options in table block menu (#8087) 2025-11-13 14:46:27 +05:30
Jayash Tripathy cbfdcd5638 [WEB-5390] refactor: gantt layout support in base layouts (#8089) 2025-11-13 14:33:24 +05:30
2500 changed files with 32808 additions and 27194 deletions
+48
View File
@@ -0,0 +1,48 @@
---
description: Guidelines for bash commands and tooling in the monorepo
applyTo: "**/*.sh"
---
# Bash & Tooling Instructions
This document outlines the standard tools and commands used in this monorepo.
## Package Manager
We use **pnpm** for package management.
- **Do not use `npm` or `yarn`.**
- Lockfile: `pnpm-lock.yaml`
- Workspace configuration: `pnpm-workspace.yaml`
### Common Commands
- Install dependencies: `pnpm install`
- Run a script in a specific package: `pnpm --filter <package_name> run <script>`
- Run a script in all packages: `pnpm -r run <script>`
## Monorepo Tooling
We use **Turbo** for build system orchestration.
- Configuration: `turbo.json`
## Project Structure
- `apps/`: Contains application services (admin, api, live, proxy, space, web).
- `packages/`: Contains shared packages and libraries.
- `deployments/`: Deployment configurations.
## Running Tests
- To run tests in a specific package (e.g., codemods):
```bash
cd packages/codemods
pnpm run test
```
- Or from root:
```bash
pnpm --filter @plane/codemods run test
```
## Docker
- Local development uses `docker-compose-local.yml`.
- Production/Staging uses `docker-compose.yml`.
@@ -0,0 +1,129 @@
---
description: Guidelines for using modern TypeScript features (v5.0-v5.8)
applyTo: "**/*.{ts,tsx,mts,cts}"
---
# TypeScript Coding Guidelines & Modern Features (v5.0 - v5.8)
When writing TypeScript code, prioritize using modern features and best practices introduced in recent versions (up to 5.8).
## Global Themes Across 5.x
1. **Standard decorators are here; legacy decorators are legacy.**
New TC39-compliant decorators landed in 5.0 and were extended in 5.2 (metadata). Old `experimentalDecorators`-style behavior is still supported but should be treated as legacy.
2. **Type system is more precise and less noisy.**
Major work went into narrowing, control flow analysis, error messages, and new helpers like `NoInfer`, inferred predicates, and better `undefined`/`never`/uninitialized checks.
3. **Module / runtime interop has been modernized.**
Options like `--moduleResolution bundler`, `--module nodenext`/`node18`, `--rewriteRelativeImportExtensions`, `--erasableSyntaxOnly`, and `--verbatimModuleSyntax` are about playing nicely with ESM, Node 18+/22+, direct TypeScript execution, and bundlers.
4. **The standard library keeps tracking modern JS.**
Support for new ES features (iterator helpers, `Object.groupBy`/`Map.groupBy`, new Set/ES2024 APIs) shows up as type declarations and sometimes extra checks (regex syntax checking, etc.).
When generating or refactoring code, prefer these newer idioms, and avoid patterns that conflict with updated checks.
## Modern Features to Utilize
### Type System & Inference
- **`const` Type Parameters (5.0)**: Use `const` type parameters for more precise literal inference.
```typescript
declare function names<const T extends string[]>(...names: T): void;
```
- **`@satisfies` Operator (5.0)**: Use `satisfies` to validate types without widening them.
- **Inferred Type Predicates (5.5)**: Allow TypeScript to infer type predicates for functions that filter arrays or check types, reducing the need for explicit `is` return types.
- **`NoInfer` Utility (5.4)**: Use `NoInfer<T>` to block inference for specific type arguments when you want them to be determined by other arguments.
- **Narrowing**:
- **Switch(true) (5.3)**: Utilize narrowing in `switch(true)` blocks.
- **Boolean Comparisons (5.3)**: Rely on narrowing from direct boolean comparisons.
- **Closures (5.4)**: Trust preserved narrowing in closures when variables aren't modified after the check.
- **Constant Indexed Access (5.5)**: Use constant indices to narrow object/array properties.
### Syntax & Control Flow
- **Decorators (5.0)**: Use standard ECMAScript decorators (Stage 3).
- **`using` Declarations (5.2)**: Use `using` for explicit resource management (Disposable pattern) instead of manual cleanup.
```typescript
using resource = new Resource();
```
- **Import Attributes (5.3/5.8)**: Use `with { type: "json" }` for import attributes. Avoid the deprecated `assert` syntax.
- **`switch` Exhaustiveness**: Rely on TypeScript's exhaustiveness checking in switch statements.
### Modules & Imports
- **`verbatimModuleSyntax` (5.0)**: Respect this flag by using `import type` explicitly when importing types to ensure they are erased during compilation.
- **Type-Only Imports with Extensions (5.2)**: You can use `.ts`, `.mts`, `.cts` extensions in `import type` statements.
- **`resolution-mode` (5.3)**: Use `import type { Type } from "mod" with { "resolution-mode": "import" }` if needed for specific module resolution contexts.
- **JSDoc `@import` (5.5)**: Use `@import` tags in JSDoc for cleaner type imports in JS files if working in a mixed codebase.
### Standard Library & Built-ins
- **Iterator Helpers (5.6)**: Use new iterator methods (map, filter, etc.) if targeting modern environments.
- **Set Methods (5.5)**: Utilize new `Set` methods like `union`, `intersection`, etc., when available.
- **`Object.groupBy` / `Map.groupBy` (5.4)**: Use these standard methods for grouping instead of external libraries like Lodash when appropriate.
- **`Promise.withResolvers` (5.7)**: Use `Promise.withResolvers()` for creating promises with exposed resolve/reject functions.
### Configuration & Tooling
- **`--moduleResolution bundler` (5.0)**: Assume this resolution strategy for modern web projects (Vite, Next.js, etc.).
- **`--erasableSyntaxOnly` (5.8)**: Be aware of this flag; avoid TypeScript-specific syntax that cannot be simply erased (like `enum`s or `namespaces`) if the project aims for maximum compatibility with tools like Node.js's `--strip-types`. Prefer `const` objects or unions over `enum`s if requested.
## Specific Coding Patterns
### Arrays & Collections
- Use **Copying Array Methods (5.2)** (`toSorted`, `toSpliced`, `with`) for immutable array operations.
- **TypedArrays (5.7)**: Be aware that TypedArrays are now generic over `ArrayBufferLike`.
### Classes
- **Parameter Decorators (5.0/5.2)**: Use modern standard decorators.
- **`super` Property Access (5.3)**: Avoid accessing instance fields via `super`.
### Error Handling
- **Checks for Never-Initialized Variables (5.7)**: Ensure variables are initialized before use to avoid new errors.
## Deprecations to Avoid
- Avoid `import ... assert` (use `with`).
- Avoid implicit `any` returns in `undefined`-returning functions (though 5.1 makes this easier, explicit is better).
- Avoid `enum`s if the project prefers erasable syntax (5.8).
## Version-Specific Highlights
### TypeScript 5.0
- **Decorators**: Use standard decorators unless `experimentalDecorators` is explicitly enabled.
- **`const` Type Parameters**: Use for literal inference.
- **Enums**: All enums are union enums.
- **Modules**: `--moduleResolution bundler` and `--verbatimModuleSyntax` are key for modern bundlers.
### TypeScript 5.1
- **Returns**: `undefined`-returning functions don't need explicit returns.
- **Getters/Setters**: Can have unrelated types with explicit annotations.
### TypeScript 5.2
- **Resource Management**: `using` declarations for `Symbol.dispose`.
- **Decorator Metadata**: Use `context.metadata` for design-time metadata.
### TypeScript 5.3
- **Import Attributes**: Use `with { type: "json" }`.
- **Switch(true)**: Narrowing works in `switch(true)`.
### TypeScript 5.4
- **Closures**: Narrowing preserved in closures if last assignment is before creation.
- **`NoInfer`**: Block inference for specific arguments.
- **Grouping**: `Object.groupBy` / `Map.groupBy`.
### TypeScript 5.5
- **Inferred Predicates**: Functions checking types often don't need explicit `is` return types.
- **Constant Index Access**: Better narrowing for constant keys.
- **Regex**: Syntax checking for regex literals.
### TypeScript 5.6
- **Truthiness Checks**: Errors on always-truthy/falsy conditions (e.g., `if (/regex/)`).
- **Iterator Helpers**: `.map`, `.filter` on iterators.
### TypeScript 5.7
- **Uninitialized Variables**: Stricter checks for never-initialized variables.
- **Relative Imports**: `--rewriteRelativeImportExtensions` for `.ts` imports in output.
- **ES2024**: Support for `Promise.withResolvers`, `Atomics.waitAsync`.
### TypeScript 5.8
- **Return Checks**: Granular checks for conditional returns.
- **Node Modules**: `--module node18` stable; `require()` of ESM allowed in `nodenext`.
- **Erasable Syntax**: `--erasableSyntaxOnly` forbids enums, namespaces, etc.
When generating code, always prefer the most modern, standard, and type-safe approach available in TypeScript 5.8.
@@ -49,5 +49,8 @@ jobs:
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Check Affected types
run: pnpm turbo run check:types --affected
- name: Build Affected
run: pnpm turbo run build --affected
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+2
View File
@@ -1,4 +1,6 @@
.next/*
.react-router/*
.vite/*
out/*
public/*
dist/*
-1
View File
@@ -3,7 +3,6 @@ module.exports = {
extends: ["@plane/eslint-config/next.js"],
ignorePatterns: ["build/**", "dist/**", ".vite/**"],
rules: {
"no-duplicate-imports": "off",
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
+2
View File
@@ -1,4 +1,6 @@
.next
.react-router
.vite
.vercel
.tubro
out/
+2 -1
View File
@@ -1,5 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { useForm } from "react-hook-form";
import { Lightbulb } from "lucide-react";
import { Button } from "@plane/propel/button";
@@ -17,7 +15,7 @@ type IInstanceAIForm = {
type AIFormValues = Record<TInstanceAIConfigurationKeys, string>;
export const InstanceAIForm: React.FC<IInstanceAIForm> = (props) => {
export function InstanceAIForm(props: IInstanceAIForm) {
const { config } = props;
// store
const { updateInstanceConfigurations } = useInstance();
@@ -133,4 +131,4 @@ export const InstanceAIForm: React.FC<IInstanceAIForm> = (props) => {
</div>
</div>
);
};
}
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
@@ -9,7 +7,7 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceAIForm } from "./form";
const InstanceAIPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
@@ -1,6 +1,3 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -27,7 +24,7 @@ type Props = {
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
export const InstanceGiteaConfigForm: FC<Props> = (props) => {
export function InstanceGiteaConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -207,4 +204,4 @@ export const InstanceGiteaConfigForm: FC<Props> = (props) => {
</div>
</>
);
};
}
@@ -1,8 +1,5 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
@@ -16,7 +13,7 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(() => {
const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthenticationPage() {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -65,7 +62,7 @@ const InstanceGiteaAuthenticationPage = observer(() => {
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<Image src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
value={isGiteaEnabled}
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -28,7 +26,7 @@ type Props = {
type GithubConfigFormValues = Record<TInstanceGithubAuthenticationConfigurationKeys, string>;
export const InstanceGithubConfigForm: React.FC<Props> = (props) => {
export function InstanceGithubConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -245,4 +243,4 @@ export const InstanceGithubConfigForm: React.FC<Props> = (props) => {
</div>
</>
);
};
}
@@ -1,8 +1,5 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
@@ -20,7 +17,9 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceGithubConfigForm } from "./form";
const InstanceGithubAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -73,7 +72,7 @@ const InstanceGithubAuthenticationPage = observer<React.FC<Route.ComponentProps>
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
icon={
<Image
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={24}
width={24}
@@ -24,7 +24,7 @@ type Props = {
type GitlabConfigFormValues = Record<TInstanceGitlabAuthenticationConfigurationKeys, string>;
export const InstanceGitlabConfigForm: React.FC<Props> = (props) => {
export function InstanceGitlabConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -208,4 +208,4 @@ export const InstanceGitlabConfigForm: React.FC<Props> = (props) => {
</div>
</>
);
};
}
@@ -1,8 +1,5 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
@@ -16,7 +13,9 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceGitlabConfigForm } from "./form";
const InstanceGitlabAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -63,7 +62,7 @@ const InstanceGitlabAuthenticationPage = observer<React.FC<Route.ComponentProps>
<AuthenticationMethodCard
name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts."
icon={<Image src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { isEmpty } from "lodash-es";
import Link from "next/link";
@@ -27,7 +25,7 @@ type Props = {
type GoogleConfigFormValues = Record<TInstanceGoogleAuthenticationConfigurationKeys, string>;
export const InstanceGoogleConfigForm: React.FC<Props> = (props) => {
export function InstanceGoogleConfigForm(props: Props) {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
@@ -232,4 +230,4 @@ export const InstanceGoogleConfigForm: React.FC<Props> = (props) => {
</div>
</>
);
};
}
@@ -1,8 +1,5 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import useSWR from "swr";
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
@@ -16,7 +13,9 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceGoogleConfigForm } from "./form";
const InstanceGoogleAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthenticationPage(
_props: Route.ComponentProps
) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
@@ -64,7 +63,7 @@ const InstanceGoogleAuthenticationPage = observer<React.FC<Route.ComponentProps>
name="Google"
description="Allow members to login or sign up to plane with their Google
accounts."
icon={<Image src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
@@ -14,7 +12,7 @@ import { useInstance } from "@/hooks/store";
import { AuthenticationModes } from "@/plane-admin/components/authentication";
import type { Route } from "./+types/page";
const InstanceAuthenticationPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
// types
@@ -30,7 +28,7 @@ const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
NONE: "No email security",
};
export const InstanceEmailForm: React.FC<IInstanceEmailForm> = (props) => {
export function InstanceEmailForm(props: IInstanceEmailForm) {
const { config } = props;
// states
const [isSendTestEmailModalOpen, setIsSendTestEmailModalOpen] = useState(false);
@@ -224,4 +222,4 @@ export const InstanceEmailForm: React.FC<IInstanceEmailForm> = (props) => {
</div>
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
@@ -11,7 +9,7 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.ComponentProps) {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@@ -19,7 +19,7 @@ enum ESendEmailSteps {
const instanceService = new InstanceService();
export const SendTestEmailModal: React.FC<Props> = (props) => {
export function SendTestEmailModal(props: Props) {
const { isOpen, handleClose } = props;
// state
@@ -133,4 +133,4 @@ export const SendTestEmailModal: React.FC<Props> = (props) => {
</Dialog>
</Transition.Root>
);
};
}
@@ -1,4 +1,3 @@
"use client";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Telescope } from "lucide-react";
@@ -19,7 +18,7 @@ export interface IGeneralConfigurationForm {
instanceAdmins: IInstanceAdmin[];
}
export const GeneralConfigurationForm: React.FC<IGeneralConfigurationForm> = observer((props) => {
export const GeneralConfigurationForm = observer(function GeneralConfigurationForm(props: IGeneralConfigurationForm) {
const { instance, instanceAdmins } = props;
// hooks
const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
@@ -13,7 +11,7 @@ type TIntercomConfig = {
isTelemetryEnabled: boolean;
};
export const IntercomConfig: React.FC<TIntercomConfig> = observer((props) => {
export const IntercomConfig = observer(function IntercomConfig(props: TIntercomConfig) {
const { isTelemetryEnabled } = props;
// hooks
const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance();
@@ -1,4 +1,3 @@
"use client";
import { observer } from "mobx-react";
// hooks
import { useInstance } from "@/hooks/store";
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react";
@@ -10,7 +8,7 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useTheme } from "@/hooks/store";
export const HamburgerToggle = observer(() => {
export const HamburgerToggle = observer(function HamburgerToggle() {
const { isSidebarCollapsed, toggleSidebar } = useTheme();
return (
<div
@@ -22,7 +20,7 @@ export const HamburgerToggle = observer(() => {
);
});
export const AdminHeader = observer(() => {
export const AdminHeader = observer(function AdminHeader() {
const pathName = usePathname();
const getHeaderTitle = (pathName: string) => {
@@ -1,4 +1,3 @@
"use client";
import { useForm } from "react-hook-form";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -14,7 +13,7 @@ type IInstanceImageConfigForm = {
type ImageConfigFormValues = Record<TInstanceImageConfigurationKeys, string>;
export const InstanceImageConfigForm: React.FC<IInstanceImageConfigForm> = (props) => {
export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
const { config } = props;
// store hooks
const { updateInstanceConfigurations } = useInstance();
@@ -77,4 +76,4 @@ export const InstanceImageConfigForm: React.FC<IInstanceImageConfigForm> = (prop
</div>
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
@@ -9,7 +7,7 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceImageConfigForm } from "./form";
const InstanceImagePage = observer<React.FC<Route.ComponentProps>>(() => {
const InstanceImagePage = observer(function InstanceImagePage(_props: Route.ComponentProps) {
// store
const { formattedConfig, fetchInstanceConfigurations } = useInstance();
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
@@ -14,7 +12,7 @@ import type { Route } from "./+types/layout";
import { AdminHeader } from "./header";
import { AdminSidebar } from "./sidebar";
const AdminLayout: React.FC<Route.ComponentProps> = () => {
function AdminLayout(_props: Route.ComponentProps) {
// router
const { replace } = useRouter();
// store hooks
@@ -48,6 +46,6 @@ const AdminLayout: React.FC<Route.ComponentProps> = () => {
}
return <></>;
};
}
export default observer(AdminLayout);
@@ -1,5 +1,3 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme as useNextTheme } from "next-themes";
@@ -16,7 +14,7 @@ import { useTheme, useUser } from "@/hooks/store";
// service initialization
const authService = new AuthService();
export const AdminSidebarDropdown = observer(() => {
export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
// store hooks
const { isSidebarCollapsed } = useTheme();
const { currentUser, signOut } = useUser();
@@ -1,5 +1,3 @@
"use client";
import { useState, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
@@ -34,7 +32,7 @@ const helpOptions = [
},
];
export const AdminSidebarHelpSection: React.FC = observer(() => {
export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection() {
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
// store
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -50,7 +48,7 @@ const INSTANCE_ADMIN_LINKS = [
},
];
export const AdminSidebarMenu = observer(() => {
export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
// store hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
// router
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
@@ -11,7 +9,7 @@ import { AdminSidebarDropdown } from "./sidebar-dropdown";
import { AdminSidebarHelpSection } from "./sidebar-help-section";
import { AdminSidebarMenu } from "./sidebar-menu";
export const AdminSidebar = observer(() => {
export const AdminSidebar = observer(function AdminSidebar() {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
@@ -15,7 +15,7 @@ import { useWorkspace } from "@/hooks/store";
const instanceWorkspaceService = new InstanceWorkspaceService();
export const WorkspaceCreateForm = () => {
export function WorkspaceCreateForm() {
// router
const router = useRouter();
// states
@@ -209,4 +209,4 @@ export const WorkspaceCreateForm = () => {
</div>
</div>
);
};
}
@@ -1,23 +1,23 @@
"use client";
import { observer } from "mobx-react";
// components
import type { Route } from "./+types/page";
import { WorkspaceCreateForm } from "./form";
const WorkspaceCreatePage = observer<React.FC<Route.ComponentProps>>(() => (
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
<div className="text-sm font-normal text-custom-text-300">
You will need to invite users from Workspace Settings after you create this workspace.
const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.ComponentProps) {
return (
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Create a new workspace on this instance.</div>
<div className="text-sm font-normal text-custom-text-300">
You will need to invite users from Workspace Settings after you create this workspace.
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<WorkspaceCreateForm />
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
<WorkspaceCreateForm />
</div>
</div>
));
);
});
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }];
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
@@ -18,7 +16,7 @@ import { WorkspaceListItem } from "@/components/workspace/list-item";
import { useInstance, useWorkspace } from "@/hooks/store";
import type { Route } from "./+types/page";
const WorkspaceManagementPage = observer<React.FC<Route.ComponentProps>>(() => {
const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props: Route.ComponentProps) {
// states
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store
+2 -2
View File
@@ -9,7 +9,7 @@ type TAuthBanner = {
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
};
export const AuthBanner: React.FC<TAuthBanner> = (props) => {
export function AuthBanner(props: TAuthBanner) {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
@@ -27,4 +27,4 @@ export const AuthBanner: React.FC<TAuthBanner> = (props) => {
</div>
</div>
);
};
}
+9 -9
View File
@@ -1,12 +1,12 @@
"use client";
import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
export const AuthHeader = () => (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
</Link>
</div>
);
export function AuthHeader() {
return (
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
<Link href="/">
<PlaneLockup height={20} width={95} className="text-custom-text-100" />
</Link>
</div>
);
}
+3 -4
View File
@@ -1,4 +1,3 @@
import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
@@ -135,7 +134,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
@@ -143,7 +142,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
@@ -156,7 +155,7 @@ export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
];
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
@@ -11,7 +9,7 @@ import { useInstance } from "@/hooks/store";
import type { Route } from "./+types/page";
import { InstanceSignInForm } from "./sign-in-form";
const HomePage = () => {
function HomePage() {
// store hooks
const { instance, error } = useInstance();
@@ -36,7 +34,7 @@ const HomePage = () => {
// if instance is fetched and setup is done, show sign in form
return <InstanceSignInForm />;
};
}
export default observer(HomePage);
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
@@ -45,7 +43,7 @@ const defaultFromData: TFormData = {
password: "",
};
export const InstanceSignInForm: React.FC = () => {
export function InstanceSignInForm() {
// search params
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
@@ -192,4 +190,4 @@ export const InstanceSignInForm: React.FC = () => {
</div>
</>
);
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import useSWR from "swr";
// hooks
import { useInstance } from "@/hooks/store";
export const InstanceProvider = observer<React.FC<React.PropsWithChildren>>((props) => {
export const InstanceProvider = observer(function InstanceProvider(props: React.PropsWithChildren) {
const { children } = props;
// store hooks
const { fetchInstanceInfo } = useInstance();
+2 -4
View File
@@ -1,5 +1,3 @@
"use client";
import { createContext } from "react";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
@@ -28,7 +26,7 @@ export type StoreProviderProps = {
initialState?: any;
};
export const StoreProvider = ({ children, initialState = {} }: StoreProviderProps) => {
export function StoreProvider({ children, initialState = {} }: StoreProviderProps) {
const store = initializeStore(initialState);
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
};
}
+2 -4
View File
@@ -1,10 +1,8 @@
"use client";
import { useTheme } from "next-themes";
import { Toast } from "@plane/propel/toast";
import { resolveGeneralTheme } from "@plane/utils";
export const ToastWithTheme = () => {
export function ToastWithTheme() {
const { resolvedTheme } = useTheme();
return <Toast theme={resolveGeneralTheme(resolvedTheme)} />;
};
}
+1 -3
View File
@@ -1,12 +1,10 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// hooks
import { useInstance, useTheme, useUser } from "@/hooks/store";
export const UserProvider = observer<React.FC<React.PropsWithChildren>>(({ children }) => {
export const UserProvider = observer(function UserProvider({ children }: React.PropsWithChildren) {
// hooks
const { isSidebarCollapsed, toggleSidebar } = useTheme();
const { currentUser, fetchCurrentUser } = useUser();
+3 -3
View File
@@ -1,5 +1,3 @@
"use client";
import React from "react";
// Minimal shim so code using next/image compiles under React Router + Vite
@@ -9,6 +7,8 @@ type NextImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
};
const Image: React.FC<NextImageProps> = ({ src, alt = "", ...rest }) => <img src={src} alt={alt} {...rest} />;
function Image({ src, alt = "", ...rest }: NextImageProps) {
return <img src={src} alt={alt} {...rest} />;
}
export default Image;
+3 -10
View File
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import { Link as RRLink } from "react-router";
import { ensureTrailingSlash } from "./helper";
@@ -12,13 +10,8 @@ type NextLinkProps = React.ComponentProps<"a"> & {
shallow?: boolean; // next.js prop, ignored
};
const Link: React.FC<NextLinkProps> = ({
href,
replace,
prefetch: _prefetch,
scroll: _scroll,
shallow: _shallow,
...rest
}) => <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
function Link({ href, replace, prefetch: _prefetch, scroll: _scroll, shallow: _shallow, ...rest }: NextLinkProps) {
return <RRLink to={ensureTrailingSlash(href)} replace={replace} {...rest} />;
}
export default Link;
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useMemo } from "react";
import { useLocation, useNavigate, useSearchParams as useSearchParamsRR } from "react-router";
import { ensureTrailingSlash } from "./helper";
+24 -22
View File
@@ -5,30 +5,32 @@ import { Button } from "@plane/propel/button";
// images
import Image404 from "@/app/assets/images/404.svg?url";
const PageNotFound = () => (
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" />
function PageNotFound() {
return (
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
<div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
<p className="text-sm text-custom-text-200">
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</p>
</div>
<Link to="/general/">
<span className="flex justify-center py-4">
<Button variant="neutral-primary" size="md">
Go to general settings
</Button>
</span>
</Link>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
<p className="text-sm text-custom-text-200">
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</p>
</div>
<Link to="/general/">
<span className="flex justify-center py-4">
<Button variant="neutral-primary" size="md">
Go to general settings
</Button>
</span>
</Link>
</div>
</div>
</div>
);
);
}
export default PageNotFound;
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { ThemeProvider } from "next-themes";
import { SWRConfig } from "swr";
import { AppProgressBar } from "@/lib/b-progress";
-5
View File
@@ -1,5 +0,0 @@
declare module "next/image" {
type Props = React.ComponentProps<"img"> & { src: string };
const Image: React.FC<Props>;
export default Image;
}
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { KeyRound, Mails } from "lucide-react";
// types
@@ -58,7 +57,7 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
key: "google",
name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.",
icon: <Image src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
@@ -66,7 +65,7 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
icon: (
<Image
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
height={20}
width={20}
@@ -79,21 +78,21 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
key: "gitlab",
name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitea",
name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
icon: <Image src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "oidc",
name: "OIDC",
description: "Authenticate your users via the OpenID Connect protocol.",
icon: <Image src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
icon: <img src={OIDCLogo} height={22} width={22} alt="OIDC Logo" />,
config: <UpgradeButton />,
unavailable: true,
},
@@ -101,13 +100,13 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
key: "saml",
name: "SAML",
description: "Authenticate your users via the Security Assertion Markup Language protocol.",
icon: <Image src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
icon: <img src={SAMLLogo} height={22} width={22} alt="SAML Logo" className="pl-0.5" />,
config: <UpgradeButton />,
unavailable: true,
},
];
export const AuthenticationModes = observer<React.FC<TAuthenticationModeProps>>((props) => {
export const AuthenticationModes = observer(function AuthenticationModes(props: TAuthenticationModeProps) {
const { disabled, updateConfig } = props;
// next-themes
const { resolvedTheme } = useTheme();
@@ -1,5 +1,3 @@
"use client";
import React from "react";
// icons
import { SquareArrowOutUpRight } from "lucide-react";
@@ -7,9 +5,15 @@ import { SquareArrowOutUpRight } from "lucide-react";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
export const UpgradeButton: React.FC = () => (
<a href="https://plane.so/pricing?mode=self-hosted" target="_blank" className={cn(getButtonStyling("primary", "sm"))}>
Upgrade
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
</a>
);
export function UpgradeButton() {
return (
<a
href="https://plane.so/pricing?mode=self-hosted"
target="_blank"
className={cn(getButtonStyling("primary", "sm"))}
>
Upgrade
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
</a>
);
}
@@ -1,5 +1,3 @@
"use client";
// helpers
import { cn } from "@plane/utils";
@@ -13,7 +11,7 @@ type Props = {
unavailable?: boolean;
};
export const AuthenticationMethodCard: React.FC<Props> = (props) => {
export function AuthenticationMethodCard(props: Props) {
const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props;
return (
@@ -52,4 +50,4 @@ export const AuthenticationMethodCard: React.FC<Props> = (props) => {
<div className={`shrink-0 ${disabled && "opacity-70"}`}>{config}</div>
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// hooks
@@ -14,7 +12,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
export const EmailCodesConfiguration = observer(function EmailCodesConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
@@ -17,7 +15,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GiteaConfiguration: React.FC<Props> = observer((props) => {
export const GiteaConfiguration = observer(function GiteaConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
@@ -18,7 +16,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GithubConfiguration: React.FC<Props> = observer((props) => {
export const GithubConfiguration = observer(function GithubConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
@@ -17,7 +15,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GitlabConfiguration: React.FC<Props> = observer((props) => {
export const GitlabConfiguration = observer(function GitlabConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
@@ -17,7 +15,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GoogleConfiguration: React.FC<Props> = observer((props) => {
export const GoogleConfiguration = observer(function GoogleConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// hooks
@@ -14,7 +12,7 @@ type Props = {
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
export const PasswordLoginConfiguration = observer(function PasswordLoginConfiguration(props: Props) {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
+2 -3
View File
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
type TBanner = {
@@ -6,7 +5,7 @@ type TBanner = {
message: string;
};
export const Banner: FC<TBanner> = (props) => {
export function Banner(props: TBanner) {
const { type, message } = props;
return (
@@ -29,4 +28,4 @@ export const Banner: FC<TBanner> = (props) => {
</div>
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import Link from "next/link";
import { Tooltip } from "@plane/propel/tooltip";
@@ -9,7 +7,7 @@ type Props = {
icon?: React.ReactNode | undefined;
};
export const BreadcrumbLink: React.FC<Props> = (props) => {
export function BreadcrumbLink(props: Props) {
const { href, label, icon } = props;
return (
<Tooltip tooltipContent={label} position="bottom">
@@ -35,4 +33,4 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
</li>
</Tooltip>
);
};
}
@@ -6,16 +6,18 @@ type TProps = {
darkerShade?: boolean;
};
export const CodeBlock = ({ children, className, darkerShade }: TProps) => (
<span
className={cn(
"px-0.5 text-xs text-custom-text-300 bg-custom-background-90 font-semibold rounded-md border border-custom-border-100",
{
"text-custom-text-200 bg-custom-background-80 border-custom-border-200": darkerShade,
},
className
)}
>
{children}
</span>
);
export function CodeBlock({ children, className, darkerShade }: TProps) {
return (
<span
className={cn(
"px-0.5 text-xs text-custom-text-300 bg-custom-background-90 font-semibold rounded-md border border-custom-border-100",
{
"text-custom-text-200 bg-custom-background-80 border-custom-border-200": darkerShade,
},
className
)}
>
{children}
</span>
);
}
@@ -1,5 +1,3 @@
"use client";
import React from "react";
import Link from "next/link";
// headless ui
@@ -13,7 +11,7 @@ type Props = {
onDiscardHref: string;
};
export const ConfirmDiscardModal: React.FC<Props> = (props) => {
export function ConfirmDiscardModal(props: Props) {
const { isOpen, handleClose, onDiscardHref } = props;
return (
@@ -71,4 +69,4 @@ export const ConfirmDiscardModal: React.FC<Props> = (props) => {
</Dialog>
</Transition.Root>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import React, { useState } from "react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
@@ -30,7 +28,7 @@ export type TControllerInputFormField = {
required: boolean;
};
export const ControllerInput: React.FC<Props> = (props) => {
export function ControllerInput(props: Props) {
const { name, control, type, label, description, placeholder, error, required } = props;
// states
const [showPassword, setShowPassword] = useState(false);
@@ -81,4 +79,4 @@ export const ControllerInput: React.FC<Props> = (props) => {
{description && <p className="pt-0.5 text-xs text-custom-text-300">{description}</p>}
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import React from "react";
// ui
import { Copy } from "lucide-react";
@@ -19,7 +17,7 @@ export type TCopyField = {
description: string | React.ReactNode;
};
export const CopyField: React.FC<Props> = (props) => {
export function CopyField(props: Props) {
const { label, url, description } = props;
return (
@@ -43,4 +41,4 @@ export const CopyField: React.FC<Props> = (props) => {
<div className="text-xs text-custom-text-300">{description}</div>
</div>
);
};
}
@@ -1,7 +1,4 @@
"use client";
import React from "react";
import Image from "next/image";
import { Button } from "@plane/propel/button";
type Props = {
@@ -17,32 +14,27 @@ type Props = {
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
{image && <Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
export function EmptyState({ title, description, image, primaryButton, secondaryButton, disabled = false }: Props) {
return (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
{image && <img src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
</div>
);
);
}
@@ -1,16 +1,15 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export const LogoSpinner = () => {
export function LogoSpinner() {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
</div>
);
};
}
@@ -1,11 +1,9 @@
"use client";
type TPageHeader = {
title?: string;
description?: string;
};
export const PageHeader: React.FC<TPageHeader> = (props) => {
export function PageHeader(props: TPageHeader) {
const { title = "God Mode - Plane", description = "Plane god mode" } = props;
return (
@@ -14,4 +12,4 @@ export const PageHeader: React.FC<TPageHeader> = (props) => {
<meta name="description" content={description} />
</>
);
};
}
@@ -1,6 +1,4 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Button } from "@plane/propel/button";
// assets
@@ -8,7 +6,7 @@ import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import InstanceFailureDarkImage from "@/app/assets/instance/instance-failure-dark.svg?url";
import InstanceFailureImage from "@/app/assets/instance/instance-failure.svg?url";
export const InstanceFailureView: React.FC = observer(() => {
export const InstanceFailureView = observer(function InstanceFailureView() {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
@@ -23,7 +21,7 @@ export const InstanceFailureView: React.FC = observer(() => {
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
<div className="relative flex flex-col justify-center items-center space-y-4">
<Image src={instanceImage} alt="Plane Logo" />
<img src={instanceImage} alt="Instance failure illustration" />
<h3 className="font-medium text-2xl text-white text-center">Unable to fetch instance details.</h3>
<p className="font-medium text-base text-center">
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
@@ -1,8 +1,8 @@
"use client";
export const FormHeader = ({ heading, subHeading }: { heading: string; subHeading: string }) => (
<div className="flex flex-col gap-1">
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
</div>
);
export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-2xl font-semibold text-custom-text-100 leading-7">{heading}</span>
<span className="text-lg font-semibold text-custom-text-400 leading-7">{subHeading}</span>
</div>
);
}
@@ -1,29 +1,28 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@plane/propel/button";
// assets
import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url";
export const InstanceNotReady: React.FC = () => (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-custom-text-400">
Get started by setting up your instance and workspace
</p>
</div>
export function InstanceNotReady() {
return (
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<img src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-custom-text-400">
Get started by setting up your instance and workspace
</p>
</div>
<div>
<Link href={"/setup/?auth_enabled=0"}>
<Button size="lg" className="w-full">
Get started
</Button>
</Link>
<div>
<Link href={"/setup/?auth_enabled=0"}>
<Button size="lg" className="w-full">
Get started
</Button>
</Link>
</div>
</div>
</div>
</div>
);
);
}
@@ -1,17 +1,16 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export const InstanceLoading = () => {
export function InstanceLoading() {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerLight : LogoSpinnerDark;
return (
<div className="flex items-center justify-center">
<Image src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
<img src={logoSrc} alt="logo" className="h-6 w-auto sm:h-11" />
</div>
);
};
}
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
// icons
@@ -53,8 +51,7 @@ const defaultFromData: TFormData = {
is_telemetry_enabled: true,
};
export const InstanceSetupForm: React.FC = (props) => {
const {} = props;
export function InstanceSetupForm() {
// search params
const searchParams = useSearchParams();
const firstNameParam = searchParams.get("first_name") || undefined;
@@ -351,4 +348,4 @@ export const InstanceSetupForm: React.FC = (props) => {
</div>
</>
);
};
}
@@ -1,7 +1,4 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import Link from "next/link";
import { useTheme as useNextTheme } from "next-themes";
// ui
@@ -13,7 +10,7 @@ import TakeoffIconLight from "@/app/assets/logos/takeoff-icon-light.svg?url";
import { useTheme } from "@/hooks/store";
// icons
export const NewUserPopup = observer(() => {
export const NewUserPopup = observer(function NewUserPopup() {
// hooks
const { isNewUserPopup, toggleNewUserPopup } = useTheme();
// theme
@@ -39,7 +36,7 @@ export const NewUserPopup = observer(() => {
</div>
</div>
<div className="shrink-0 flex items-center justify-center">
<Image
<img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
height={80}
width={80}
@@ -11,7 +11,7 @@ type TWorkspaceListItemProps = {
workspaceId: string;
};
export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemProps) => {
export const WorkspaceListItem = observer(function WorkspaceListItem({ workspaceId }: TWorkspaceListItemProps) {
// store hooks
const { getWorkspaceById } = useWorkspace();
// derived values
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router";
+6 -4
View File
@@ -27,7 +27,7 @@
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@react-router/node": "^7.9.3",
"@react-router/node": "catalog:",
"@sentry/react-router": "catalog:",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/virtual-core": "^3.13.12",
@@ -41,8 +41,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
"react-router": "^7.9.1",
"react-router-dom": "^7.9.1",
"react-router": "catalog:",
"react-router-dom": "catalog:",
"serve": "14.2.5",
"swr": "catalog:",
"uuid": "catalog:"
@@ -51,11 +51,13 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@react-router/dev": "^7.9.1",
"@prettier/plugin-oxc": "0.0.4",
"@react-router/dev": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "^16.4.5",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+6 -1
View File
@@ -1,9 +1,12 @@
import path from "node:path";
import { reactRouter } from "@react-router/dev/vite";
import dotenv from "dotenv";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";
dotenv.config({ path: path.resolve(__dirname, ".env") });
// Expose only vars starting with VITE_
const viteEnv = Object.keys(process.env)
.filter((k) => k.startsWith("VITE_"))
@@ -26,11 +29,13 @@ export default defineConfig(() => ({
resolve: {
alias: {
// Next.js compatibility shims used within admin
"next/image": path.resolve(__dirname, "app/compat/next/image.tsx"),
"next/link": path.resolve(__dirname, "app/compat/next/link.tsx"),
"next/navigation": path.resolve(__dirname, "app/compat/next/navigation.ts"),
},
dedupe: ["react", "react-dom"],
},
server: {
host: "127.0.0.1",
},
// No SSR-specific overrides needed; alias resolves to ESM build
}));
+47 -1
View File
@@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue
from plane.db.models import IntakeIssue, Issue, State, StateGroup
from rest_framework import serializers
@@ -103,6 +103,52 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
"""
Update intake issue and transition associated issue state if accepted.
"""
# Update the intake issue with validated data
instance = super().update(instance, validated_data)
# If status is accepted (1), update the associated issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
class IssueDataSerializer(serializers.Serializer):
"""
+5 -1
View File
@@ -1,6 +1,7 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import State
from plane.db.models import State, StateGroup
from rest_framework import serializers
class StateSerializer(BaseSerializer):
@@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
if data.get("group", None) == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return data
class Meta:
+63 -60
View File
@@ -23,7 +23,7 @@ from plane.api.serializers import (
)
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, StateGroup
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
@@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -172,6 +186,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state_id=triage_state.id,
)
# create an intake issue
@@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
intake_serializer = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
@@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=(intake_issue.id),
)
issue_serializer.save()
else:
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Both serializers are valid, now save them
if issue_serializer:
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=str(intake_issue.id),
)
issue_serializer.save()
# Save intake issue (state transition happens in serializer's update method)
if intake_serializer:
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer.save()
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
else:
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
+2 -36
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
DeployBoard,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
UserFavorite,
)
@@ -232,41 +233,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
user_id=serializer.instance.project_lead,
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -279,7 +245,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
default=state.get("default", False),
created_by=request.user,
)
for state in states
for state in DEFAULT_STATES
]
)
@@ -37,6 +37,7 @@ from .project import (
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
+44 -1
View File
@@ -7,7 +7,7 @@ from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerial
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Intake, IntakeIssue, Issue
from plane.db.models import Intake, IntakeIssue, Issue, StateGroup, State
class IntakeSerializer(BaseSerializer):
@@ -36,6 +36,49 @@ class IntakeIssueSerializer(BaseSerializer):
]
read_only_fields = ["project", "workspace"]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
# Update the intake issue
instance = super().update(instance, validated_data)
# If status is accepted (1), transition the issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
+26 -2
View File
@@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
class IssueCreateSerializer(BaseSerializer):
# ids
state_id = serializers.PrimaryKeyRelatedField(
source="state", queryset=State.objects.all(), required=False, allow_null=True
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
@@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
return data
def validate(self, attrs):
allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
@@ -160,7 +163,7 @@ class IssueCreateSerializer(BaseSerializer):
# Check state is from the project only else raise validation error
if (
attrs.get("state")
and not State.objects.filter(
and not state_manager.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
@@ -364,6 +367,19 @@ class LabelSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
def validate_name(self, value):
project_id = self.context.get("project_id")
label = Label.objects.filter(project_id=project_id, name__iexact=value)
if self.instance:
label = label.exclude(id=self.instance.pk)
if label.exists():
raise serializers.ValidationError(detail="LABEL_NAME_ALREADY_EXISTS")
return value
class LabelLiteSerializer(BaseSerializer):
class Meta:
@@ -782,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
def validate(self, data):
if (
data.get("state_id")
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
):
raise serializers.ValidationError("State is not valid please pass a valid state_id")
return data
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
+12
View File
@@ -142,6 +142,18 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberPreferenceSerializer(BaseSerializer):
class Meta:
model = ProjectMember
fields = ["preferences", "project_id", "member_id", "workspace_id"]
def validate_preferences(self, value):
preferences = self.instance.preferences
preferences.update(value)
return preferences
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
+6 -1
View File
@@ -2,7 +2,7 @@
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State
from plane.db.models import State, StateGroup
class StateSerializer(BaseSerializer):
@@ -24,6 +24,11 @@ class StateSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
def validate(self, attrs):
if attrs.get("group") == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return attrs
class StateLiteSerializer(BaseSerializer):
class Meta:
+6
View File
@@ -13,6 +13,7 @@ from plane.app.views import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -91,6 +92,11 @@ urlpatterns = [
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/duplicate-assets/<uuid:asset_id>/",
DuplicateAssetEndpoint.as_view(),
name="duplicate-assets",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
+6
View File
@@ -14,6 +14,7 @@ from plane.app.views import (
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
)
@@ -125,4 +126,9 @@ urlpatterns = [
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
ProjectMemberPreferenceEndpoint.as_view(),
name="project-member-preference",
),
]
+6 -1
View File
@@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import StateViewSet
from plane.app.views import StateViewSet, IntakeStateEndpoint
urlpatterns = [
@@ -15,6 +15,11 @@ urlpatterns = [
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
IntakeStateEndpoint.as_view(),
name="intake-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
StateViewSet.as_view({"post": "mark_as_default"}),
+10
View File
@@ -30,6 +30,16 @@ urlpatterns = [
UserEndpoint.as_view({"get": "retrieve_user_settings"}),
name="users",
),
path(
"users/me/email/generate-code/",
UserEndpoint.as_view({"post": "generate_email_verification_code"}),
name="user-email-verify-code",
),
path(
"users/me/email/",
UserEndpoint.as_view({"patch": "update_email"}),
name="user-email-update",
),
# Profile
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
# End profile
-5
View File
@@ -253,9 +253,4 @@ urlpatterns = [
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]
+3 -1
View File
@@ -18,6 +18,7 @@ from .project.member import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)
from .user.base import (
@@ -79,7 +80,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .state.base import StateViewSet
from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import (
WorkspaceViewViewSet,
WorkspaceViewIssuesViewSet,
@@ -107,6 +108,7 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
DuplicateAssetEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
+153 -18
View File
@@ -19,6 +19,7 @@ from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
class UserAssetsV2Endpoint(BaseAPIView):
@@ -44,7 +45,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new avatar
user.avatar_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -62,7 +65,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Save the new cover image
user.cover_image_asset_id = asset_id
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -78,7 +83,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.avatar_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -91,7 +98,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
user = User.objects.get(id=asset.user_id)
user.cover_image_asset_id = None
user.save()
invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request)
invalidate_cache_directly(
path="/api/users/me/", url_params=False, user=True, request=request
)
invalidate_cache_directly(
path="/api/users/me/settings/",
url_params=False,
@@ -151,7 +160,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -188,7 +199,9 @@ class UserAssetsV2Endpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -252,14 +265,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace.logo = ""
workspace.logo_asset_id = asset_id
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return
# Project Cover
@@ -286,14 +303,18 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
return
workspace.logo_asset_id = None
workspace.save()
invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/workspaces/", url_params=False, user=False, request=request
)
invalidate_cache_directly(
path="/api/users/me/workspaces/",
url_params=False,
user=True,
request=request,
)
invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request)
invalidate_cache_directly(
path="/api/instances/", url_params=False, user=False, request=request
)
return
# Project Cover
elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
@@ -354,13 +375,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier),
**self.get_entity_id_field(
entity_type=entity_type, entity_id=entity_identifier
),
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -397,7 +422,9 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
self.entity_asset_delete(
entity_type=asset.entity_type, asset=asset, request=request
)
asset.save(update_fields=["is_deleted", "deleted_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -560,7 +587,9 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit)
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
@@ -590,7 +619,9 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
@@ -601,7 +632,9 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
asset = FileAsset.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Check if the asset is uploaded
if not asset.is_uploaded:
@@ -634,7 +667,9 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
# Check if the asset ids are provided
if not asset_ids:
return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST
)
# get the asset id
assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug)
@@ -688,10 +723,110 @@ class AssetCheckEndpoint(BaseAPIView):
@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()
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 DuplicateAssetEndpoint(BaseAPIView):
throttle_classes = [AssetRateThrottle]
def get_entity_id_field(self, entity_type, entity_id):
# Workspace Logo
if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO:
return {"workspace_id": entity_id}
# Project Cover
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER:
return {"project_id": entity_id}
# User Avatar and Cover
if entity_type in [
FileAsset.EntityTypeContext.USER_AVATAR,
FileAsset.EntityTypeContext.USER_COVER,
]:
return {"user_id": entity_id}
# Issue Attachment and Description
if entity_type in [
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
]:
return {"issue_id": entity_id}
# Page Description
if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
return {"page_id": entity_id}
# Comment Description
if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
return {"comment_id": entity_id}
return {}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
entity_type = request.data.get("entity_type", None)
if (
not entity_type
or entity_type not in FileAsset.EntityTypeContext.values
):
return Response(
{"error": "Invalid entity type or entity id"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response(
{"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND
)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(
workspace=workspace, id=asset_id, is_uploaded=True
).first()
if not original_asset:
return Response(
{"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
)
destination_key = (
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
)
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=request.user.id,
entity_type=entity_type,
project_id=project_id if project_id else None,
storage_metadata=original_asset.storage_metadata,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
)
storage.copy_object(original_asset.asset, destination_key)
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
return Response(
{"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK
)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
+96 -97
View File
@@ -22,6 +22,7 @@ from plane.db.models import (
IntakeIssue,
Issue,
State,
StateGroup,
IssueLink,
FileAsset,
Project,
@@ -228,14 +229,30 @@ class IntakeIssueViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# create an issue
project = Project.objects.get(pk=project_id)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
request.data["issue"]["state_id"] = triage_state.id
# create an issue
serializer = IssueCreateSerializer(
data=request.data.get("issue"),
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
"allow_triage_state": True,
},
)
if serializer.is_valid():
@@ -344,6 +361,12 @@ class IntakeIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
issue = None
issue_current_instance = None
issue_requested_data = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -371,119 +394,95 @@ class IntakeIssueViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
)
if issue_serializer.is_valid():
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
issue_serializer.save()
else:
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins can edit intake issue attributes
# Validate intake issue data if user has permission
intake_serializer = None
intake_current_instance = None
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
# Both serializers are valid, now save them
if issue_serializer:
issue_serializer.save()
# Log all the updates
if issue is not None:
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
notification=True,
origin=base_host(request=request, is_app=True),
intake=(intake_issue.id),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
if intake_serializer:
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=intake_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# Fetch and return the updated intake issue
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True)
& Q(issue__issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
def retrieve(self, request, slug, project_id, pk):
+16 -3
View File
@@ -39,7 +39,9 @@ class LabelViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
try:
serializer = LabelSerializer(data=request.data)
serializer = LabelSerializer(
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -64,8 +66,18 @@ class LabelViewSet(BaseViewSet):
{"error": "Label with the same name already exists in the project"},
status=status.HTTP_400_BAD_REQUEST,
)
# call the parent method to perform the update
return super().partial_update(request, *args, **kwargs)
serializer = LabelSerializer(
instance=self.get_object(),
data=request.data,
context={"project_id": kwargs["project_id"]},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
@@ -77,6 +89,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def post(self, request, slug, project_id):
label_data = request.data.get("label_data", [])
project = Project.objects.get(pk=project_id)
labels = Label.objects.bulk_create(
+74 -21
View File
@@ -149,14 +149,24 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, page_id):
try:
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
if page.is_locked:
return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST)
parent = request.data.get("parent", None)
if parent:
_ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id)
_ = Page.objects.get(
pk=parent,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -230,14 +240,24 @@ class PageViewSet(BaseViewSet):
return Response(data, status=status.HTTP_200_OK)
def lock(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page.is_locked = False
page.save()
@@ -246,7 +266,12 @@ class PageViewSet(BaseViewSet):
def access(self, request, slug, project_id, page_id):
access = request.data.get("access", 0)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -277,7 +302,12 @@ class PageViewSet(BaseViewSet):
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# only the owner or admin can archive the page
if (
@@ -303,7 +333,12 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# only the owner or admin can un archive the page
if (
@@ -327,7 +362,12 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
if page.archived_at is None:
return Response(
@@ -350,7 +390,12 @@ class PageViewSet(BaseViewSet):
)
# remove parent from all the children
_ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None)
_ = Page.objects.filter(
parent_id=page_id,
projects__id=project_id,
workspace__slug=slug,
project_pages__deleted_at__isnull=True,
).update(parent=None)
page.delete()
# Delete the user favorite page
@@ -451,12 +496,14 @@ class PagesDescriptionViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
if page is None:
return Response({"error": "Page not found"}, status=404)
binary_data = page.description_binary
def stream_data():
@@ -471,14 +518,15 @@ class PagesDescriptionViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
if page is None:
return Response({"error": "Page not found"}, status=404)
if page.is_locked:
return Response(
{
@@ -529,7 +577,12 @@ class PageDuplicateEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# check for permission
if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id:
+2 -36
View File
@@ -31,6 +31,7 @@ from plane.db.models import (
ProjectIdentifier,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
@@ -264,41 +265,6 @@ class ProjectViewSet(BaseViewSet):
user_id=serializer.data["project_lead"],
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -311,7 +277,7 @@ class ProjectViewSet(BaseViewSet):
default=state.get("default", False),
created_by=request.user,
)
for state in states
for state in DEFAULT_STATES
]
)

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