Compare commits

..

40 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
278 changed files with 9344 additions and 2012 deletions
@@ -51,8 +51,7 @@ const defaultFromData: TFormData = {
is_telemetry_enabled: true,
};
export function InstanceSetupForm(props) {
const {} = props;
export function InstanceSetupForm() {
// search params
const searchParams = useSearchParams();
const firstNameParam = searchParams.get("first_name") || undefined;
+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 -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
+1 -1
View File
@@ -80,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,
+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
]
)
+11 -15
View File
@@ -8,6 +8,7 @@ from plane.app.serializers import (
ProjectMemberSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from plane.app.permissions import WorkspaceUserPermission
@@ -303,7 +304,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_project_member(self, slug, project_id, member_id):
def get_queryset(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
@@ -312,25 +313,20 @@ class ProjectMemberPreferenceEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
project_member = self.get_queryset(slug, project_id, member_id)
current_preferences = project_member.preferences or {}
current_preferences["navigation"] = request.data["navigation"]
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
project_member.preferences = current_preferences
project_member.save(update_fields=["preferences"])
if serializer.is_valid():
serializer.save()
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
return Response({"preferences": serializer.data["preferences"]}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
project_member = self.get_queryset(slug, project_id, member_id)
response = {
"preferences": project_member.preferences,
"project_id": project_member.project_id,
"member_id": project_member.member_id,
"workspace_id": project_member.workspace_id,
}
serializer = ProjectMemberPreferenceSerializer(project_member)
return Response(response, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
+14 -1
View File
@@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import StateSerializer
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import State, Issue
@@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeStateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
if not state:
return Response(
{"error": "Triage state not found"},
status=status.HTTP_404_NOT_FOUND,
)
return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
+186 -3
View File
@@ -1,10 +1,20 @@
# Python imports
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
from django.db.models import Case, Count, IntegerField, Q, When
from django.contrib.auth import logout
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from django.core.validators import validate_email
from django.core.cache import cache
# Third party imports
from rest_framework import status
@@ -36,9 +46,11 @@ from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
from plane.authentication.rate_limit import EmailVerificationThrottle
logger = logging.getLogger("plane")
class UserEndpoint(BaseViewSet):
@@ -49,6 +61,14 @@ class UserEndpoint(BaseViewSet):
def get_object(self):
return self.request.user
def get_throttles(self):
"""
Apply rate limiting to specific endpoints.
"""
if self.action == "generate_email_verification_code":
return [EmailVerificationThrottle()]
return super().get_throttles()
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve(self, request):
@@ -69,6 +89,169 @@ class UserEndpoint(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def _validate_new_email(self, user, new_email):
"""
Validate the new email address.
Args:
user: The User instance
new_email: The new email address to validate
Returns:
Response object with error if validation fails, None if validation passes
"""
if not new_email:
return Response(
{"error": "Email is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Validate email format
try:
validate_email(new_email)
except Exception:
return Response(
{"error": "Invalid email format"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if email is the same as current email
if new_email == user.email:
return Response(
{"error": "New email must be different from current email"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if email already exists in the User model
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
return Response(
{"error": "An account with this email already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
return None
def generate_email_verification_code(self, request):
"""
Generate and send a magic code to the new email address for verification.
Rate limited to 3 requests per hour per user (enforced by EmailVerificationThrottle).
Additional per-email cooldown of 60 seconds prevents rapid repeated requests.
"""
user = self.get_object()
new_email = request.data.get("email", "").strip().lower()
# Validate the new email
validation_error = self._validate_new_email(user, new_email)
if validation_error:
return validation_error
try:
# Generate magic code for email verification
# Use a special key prefix to distinguish from regular magic signin
# Include user ID to bind the code to the specific user
cache_key = f"magic_email_update_{user.id}_{new_email}"
## Generate a random token
token = (
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
)
# Store in cache with 10 minute expiration
cache_data = json.dumps({"token": token})
cache.set(cache_key, cache_data, timeout=600)
# Send magic code to the new email
send_email_update_magic_code.delay(new_email, token)
return Response(
{"message": "Verification code sent to email"},
status=status.HTTP_200_OK,
)
except Exception as e:
logger.error("Failed to generate verification code: %s", str(e), exc_info=True)
return Response(
{"error": "Failed to generate verification code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
)
def update_email(self, request):
"""
Verify the magic code and update the user's email address.
This endpoint verifies the code and updates the existing user record
without creating a new user, ensuring the user ID remains unchanged.
"""
user = self.get_object()
new_email = request.data.get("email", "").strip().lower()
code = request.data.get("code", "").strip()
# Validate the new email
validation_error = self._validate_new_email(user, new_email)
if validation_error:
return validation_error
if not code:
return Response(
{"error": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the magic code
try:
cache_key = f"magic_email_update_{user.id}_{new_email}"
cached_data = cache.get(cache_key)
if not cached_data:
logger.warning("Cache key not found: %s. Code may have expired or was never generated.", cache_key)
return Response(
{"error": "Verification code has expired or is invalid"},
status=status.HTTP_400_BAD_REQUEST,
)
data = json.loads(cached_data)
stored_token = data.get("token")
if str(stored_token) != str(code):
return Response(
{"error": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response(
{"error": "Failed to verify code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Final check: ensure email is still available (might have been taken between code generation and update)
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
return Response(
{"error": "An account with this email already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
old_email = user.email
# Update the email - this updates the existing user record without creating a new user
user.email = new_email
# Reset email verification status when email is changed
user.is_email_verified = False
user.save()
# delete the cache
cache.delete(cache_key)
# Logout the user
logout(request)
# Send confirmation email to the new email address
send_email_update_confirmation.delay(new_email)
# send the email to the old email address
send_email_update_confirmation.delay(old_email)
# Return updated user data
serialized_data = UserMeSerializer(user).data
return Response(serialized_data, status=status.HTTP_200_OK)
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
+13 -10
View File
@@ -249,23 +249,26 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
def patch(self, request, slug):
workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug)
workspace = Workspace.objects.get(slug=slug)
workspace_properties.filters = request.data.get("filters", workspace_properties.filters)
workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters)
workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters)
workspace_properties.display_properties = request.data.get(
"display_properties", workspace_properties.display_properties
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace_id=workspace.id
)
workspace_properties.save()
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace__slug=slug
user=request.user, workspace=workspace
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -39,6 +39,16 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
user=request.user,
workspace=workspace,
sort_order=(65535 + (i * 10000)),
is_pinned=(
True
if key
in [
WorkspaceUserPreference.UserPreferenceKeys.DRAFTS,
WorkspaceUserPreference.UserPreferenceKeys.YOUR_WORK,
WorkspaceUserPreference.UserPreferenceKeys.STICKIES,
]
else False
),
)
for i, key in enumerate(create_preference_keys)
],
+20 -1
View File
@@ -1,5 +1,5 @@
# Third party imports
from rest_framework.throttling import AnonRateThrottle
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from rest_framework import status
from rest_framework.response import Response
@@ -22,3 +22,22 @@ class AuthenticationThrottle(AnonRateThrottle):
)
except AuthenticationException as e:
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
class EmailVerificationThrottle(UserRateThrottle):
"""
Throttle for email verification code generation.
Limits to 3 requests per hour per user to prevent abuse.
"""
rate = "3/hour"
scope = "email_verification"
def throttle_failure_view(self, request, *args, **kwargs):
try:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
error_message="RATE_LIMIT_EXCEEDED",
)
except AuthenticationException as e:
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
+4 -1
View File
@@ -17,6 +17,7 @@ from plane.db.models import (
Project,
ProjectMember,
State,
StateGroup,
Label,
Cycle,
Module,
@@ -264,7 +265,9 @@ def create_issues(workspace, project, user_id, issue_count):
Faker.seed(0)
states = (
State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
State.objects.filter(workspace=workspace, project=project)
.exclude(group=StateGroup.TRIAGE.value)
.values_list("id", flat=True)
)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
@@ -0,0 +1,110 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Module imports
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception
@shared_task
def send_email_update_magic_code(email, token):
try:
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_USE_SSL,
EMAIL_FROM,
) = get_email_configuration()
# Send the mail
subject = "Verify your new email address"
context = {"code": token, "email": email}
html_content = render_to_string("emails/auth/magic_signin.html", context)
text_content = strip_tags(html_content)
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
use_ssl=EMAIL_USE_SSL == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
return
@shared_task
def send_email_update_confirmation(email):
"""
Send a confirmation email to the user after their email address has been successfully updated.
Args:
email: The new email address that was successfully updated
"""
try:
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_USE_SSL,
EMAIL_FROM,
) = get_email_configuration()
# Send the confirmation email
subject = "Plane email address successfully updated"
context = {"email": email}
html_content = render_to_string("emails/user/email_updated.html", context)
text_content = strip_tags(html_content)
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
use_ssl=EMAIL_USE_SSL == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
return
except Exception as e:
log_exception(e)
return
@@ -0,0 +1,49 @@
# Django imports
from django.core.management.base import BaseCommand
from django.db import transaction
# Module imports
from plane.db.models import Description
from plane.db.models import IssueComment
class Command(BaseCommand):
help = "Create Description records for existing IssueComment"
def handle(self, *args, **kwargs):
batch_size = 500
while True:
comments = list(
IssueComment.objects.filter(description_id__isnull=True).order_by("created_at")[:batch_size]
)
if not comments:
break
with transaction.atomic():
descriptions = [
Description(
created_at=comment.created_at,
updated_at=comment.updated_at,
description_json=comment.comment_json,
description_html=comment.comment_html,
description_stripped=comment.comment_stripped,
project_id=comment.project_id,
created_by_id=comment.created_by_id,
updated_by_id=comment.updated_by_id,
workspace_id=comment.workspace_id,
)
for comment in comments
]
created_descriptions = Description.objects.bulk_create(descriptions)
comments_to_update = []
for comment, description in zip(comments, created_descriptions):
comment.description_id = description.id
comments_to_update.append(comment)
IssueComment.objects.bulk_update(comments_to_update, ["description_id"])
self.stdout.write(self.style.SUCCESS("Successfully Copied IssueComment to Description"))
@@ -0,0 +1,24 @@
# Generated by Django 4.2.22 on 2025-11-06 08:28
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0108_alter_issueactivity_issue_comment'),
]
operations = [
migrations.AddField(
model_name='issuecomment',
name='description',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_comment_description', to='db.description'),
),
migrations.AddField(
model_name='issuecomment',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue_comment', to='db.issuecomment'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.26 on 2025-11-21 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0109_issuecomment_description_and_parent_id'),
]
operations = [
migrations.AddField(
model_name='workspaceuserproperties',
name='navigation_control_preference',
field=models.CharField(choices=[('ACCORDION', 'Accordion'), ('TABBED', 'Tabbed')], default='ACCORDION', max_length=25),
),
migrations.AddField(
model_name='workspaceuserproperties',
name='navigation_project_limit',
field=models.IntegerField(default=10),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 4.2.22 on 2025-09-29 15:36
from django.db import migrations, models
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False
dependencies = [
('db', '0110_workspaceuserproperties_navigation_control_preference_and_more'),
]
operations = [
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['receiver', 'workspace', 'read_at', 'created_at'], name='notif_receiver_status_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['receiver', 'workspace', 'entity_name', 'read_at'], name='notif_receiver_entity_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['receiver', 'workspace', 'snoozed_till', 'archived_at'], name='notif_receiver_state_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['receiver', 'workspace', 'sender'], name='notif_receiver_sender_idx'),
),
AddIndexConcurrently(
model_name='notification',
index=models.Index(fields=['workspace', 'entity_identifier', 'entity_name'], name='notif_entity_lookup_idx'),
),
AddIndexConcurrently(
model_name='fileasset',
index=models.Index(fields=['asset'], name='asset_asset_idx'),
),
]
@@ -0,0 +1,92 @@
# Generated by Django 4.2.25 on 2025-11-24 06:03
from django.db import migrations, transaction
from django.db.models import OuterRef, Subquery
import logging
logger = logging.getLogger("plane.migrations")
BATCH_SIZE = 4000
def create_triage_state(apps, _schema_editor):
Project = apps.get_model("db", "Project")
State = apps.get_model("db", "State")
Issue = apps.get_model("db", "Issue")
# 1) Bulk-update existing triage states
triage_qs = State.objects.filter(group="triage")
projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
triage_qs.update(
name="Triage",
color="#4E5355",
sequence=65000,
default=False,
)
logger.info(f"Updated {triage_qs.count()} triage states.")
# 2) Projects that already have a 'Triage' name but not in triage group
projects_to_update_qs = (
State.objects.exclude(group="triage")
.filter(name="Triage")
.values_list("project_id", flat=True)
)
projects_to_update = set(projects_to_update_qs)
logger.info(f"Projects to update: {len(projects_to_update)}")
# 3) Create missing triage states in chunks to avoid memory spike
states_to_create = []
project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
for proj_id, workspace_id in project_iter:
if proj_id in projects_with_triage_state:
continue
if proj_id in projects_to_update:
name = f"Triage-{str(proj_id)[:5]}"
else:
name = "Triage"
states_to_create.append(
State(
name=name,
group="triage",
project_id=proj_id,
workspace_id=workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
)
if len(states_to_create) >= BATCH_SIZE:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
states_to_create = []
if states_to_create:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
# 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
with transaction.atomic():
triage_state_subquery = (
State.objects.filter(
group="triage",
project_id=OuterRef("project_id"),
workspace_id=OuterRef("workspace_id"),
)
.values("id")[:1]
)
updated_count = Issue._default_manager.filter(
issue_intake__status__in=[-2, 0],
).update(state_id=Subquery(triage_state_subquery))
logger.info(f"Updated {updated_count} issues.")
class Migration(migrations.Migration):
dependencies = [
('db', '0111_notification_notif_receiver_status_idx_and_more'),
]
operations = [
migrations.RunPython(create_triage_state,
reverse_code=migrations.RunPython.noop),
]
+1 -1
View File
@@ -56,7 +56,7 @@ from .project import (
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
+1
View File
@@ -66,6 +66,7 @@ class FileAsset(BaseModel):
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
models.Index(fields=["entity_identifier"], name="asset_entity_identifier_idx"),
models.Index(fields=["entity_type", "entity_identifier"], name="asset_entity_idx"),
models.Index(fields=["asset"], name="asset_asset_idx"),
]
def __str__(self):
+59 -2
View File
@@ -17,6 +17,9 @@ from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
from .state import StateGroup
def get_default_properties():
@@ -95,6 +98,7 @@ class IssueManager(SoftDeletionManager):
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(state__group=StateGroup.TRIAGE.value)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
@@ -442,10 +446,13 @@ class IssueActivity(ProjectBaseModel):
return str(self.issue)
class IssueComment(ProjectBaseModel):
class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
description = models.OneToOneField(
"db.Description", on_delete=models.CASCADE, related_name="issue_comment_description", null=True
)
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
# System can also create comment
@@ -463,10 +470,60 @@ class IssueComment(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
edited_at = models.DateTimeField(null=True, blank=True)
parent = models.ForeignKey(
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="parent_issue_comment"
)
TRACKED_FIELDS = ["comment_stripped", "comment_json", "comment_html"]
def save(self, *args, **kwargs):
"""
Custom save method for IssueComment that manages the associated Description model.
This method handles creation and updates of both the comment and its description in a
single atomic transaction to ensure data consistency.
"""
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
return super(IssueComment, self).save(*args, **kwargs)
is_creating = self._state.adding
# Prepare description defaults
description_defaults = {
"workspace_id": self.workspace_id,
"project_id": self.project_id,
"created_by_id": self.created_by_id,
"updated_by_id": self.updated_by_id,
"description_stripped": self.comment_stripped,
"description_json": self.comment_json,
"description_html": self.comment_html,
}
with transaction.atomic():
super(IssueComment, self).save(*args, **kwargs)
if is_creating or not self.description_id:
# Create new description for new comment
description = Description.objects.create(**description_defaults)
self.description_id = description.id
super(IssueComment, self).save(update_fields=["description_id"])
else:
field_mapping = {
"comment_html": "description_html",
"comment_stripped": "description_stripped",
"comment_json": "description_json",
}
changed_fields = {
desc_field: getattr(self, comment_field)
for comment_field, desc_field in field_mapping.items()
if self.has_changed(comment_field)
}
# Update description only if comment fields changed
if changed_fields and self.description_id:
Description.objects.filter(pk=self.description_id).update(
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
)
class Meta:
verbose_name = "Issue Comment"
+20
View File
@@ -38,6 +38,26 @@ class Notification(BaseModel):
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
models.Index(fields=["read_at"], name="notif_read_at_idx"),
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
models.Index(
fields=["receiver", "workspace", "read_at", "created_at"],
name="notif_receiver_status_idx",
),
models.Index(
fields=["receiver", "workspace", "entity_name", "read_at"],
name="notif_receiver_entity_idx",
),
models.Index(
fields=["receiver", "workspace", "snoozed_till", "archived_at"],
name="notif_receiver_state_idx",
),
models.Index(
fields=["receiver", "workspace", "sender"],
name="notif_receiver_sender_idx",
),
models.Index(
fields=["workspace", "entity_identifier", "entity_name"],
name="notif_entity_lookup_idx",
),
]
def __str__(self):
+71 -9
View File
@@ -7,6 +7,71 @@ from django.db.models import Q
from .project import ProjectBaseModel
class StateGroup(models.TextChoices):
BACKLOG = "backlog", "Backlog"
UNSTARTED = "unstarted", "Unstarted"
STARTED = "started", "Started"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
TRIAGE = "triage", "Triage"
# Default states
DEFAULT_STATES = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": StateGroup.BACKLOG.value,
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": StateGroup.UNSTARTED.value,
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": StateGroup.STARTED.value,
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": StateGroup.COMPLETED.value,
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": StateGroup.CANCELLED.value,
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": StateGroup.TRIAGE.value,
},
]
class StateManager(models.Manager):
"""Default manager - excludes triage states"""
def get_queryset(self):
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
class TriageStateManager(models.Manager):
"""Manager for triage states only"""
def get_queryset(self):
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
class State(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="State Name")
description = models.TextField(verbose_name="State Description", blank=True)
@@ -14,15 +79,8 @@ class State(ProjectBaseModel):
slug = models.SlugField(max_length=100, blank=True)
sequence = models.FloatField(default=65535)
group = models.CharField(
choices=(
("backlog", "Backlog"),
("unstarted", "Unstarted"),
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage"),
),
default="backlog",
choices=StateGroup.choices,
default=StateGroup.BACKLOG,
max_length=20,
)
is_triage = models.BooleanField(default=False)
@@ -30,6 +88,10 @@ class State(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
objects = StateManager()
all_state_objects = models.Manager()
triage_objects = TriageStateManager()
def __str__(self):
"""Return name of the state"""
return f"{self.name} <{self.project.name}>"
+10
View File
@@ -301,6 +301,10 @@ class WorkspaceTheme(BaseModel):
class WorkspaceUserProperties(BaseModel):
class NavigationControlPreference(models.TextChoices):
ACCORDION = "ACCORDION", "Accordion"
TABBED = "TABBED", "Tabbed"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
@@ -315,6 +319,12 @@ class WorkspaceUserProperties(BaseModel):
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(default=get_default_display_properties)
rich_filters = models.JSONField(default=dict)
navigation_project_limit = models.IntegerField(default=10)
navigation_control_preference = models.CharField(
max_length=25,
choices=NavigationControlPreference.choices,
default=NavigationControlPreference.ACCORDION,
)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
+5
View File
@@ -76,5 +76,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+5
View File
@@ -86,5 +86,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+19 -2
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State, StateGroup
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -121,6 +121,22 @@ class IntakeIssuePublicViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(
project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -128,6 +144,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state_id=triage_state.id,
)
# Create an Issue Activity
@@ -191,7 +208,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id},
context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
)
if issue_serializer.is_valid():
@@ -0,0 +1,286 @@
import pytest
from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
@pytest.fixture
def workspace(create_user):
"""Create a test workspace"""
return Workspace.objects.create(
name="Test Workspace",
slug="test-workspace",
owner=create_user,
)
@pytest.fixture
def project(workspace, create_user):
"""Create a test project"""
return Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)
@pytest.fixture
def state(project):
"""Create a test state"""
return State.objects.create(
name="Todo",
project=project,
group="backlog",
default=True,
)
@pytest.fixture
def issue(workspace, project, state, create_user):
"""Create a test issue"""
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
state=state,
created_by=create_user,
)
@pytest.mark.unit
class TestIssueCommentModel:
"""Test the IssueComment model"""
@pytest.mark.django_db
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
"""Test that creating a comment automatically creates a description"""
# Arrange
comment_html = "<p>This is a test comment</p>"
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.id is not None
assert issue_comment.comment_stripped == "This is a test comment"
assert issue_comment.description_id is not None
# Verify description was created
description = Description.objects.get(pk=issue_comment.description_id)
assert description is not None
assert description.description_html == comment_html
assert description.description_json == comment_json
assert description.description_stripped == "This is a test comment"
assert description.workspace_id == workspace.id
assert description.project_id == project.id
@pytest.mark.django_db
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
"""Test that updating a comment updates its associated description"""
# Arrange - Create initial comment
initial_html = "<p>Initial comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Update the comment
updated_html = "<p>Updated comment</p>"
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
issue_comment.comment_html = updated_html
issue_comment.comment_json = updated_json
issue_comment.save()
# Assert
# Refresh from database
issue_comment.refresh_from_db()
updated_description = Description.objects.get(pk=initial_description_id)
# Verify comment was updated
assert issue_comment.comment_stripped == "Updated comment"
assert issue_comment.description_id == initial_description_id # Same description object
# Verify description was updated
assert updated_description.description_html == updated_html
assert updated_description.description_json == updated_json
assert updated_description.description_stripped == "Updated comment"
@pytest.mark.django_db
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
"""Test that only changed fields are updated in description"""
# Arrange - Create initial comment
initial_html = "<p>Initial comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Update only the HTML (not JSON)
updated_html = "<p>Updated comment only HTML</p>"
issue_comment.comment_html = updated_html
# comment_json remains the same
issue_comment.save()
# Assert
updated_description = Description.objects.get(pk=initial_description_id)
# Verify HTML was updated
assert updated_description.description_html == updated_html
assert updated_description.description_stripped == "Updated comment only HTML"
# Verify JSON remained the same
assert updated_description.description_json == initial_json
@pytest.mark.django_db
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
"""Test that description is not updated when comment content doesn't change"""
# Arrange - Create initial comment
initial_html = "<p>Test comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
initial_description_id = issue_comment.description_id
# Act - Save without changing content
issue_comment.save()
# Assert
updated_description = Description.objects.get(pk=initial_description_id)
# Verify description was not updated (updated_at should be the same)
# Note: This test assumes updated_at is not changed when no fields change
assert updated_description.description_html == initial_html
assert updated_description.description_json == initial_json
assert updated_description.description_stripped == "Test comment"
@pytest.mark.django_db
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
# Arrange - Create comment and manually remove description (simulating legacy data)
initial_html = "<p>Legacy comment</p>"
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=initial_html,
comment_json=initial_json,
created_by=create_user,
updated_by=create_user,
)
# Simulate legacy data by removing the description
if issue_comment.description_id:
Description.objects.filter(pk=issue_comment.description_id).delete()
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
issue_comment.refresh_from_db()
assert issue_comment.description_id is None
# Act - Update the comment
updated_html = "<p>Updated legacy comment</p>"
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
issue_comment.comment_html = updated_html
issue_comment.comment_json = updated_json
issue_comment.save()
# Assert
issue_comment.refresh_from_db()
# Verify description was created
assert issue_comment.description_id is not None
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_html == updated_html
assert description.description_json == updated_json
assert description.description_stripped == "Updated legacy comment"
@pytest.mark.django_db
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
"""Test that HTML tags are properly stripped from comment_html"""
# Arrange
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
comment_json = {"type": "doc", "content": []}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.comment_stripped == "This is bold and italic text"
# Verify description has the same stripped content
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_stripped == "This is bold and italic text"
@pytest.mark.django_db
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
"""Test that empty HTML results in empty comment_stripped"""
# Arrange
comment_html = ""
comment_json = {"type": "doc", "content": []}
# Act
issue_comment = IssueComment.objects.create(
workspace=workspace,
project=project,
issue=issue,
comment_html=comment_html,
comment_json=comment_json,
created_by=create_user,
updated_by=create_user,
)
# Assert
assert issue_comment.comment_stripped == ""
# Verify description was created with empty stripped content
description = Description.objects.get(pk=issue_comment.description_id)
assert description.description_stripped is None
@@ -0,0 +1,43 @@
import pytest
from plane.app.serializers import LabelSerializer
from plane.db.models import Project, Label
@pytest.mark.unit
class TestLabelSerializer:
"""Test the LabelSerializer"""
@pytest.mark.django_db
def test_label_serializer_create_valid_data(self, db, workspace):
"""Test creating a label with valid data"""
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
serializer = LabelSerializer(
data={"name": "Test Label"},
context={"project_id": project.id},
)
assert serializer.is_valid()
assert serializer.errors == {}
serializer.save(project_id=project.id)
label = Label.objects.all().first()
assert label.name == "Test Label"
assert label.project == project
assert label
@pytest.mark.django_db
def test_label_serializer_create_duplicate_name(self, db, workspace):
"""Test creating a label with a duplicate name"""
project = Project.objects.create(
name="Test Project", identifier="TEST", workspace=workspace
)
Label.objects.create(name="Test Label", project=project)
serializer = LabelSerializer(
data={"name": "Test Label"}, context={"project_id": project.id}
)
assert not serializer.is_valid()
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
+169 -27
View File
@@ -1,10 +1,16 @@
# Python imports
import json
from django.core.exceptions import ValidationError
# Django imports
from django.db.models import Q
from django.http import QueryDict
# Third party imports
from django_filters.utils import translate_validation
from rest_framework import filters
from rest_framework.exceptions import ValidationError as DRFValidationError
from plane.utils.exception_logger import log_exception
class ComplexFilterBackend(filters.BaseFilterBackend):
@@ -35,12 +41,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
normalized = self._normalize_filter_data(filter_string, "filter")
return self._apply_json_filter(queryset, normalized, view)
except ValidationError:
except DRFValidationError:
# Propagate validation errors unchanged
raise
except Exception as e:
# Convert unexpected errors to ValidationError to keep response consistent
raise ValidationError(f"Filter error: {str(e)}")
log_exception(e)
raise
def _normalize_filter_data(self, raw_filter, source_label):
"""Return a dict from raw filter input or raise a ValidationError.
@@ -53,9 +59,19 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
return json.loads(raw_filter)
if isinstance(raw_filter, dict):
return raw_filter
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
raise DRFValidationError(
{
"message": f"'{source_label}' must be a dict or a JSON string.",
"code": "invalid_filter_type",
}
)
except json.JSONDecodeError:
raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.")
raise DRFValidationError(
{
"message": (f"Invalid JSON for '{source_label}'. Expected a valid JSON object."),
"code": "invalid_json",
}
)
def _apply_json_filter(self, queryset, filter_data, view):
"""Process a JSON filter structure using Q object composition."""
@@ -83,7 +99,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None
if not allowed_fields:
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
raise ValidationError("Filtering is not enabled for this endpoint (missing filterset_class)")
raise DRFValidationError(
{
"message": ("Filtering is not enabled for this endpoint (missing filterset_class)"),
"code": "filtering_not_enabled",
}
)
# Extract field names from the filter data
fields = self._extract_field_names(filter_data)
@@ -94,7 +115,25 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Example: 'sequence_id__gte' should be declared in base_filters
# Special-case __range: require the '<base>__range' filter itself
if field not in allowed_fields:
raise ValidationError(f"Filtering on field '{field}' is not allowed")
raise DRFValidationError(
{
"message": f"Filtering on field '{field}' is not allowed",
"code": "invalid_filter_field",
}
)
def _transform_field_name_for_validation(self, field_name):
"""Hook: Transform a field name before validation.
Override this in subclasses to handle special field naming conventions.
Args:
field_name: The original field name from the filter data
Returns:
The transformed field name to validate against the FilterSet
"""
return field_name
def _extract_field_names(self, filter_data):
"""Extract all field names from a nested filter structure"""
@@ -112,8 +151,9 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
for item in value:
fields.extend(self._extract_field_names(item))
else:
# This is a field name
fields.append(key)
# This is a field name - apply transformation hook
transformed_field = self._transform_field_name_for_validation(key)
fields.append(transformed_field)
return fields
return []
@@ -171,6 +211,23 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Leaf dict: evaluate via FilterSet to get a Q object
return self._build_leaf_q(node, view, queryset)
def _preprocess_leaf_conditions(self, leaf_conditions, view, queryset):
"""Hook: Preprocess leaf conditions before building Q object.
Override this in subclasses to transform filter keys/values.
For example, custom property filters might need to be transformed
from 'customproperty_<id>__<lookup>' to 'customproperty_value__<lookup>'.
Args:
leaf_conditions: Dict of field filters
view: The view instance
queryset: The queryset being filtered
Returns:
Dict of transformed field filters
"""
return leaf_conditions
def _build_leaf_q(self, leaf_conditions, view, queryset):
"""Build a Q object from leaf filter conditions using the view's FilterSet.
@@ -186,11 +243,19 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Get the filterset class from the view
filterset_class = getattr(view, "filterset_class", None)
if not filterset_class:
raise ValidationError("Filtering requires a filterset_class to be defined on the view")
raise DRFValidationError(
{
"message": ("Filtering requires a filterset_class to be defined on the view"),
"code": "filterset_missing",
}
)
# Apply preprocessing hook
processed_conditions = self._preprocess_leaf_conditions(leaf_conditions, view, queryset)
# Build a QueryDict from the leaf conditions
qd = QueryDict(mutable=True)
for key, value in leaf_conditions.items():
for key, value in processed_conditions.items():
# Default serialization to string; QueryDict expects strings
if isinstance(value, list):
# Repeat key for list values (e.g., __in)
@@ -206,11 +271,23 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
fs = filterset_class(data=qd, queryset=queryset)
if not fs.is_valid():
raise translate_validation(fs.errors)
ve = translate_validation(fs.errors)
raise DRFValidationError(
{
"message": "Invalid filter parameters",
"code": "invalid_filterset",
"errors": ve.detail,
}
)
# Build and return the combined Q object
if not hasattr(fs, "build_combined_q"):
raise ValidationError("FilterSet must have build_combined_q method for complex filtering")
raise DRFValidationError(
{
"message": ("FilterSet must have build_combined_q method for complex filtering"),
"code": "missing_build_combined_q",
}
)
return fs.build_combined_q()
@@ -243,34 +320,69 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
- Depth must not exceed max_depth
"""
if current_depth > max_depth:
raise ValidationError(f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}")
raise DRFValidationError(
{
"message": (f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}"),
"code": "max_depth_exceeded",
}
)
if not isinstance(node, dict):
raise ValidationError("Each filter node must be a JSON object")
raise DRFValidationError(
{
"message": "Each filter node must be a JSON object",
"code": "invalid_filter_node",
}
)
if not node:
raise ValidationError("Filter objects must not be empty")
raise DRFValidationError(
{
"message": "Filter objects must not be empty",
"code": "empty_filter_object",
}
)
logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")]
if len(logical_keys) > 1:
raise ValidationError("A filter object cannot contain multiple logical operators at the same level")
raise DRFValidationError(
{
"message": ("A filter object cannot contain multiple logical operators at the same level"),
"code": "multiple_logical_operators",
}
)
if len(logical_keys) == 1:
op_key = logical_keys[0]
# must not mix operator with other keys
if len(node) != 1:
raise ValidationError(f"Cannot mix logical operator '{op_key}' with field keys at the same level")
raise DRFValidationError(
{
"message": (f"Cannot mix logical operator '{op_key}' with field keys at the same level"),
"code": "mixed_operator_and_fields",
}
)
op = op_key.lower()
value = node[op_key]
if op in ("or", "and"):
if not isinstance(value, list) or len(value) == 0:
raise ValidationError(f"'{op}' must be a non-empty list of filter objects")
raise DRFValidationError(
{
"message": f"'{op}' must be a non-empty list of filter objects",
"code": "invalid_operator_children",
}
)
for child in value:
if not isinstance(child, dict):
raise ValidationError(f"All children of '{op}' must be JSON objects")
raise DRFValidationError(
{
"message": f"All children of '{op}' must be JSON objects",
"code": "invalid_operator_child_type",
}
)
self._validate_structure(
child,
max_depth=max_depth,
@@ -280,7 +392,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
if op == "not":
if not isinstance(value, dict):
raise ValidationError("'not' must be a single JSON object")
raise DRFValidationError(
{
"message": "'not' must be a single JSON object",
"code": "invalid_not_child",
}
)
self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1)
return
@@ -290,24 +407,49 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
def _validate_leaf(self, leaf):
"""Validate a leaf dict containing field lookups and values."""
if not isinstance(leaf, dict) or not leaf:
raise ValidationError("Leaf filter must be a non-empty JSON object")
raise DRFValidationError(
{
"message": "Leaf filter must be a non-empty JSON object",
"code": "invalid_leaf",
}
)
for key, value in leaf.items():
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
raise ValidationError("Logical operators cannot appear in a leaf filter object")
raise DRFValidationError(
{
"message": "Logical operators cannot appear in a leaf filter object",
"code": "operator_in_leaf",
}
)
# Lists/Tuples must contain only scalar values
if isinstance(value, (list, tuple)):
if len(value) == 0:
raise ValidationError(f"List value for '{key}' must not be empty")
raise DRFValidationError(
{
"message": f"List value for '{key}' must not be empty",
"code": "empty_list_value",
}
)
for item in value:
if not self._is_scalar(item):
raise ValidationError(f"List value for '{key}' must contain only scalar items")
raise DRFValidationError(
{
"message": f"List value for '{key}' must contain only scalar items",
"code": "non_scalar_list_item",
}
)
continue
# Scalars and None are allowed
if not self._is_scalar(value):
raise ValidationError(f"Value for '{key}' must be a scalar, null, or list/tuple of scalars")
raise DRFValidationError(
{
"message": (f"Value for '{key}' must be a scalar, null, or list/tuple of scalars"),
"code": "invalid_value_type",
}
)
def _is_scalar(self, value):
return value is None or isinstance(value, (str, int, float, bool))
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -56,9 +56,10 @@ COPY --from=installer /app/packages ./packages
COPY --from=installer /app/apps/live/dist ./apps/live/dist
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
COPY --from=installer /app/node_modules ./node_modules
COPY --from=installer /app/apps/live/package.json ./apps/live/package.json
ENV TURBO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "apps/live/dist/start.js"]
CMD ["node", "apps/live"]
+8 -3
View File
@@ -3,13 +3,18 @@
"version": "1.1.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
"main": "./dist/start.mjs",
"module": "./dist/start.mjs",
"exports": {
".": "./dist/start.mjs",
"./package.json": "./package.json"
},
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"",
"start": "node --env-file=.env dist/start.js",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"check:lint": "eslint . --max-warnings 10",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
-4
View File
@@ -1,10 +1,6 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": ["ES2015"],
"target": "ES2015",
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
+1
View File
@@ -7,4 +7,5 @@ export default defineConfig({
dts: false,
clean: true,
sourcemap: false,
exports: true,
});
+5
View File
@@ -9,6 +9,11 @@ import { LogoSpinner } from "@/components/common/logo-spinner";
import { AuthView } from "@/components/views";
// hooks
import { useUser } from "@/hooks/store/use-user";
import type { Route } from "./+types/page";
export const headers: Route.HeadersFunction = () => ({
"X-Frame-Options": "SAMEORIGIN",
});
const HomePage = observer(function HomePage() {
const { data: currentUser, isAuthenticated, isInitializing } = useUser();
+2 -4
View File
@@ -1,6 +1,5 @@
import * as Sentry from "@sentry/react-router";
import { Links, Meta, Outlet, Scripts } from "react-router";
import type { HeadersFunction, LinksFunction } from "react-router";
// assets
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
@@ -18,7 +17,7 @@ import { AppProviders } from "./providers";
const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click";
const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so";
export const links: LinksFunction = () => [
export const links: Route.LinksFunction = () => [
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
{ rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 },
{ rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 },
@@ -27,9 +26,8 @@ export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStyles },
];
export const headers: HeadersFunction = () => ({
export const headers: Route.HeadersFunction = () => ({
"Referrer-Policy": "origin-when-cross-origin",
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
"X-DNS-Prefetch-Control": "on",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
@@ -1 +0,0 @@
export * from "./mentions";
@@ -1,6 +1,8 @@
// plane editor
import type { TCallbackMentionComponentProps } from "@plane/editor";
export function EditorAdditionalMentionsRoot(_props: TCallbackMentionComponentProps) {
export type TEditorMentionComponentProps = TCallbackMentionComponentProps;
export function EditorAdditionalMentionsRoot(_props: TEditorMentionComponentProps) {
return null;
}
-1
View File
@@ -1 +0,0 @@
export * from "./embeds";
@@ -1,11 +1,10 @@
// plane editor
import type { TCallbackMentionComponentProps } from "@plane/editor";
// plane web components
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
// plane web imports
import type { TEditorMentionComponentProps } from "@/plane-web/components/editor/embeds/mentions";
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor/embeds/mentions";
// local components
import { EditorUserMention } from "./user";
export function EditorMentionsRoot(props: TCallbackMentionComponentProps) {
export function EditorMentionsRoot(props: TEditorMentionComponentProps) {
const { entity_identifier, entity_name } = props;
switch (entity_name) {
@@ -79,21 +79,23 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
// overriding the containerClassName to add relative class passed
containerClassName={cn(containerClassName, "relative")}
/>
<IssueCommentToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
isSubmitting={isSubmitting}
showSubmitButton={showSubmitButton}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
editorRef={editorRef}
/>
{editable && (
<IssueCommentToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
isSubmitting={isSubmitting}
showSubmitButton={showSubmitButton}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
editorRef={editorRef}
/>
)}
</div>
);
});
@@ -104,5 +104,3 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
</div>
);
});
KanbanIssueBlock.displayName = "KanbanIssueBlock";
@@ -1,31 +1,47 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TStateGroups } from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
//hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
stateId: string | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockState = observer(function IssueBlockState({ stateId, shouldShowBorder = true }: Props) {
const { getStateById } = useStates();
} & (
| {
stateDetails: {
name: string;
group: TStateGroups;
};
}
| {
stateId: string;
}
);
const state = getStateById(stateId);
export const IssueBlockState: React.FC<Props> = observer((props) => {
const { shouldShowBorder = true } = props;
// store hooks
const { getStateById } = useStates();
// derived values
const state = "stateId" in props ? getStateById(props.stateId) : props.stateDetails;
if (!state) return null;
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
<Tooltip tooltipHeading="State" tooltipContent={state.name}>
<div
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
<div className="text-xs">{state?.name ?? "State"}</div>
<StateGroupIcon stateGroup={state.group} />
<div className="text-xs">{state.name}</div>
</div>
</div>
</Tooltip>
@@ -96,6 +96,9 @@ export const AddComment = observer(function AddComment(props: Props) {
setUploadAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
@@ -42,7 +42,7 @@ export const CommentCard = observer(function CommentCard(props: Props) {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm<any>({
} = useForm<TIssuePublicComment>({
defaultValues: { comment_html: comment.comment_html },
});
@@ -120,6 +120,9 @@ export const CommentCard = observer(function CommentCard(props: Props) {
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
-1
View File
@@ -1 +0,0 @@
export * from "ce/components/editor";
@@ -2,13 +2,13 @@ import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useParams, usePathname } from "next/navigation";
import { SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// components
import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
// local imports
import { ExtendedAppSidebar } from "./extended-sidebar";
import { AppSidebar } from "./sidebar";
@@ -26,14 +26,19 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
// states
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
// hooks
const { shouldRenderAppRail } = useAppRail();
// routes
const { workspaceSlug } = useParams();
const pathname = usePathname();
// derived values
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
// handlers
const handleWidthChange = (width: number) => setValue(width);
if (isNotificationsPath) return null;
return (
<>
<ResizableSidebar
@@ -55,7 +60,6 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
}
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
disablePeekTrigger={shouldRenderAppRail}
>
<AppSidebar />
</ResizableSidebar>
@@ -1,13 +1,15 @@
import { useMemo } from "react";
"use client";
import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { Tabs } from "@plane/ui";
import type { TabItem } from "@plane/ui";
import { Tabs } from "@plane/propel/tabs";
// components
import { cn } from "@plane/utils";
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
import { PageHead } from "@/components/core/page-title";
// hooks
@@ -16,7 +18,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
import { useAnalyticsTabs } from "@/plane-web/components/analytics/use-analytics-tabs";
import type { Route } from "./+types/page";
function AnalyticsPage({ params }: Route.ComponentProps) {
@@ -34,33 +36,32 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
const ANALYTICS_TABS = useMemo(function ANALYTICS_TABS() {
return getAnalyticsTabs(t);
});
const tabs: TabItem[] = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
content: <tab.content />,
onClick: () => {
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
},
disabled: tab.isDisabled,
})),
[ANALYTICS_TABS, router, currentWorkspace?.slug]
);
const defaultTab = tabId;
const workspaceSlug = params.workspaceSlug;
const ANALYTICS_TABS = useAnalyticsTabs(workspaceSlug.toString());
const [selectedTab, setSelectedTab] = useState(tabId || ANALYTICS_TABS[0]?.key);
useEffect(() => {
if (tabId) {
setSelectedTab(tabId);
}
}, [tabId]);
// Handle tab change
const handleTabChange = (value: string) => {
setSelectedTab(value);
router.push(`/${currentWorkspace?.slug}/analytics/${value}`);
};
return (
<>
@@ -68,19 +69,43 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
{workspaceProjectIds && (
<>
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
<Tabs
tabs={tabs}
storageKey={`analytics-page-${currentWorkspace?.id}`}
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 w-auto"
tabClassName="px-3"
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
storeInLocalStorage={false}
actions={<AnalyticsFilterActions />}
/>
<div className="flex h-full overflow-hidden bg-custom-background-100 ">
<Tabs value={selectedTab} onValueChange={handleTabChange} className="w-full h-full">
<div className={"flex flex-col w-full h-full"}>
<div
className={cn(
"px-6 py-2 border-b border-custom-border-200 flex items-center gap-4 overflow-hidden w-full justify-between"
)}
>
<Tabs.List className={"my-2 overflow-x-auto flex w-fit"}>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Trigger
key={tab.key}
value={tab.key}
disabled={tab.isDisabled}
size="md"
className="px-3"
>
{tab.label}
</Tabs.Trigger>
))}
</Tabs.List>
<div className="flex-shrink-0">
<AnalyticsFilterActions />
</div>
</div>
{ANALYTICS_TABS.map((tab) => (
<Tabs.Content
key={tab.key}
value={tab.key}
className={"h-full overflow-hidden overflow-y-auto px-2"}
>
<tab.content />
</Tabs.Content>
))}
</div>
</Tabs>
</div>
) : (
<EmptyStateDetailed
@@ -1,61 +1,59 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
import { AppHeader } from "@/components/core/app-header";
import { TabNavigationRoot } from "@/components/navigation";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local components
import { WorkItemDetailsHeader } from "./work-item-header";
export const ProjectIssueDetailsHeader = observer(function ProjectIssueDetailsHeader() {
export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDetailsHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const { sidebarCollapsed } = useAppTheme();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
const issueDetails = issueId ? getIssueById(issueId?.toString()) : undefined;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
<>
{projectPreferences.navigationMode === "horizontal" && (
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center gap-2 size-full flex-1">
{sidebarCollapsed && (
<div className="shrink-0">
<AppSidebarToggleButton />
</div>
)}
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
<Header.LeftItem className="h-full max-w-full">
<TabNavigationRoot
workspaceSlug={workspaceSlug}
projectId={issueDetails?.project_id?.toString() ?? ""}
/>
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
)}
<AppHeader header={<WorkItemDetailsHeader />} />
</>
);
});
@@ -1,13 +1,12 @@
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectIssueDetailsHeader } from "./header";
import { ProjectWorkItemDetailsHeader } from "./header";
export default function ProjectIssueDetailsLayout() {
return (
<>
<AppHeader header={<ProjectIssueDetailsHeader />} />
<ProjectWorkItemDetailsHeader />
<ContentWrapper className="overflow-hidden">
<Outlet />
</ContentWrapper>
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane ui
import { WorkItemsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const WorkItemDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});
@@ -1,10 +1,11 @@
import React, { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
@@ -75,7 +76,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
EUserPermissionsLevel.WORKSPACE
);
const handleClose = () => toggleExtendedProjectSidebar(false);
const handleClose = useCallback(() => toggleExtendedProjectSidebar(false), [toggleExtendedProjectSidebar]);
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
@@ -101,8 +102,9 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
extendedSidebarRef={extendedProjectSidebarRef}
handleClose={handleClose}
excludedElementId="extended-project-sidebar-toggle"
className="px-0"
>
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
<div className="flex flex-col gap-1 w-full sticky top-4 px-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
@@ -110,7 +112,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.EXTENDED_SIDEBAR_ADD_BUTTON}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 text-custom-text-300 hover:text-custom-text-200 transition-colors"
onClick={() => {
setIsProjectModalOpen(true);
}}
@@ -131,21 +133,33 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
/>
</div>
</div>
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
{filteredProjects.length === 0 ? (
<div className="flex flex-col items-center mt-4 p-10">
<EmptyStateCompact
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
assetKey="search"
assetClassName="size-20"
align="center"
/>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-9 pr-2">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === filteredProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
/>
))}
</div>
)}
</ExtendedSidebarWrapper>
</>
);
@@ -1,14 +1,16 @@
import type { FC } from "react";
import React from "react";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
// plane imports
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { cn } from "@plane/utils";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
// hooks
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
type Props = {
className?: string;
children: React.ReactNode;
extendedSidebarRef: React.RefObject<HTMLDivElement>;
isExtendedSidebarOpened: boolean;
@@ -17,26 +19,35 @@ type Props = {
};
export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(props: Props) {
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
const { className, children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// local storage
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
useEffect(() => {
if (sidebarCollapsed) {
handleClose();
}
}, [sidebarCollapsed, handleClose]);
return (
<div
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
"absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm",
{
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
}
"opacity-100": isExtendedSidebarOpened,
"opacity-0 hidden": !isExtendedSidebarOpened,
},
className
)}
style={{
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
width: `${EXTENDED_SIDEBAR_WIDTH}px`,
}}
>
{children}
@@ -1,12 +1,13 @@
import React, { useMemo, useRef } from "react";
import { useCallback, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import type { EUserWorkspaceRoles } from "@plane/types";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane-web imports
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item";
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
@@ -18,22 +19,38 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const { workspaceSlug } = useParams();
// store hooks
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const { preferences: workspacePreferences, updateWorkspaceItemSortOrder } = useWorkspaceNavigationPreferences();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const currentWorkspaceNavigationPreferences = workspacePreferences.items;
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const sortedNavigationItems = useMemo(() => {
const slug = workspaceSlug.toString();
return WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
// Permission check
const hasPermission = allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug);
return hasPermission;
})
.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
sort_order: preference?.sort_order ?? 0,
is_pinned: preference?.is_pinned ?? false,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
})
.sort((a, b) => {
// First sort by pinned status (pinned items first)
if (a.is_pinned !== b.is_pinned) {
return b.is_pinned ? 1 : -1;
}
// Then sort by sort_order within each group
return a.sort_order - b.sort_order;
});
}, [workspaceSlug, currentWorkspaceNavigationPreferences, allowPermissions]);
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
@@ -87,13 +104,10 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
if (updatedSortOrder != undefined)
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
sort_order: updatedSortOrder,
});
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
};
const handleClose = () => toggleExtendedSidebar(false);
const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]);
return (
<ExtendedSidebarWrapper
@@ -3,6 +3,7 @@ import { Outlet } from "react-router";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
// plane web components
import { ProjectAppSidebar } from "./_sidebar";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
function WorkspaceLayout() {
return (
@@ -12,6 +13,7 @@ function WorkspaceLayout() {
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<ExtendedProjectSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<Outlet />
</main>
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons";
@@ -13,8 +12,8 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
type TProps = {
activeTab: "issues" | "cycles" | "modules";
@@ -67,7 +66,7 @@ export const ProjectArchivesHeader = observer(function ProjectArchivesHeader(pro
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -8,7 +8,6 @@ import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
@@ -23,6 +22,7 @@ import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import { CycleQuickActions } from "@/components/cycles/quick-actions";
import {
@@ -135,10 +135,15 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.CYCLES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${projectId}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
@@ -1,26 +1,26 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CycleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CyclesViewHeader } from "@/components/cycles/cycles-view-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const CyclesListHeader = observer(function CyclesListHeader() {
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateCycleModal } = useCommandPalette();
@@ -37,10 +37,16 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={currentProjectDetails?.id ?? ""}
featureKey={EProjectFeatureKey.CYCLES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -0,0 +1,52 @@
"use client";
import { observer } from "mobx-react";
import { Outlet } from "react-router";
// plane imports
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local imports
import type { Route } from "./+types/layout";
function ProjectLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<>
{projectPreferences.navigationMode === "horizontal" && (
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center gap-2 size-full flex-1">
{sidebarCollapsed && (
<div className="shrink-0">
<AppSidebarToggleButton />
</div>
)}
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
<Header.LeftItem className="h-full max-w-full flex items-center gap-2">
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
)}
<Outlet />
</>
);
}
export default observer(ProjectLayout);
@@ -9,7 +9,6 @@ import {
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { Button } from "@plane/propel/button";
@@ -21,6 +20,7 @@ import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
@@ -40,7 +40,7 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
@@ -128,10 +128,17 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
<Breadcrumbs.Item
component={
@@ -1,21 +1,22 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ModuleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ModuleViewHeader } from "@/components/modules";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const ModulesListHeader = observer(function ModulesListHeader() {
// router
@@ -40,10 +41,16 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -1,24 +1,21 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EProjectFeatureKey } from "@plane/constants";
// plane imports
import { PageIcon } from "@plane/propel/icons";
// types
import type { ICustomSearchSelectOption } from "@plane/types";
// ui
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { getPageName } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
// helpers
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web components
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
export interface IPagesHeaderProps {
@@ -65,10 +62,15 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.PAGES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${projectId}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
@@ -2,25 +2,21 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
// constants
import {
EPageAccess,
EProjectFeatureKey,
PROJECT_PAGE_TRACKER_EVENTS,
PROJECT_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EPageAccess, PROJECT_PAGE_TRACKER_EVENTS, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
// plane types
import { Button } from "@plane/propel/button";
import { PageIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
export const PagesListHeader = observer(function PagesListHeader() {
@@ -28,7 +24,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
const [isCreatingPage, setIsCreatingPage] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
const searchParams = useSearchParams();
const pageType = searchParams.get("type");
// store hooks
@@ -74,10 +70,16 @@ export const PagesListHeader = observer(function PagesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={currentProjectDetails?.id?.toString() ?? ""}
featureKey={EProjectFeatureKey.PAGES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -2,27 +2,24 @@ import { useCallback, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Lock } from "lucide-react";
// plane constants
// plane imports
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
// types
import { Button } from "@plane/propel/button";
import { ViewsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
// ui
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
// constants
import { ViewQuickActions } from "@/components/views/quick-actions";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
@@ -31,8 +28,8 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
@@ -121,12 +118,16 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
@@ -1,15 +1,17 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { ViewsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ViewListHeader } from "@/components/views/view-list-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
@@ -23,10 +25,16 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -1,5 +1,4 @@
import { Outlet } from "react-router";
import { AppRailProvider } from "@/hooks/context/app-rail-context";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
@@ -8,11 +7,9 @@ export default function WorkspaceLayout() {
return (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<AppRailProvider>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</AppRailProvider>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FC, ReactNode } from "react";
import { lazy, Suspense } from "react";
import { useTheme, ThemeProvider } from "next-themes";
import { SWRConfig } from "swr";
@@ -31,7 +30,7 @@ const IntercomProvider = lazy(function IntercomProvider() {
});
export interface IAppProvider {
children: ReactNode;
children: React.ReactNode;
}
function ToastWithTheme() {
+86 -91
View File
@@ -123,6 +123,92 @@ export const coreRoutes: RouteConfigEntry[] = [
// Project Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Intake list
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
]),
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
@@ -131,97 +217,6 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// Project Issues
// Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Project Cycles
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Project Modules
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Project Views
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Project Pages
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Project Intake
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
@@ -0,0 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { getAnalyticsTabs } from "./tabs";
export const useAnalyticsTabs = (workspaceSlug: string) => {
const { t } = useTranslation();
const analyticsTabs = useMemo(() => getAnalyticsTabs(t), [t]);
return analyticsTabs;
};
@@ -0,0 +1,34 @@
// hoc/withDockItems.tsx
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlaneNewIcon } from "@plane/propel/icons";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { useWorkspacePaths } from "@/hooks/use-workspace-paths";
type WithDockItemsProps = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
};
export function withDockItems<P extends WithDockItemsProps>(WrappedComponent: React.ComponentType<P>) {
const ComponentWithDockItems = observer((props: Omit<P, keyof WithDockItemsProps>) => {
const { workspaceSlug } = useParams();
const { isProjectsPath, isNotificationsPath } = useWorkspacePaths();
const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [
{
label: "Projects",
icon: <PlaneNewIcon className="size-4" />,
href: `/${workspaceSlug}/`,
isActive: isProjectsPath && !isNotificationsPath,
shouldRender: true,
},
];
return <WrappedComponent {...(props as P)} dockItems={dockItems} />;
});
return ComponentWithDockItems;
}
+1 -1
View File
@@ -1 +1 @@
export * from "./root";
export * from "./app-rail-hoc";
-5
View File
@@ -1,5 +0,0 @@
import React from "react";
export function AppRailRoot() {
return <></>;
}
+7 -20
View File
@@ -1,30 +1,17 @@
import type { FC } from "react";
// plane imports
import type { EProjectFeatureKey } from "@plane/constants";
// local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { ProjectBreadcrumb } from "./project";
import { ProjectFeatureBreadcrumb } from "./project-feature";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
featureKey?: EProjectFeatureKey;
isLast?: boolean;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId, featureKey, isLast = false } = props;
return (
<>
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
{featureKey && (
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={featureKey}
isLast={isLast}
/>
)}
</>
);
const { workspaceSlug, projectId } = props;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
if (projectPreferences.navigationMode === "horizontal") return null;
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
}
@@ -1,15 +1,13 @@
import type { FC } from "react";
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
import type { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs } from "@plane/ui";
// components
import { SwitcherLabel } from "@/components/common/switcher-label";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
@@ -25,8 +23,6 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
props: TProjectFeatureBreadcrumbProps
) {
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
// router
const router = useAppRouter();
// store hooks
const { getPartialProjectById } = useProject();
// derived values
@@ -39,27 +35,21 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
// if additional navigation items are provided, add them to the navigation items
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey);
const icon = currentNavigationItem?.icon as ReactNode;
const name = currentNavigationItem?.name;
const href = currentNavigationItem?.href;
return (
<>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationDropdown
selectedItemKey={featureKey}
navigationItems={allNavigationItems
.filter((item) => item.shouldRender)
.map((item) => ({
key: item.key,
title: item.name,
customContent: <SwitcherLabel name={item.name} LabelIcon={item.icon as FC<ISvgIcons>} />,
action: () => router.push(item.href),
icon: item.icon as FC<ISvgIcons>,
}))}
handleOnClick={() => {
router.push(
`/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/`
);
}}
<BreadcrumbLink
key={featureKey}
label={name}
isLast={isLast}
href={href}
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
/>
}
showSeparator={false}
@@ -49,7 +49,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
// helpers
const renderIcon = (projectDetails: TProject) => (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<span className="grid place-items-center flex-shrink-0 size-4">
<Logo logo={projectDetails.logo_props} size={14} />
</span>
);
@@ -1,8 +1,8 @@
// local imports
import type { TPowerKContextTypeExtended } from "../types";
import type { TPowerKContextType } from "@/components/power-k/core/types";
type TArgs = {
activeContext: TPowerKContextTypeExtended | null;
activeContext: TPowerKContextType | null;
};
export const useExtendedContextIndicator = (_args: TArgs): string | null => null;
@@ -1,10 +1,9 @@
import type { FC, ReactNode } from "react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueComment } from "@plane/types";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// hooks
@@ -56,9 +55,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock)
<div className="flex w-full gap-2">
<div className="flex-1 flex flex-wrap items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-xs font-medium">
{`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`}
</span>
<span className="text-xs font-medium">{displayName}</span>
</div>
<div className="text-xs text-custom-text-300">
commented{" "}
@@ -67,7 +64,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock)
position="bottom"
>
<span className="text-custom-text-350">
{calculateTimeAgo(comment.updated_at)}
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && ` (${t("edited")})`}
</span>
</Tooltip>
@@ -1,16 +1,26 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams } from "react-router";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { header: ReactNode }) {
const { header } = props;
// params
const { projectId, workItem } = useParams();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
// store hooks
const { sidebarCollapsed } = useAppTheme();
// derived values
const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "accordion" || (!projectId && !workItem);
return (
<>
{sidebarCollapsed && <AppSidebarToggleButton />}
{sidebarCollapsed && shouldShowSidebarToggleButton && <AppSidebarToggleButton />}
<div className="w-full">{header}</div>
</>
);
@@ -1,9 +1,9 @@
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { Disclosure } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { ICycle } from "@plane/types";
import { Row } from "@plane/ui";
// assets
import darkActiveCycleAsset from "@/app/assets/empty-state/cycle/active-dark.webp?url";
@@ -27,6 +27,69 @@ interface IActiveCycleDetails {
showHeader?: boolean;
}
type ActiveCyclesComponentProps = {
cycleId: string | null | undefined;
activeCycle: ICycle | null;
activeCycleResolvedPath: string;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (filters: any) => void;
cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean };
};
const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
cycleId,
activeCycle,
activeCycleResolvedPath,
workspaceSlug,
projectId,
handleFiltersUpdate,
cycleIssueDetails,
}: ActiveCyclesComponentProps) {
const { t } = useTranslation();
if (!cycleId || !activeCycle) {
return (
<DetailedEmptyState
title={t("project_cycles.empty_state.active.title")}
description={t("project_cycles.empty_state.active.description")}
assetPath={activeCycleResolvedPath}
/>
);
}
return (
<div className="flex flex-col border-b border-custom-border-200">
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
<Row className="bg-custom-background-100 pt-3 pb-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={cycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
</div>
</Row>
</div>
);
});
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveCycleDetails) {
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
// theme hook
@@ -45,51 +108,6 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveC
cycleIssueDetails,
} = useCyclesDetails({ workspaceSlug, projectId, cycleId });
const ActiveCyclesComponent = useMemo(function ActiveCyclesComponent() {
return (
<>
{!cycleId || !activeCycle ? (
<DetailedEmptyState
title={t("project_cycles.empty_state.active.title")}
description={t("project_cycles.empty_state.active.description")}
assetPath={activeCycleResolvedPath}
/>
) : (
<div className="flex flex-col border-b border-custom-border-200">
{cycleId && (
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
)}
<Row className="bg-custom-background-100 pt-3 pb-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={cycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
/>
</div>
</Row>
</div>
)}
</>
);
});
return (
<>
{showHeader ? (
@@ -99,12 +117,30 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveC
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<CycleListGroupHeader title={t("project_cycles.active_cycle.label")} type="current" isExpanded={open} />
</Disclosure.Button>
<Disclosure.Panel>{ActiveCyclesComponent}</Disclosure.Panel>
<Disclosure.Panel>
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
activeCycleResolvedPath={activeCycleResolvedPath}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
</Disclosure.Panel>
</>
)}
</Disclosure>
) : (
<>{ActiveCyclesComponent}</>
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
activeCycleResolvedPath={activeCycleResolvedPath}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
)}
</>
);
+1
View File
@@ -0,0 +1 @@
export const isSidebarToggleVisible = () => true;
+2
View File
@@ -0,0 +1,2 @@
export * from "./helper";
export * from "./sidebar-workspace-menu";
@@ -0,0 +1,3 @@
export function DesktopSidebarWorkspaceMenu() {
return null;
}
@@ -1,9 +1,9 @@
import { observer } from "mobx-react";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
export function HomePeekOverviewsRoot() {
return (
<>
<IssuePeekOverview />
</>
);
}
export const HomePeekOverviewsRoot = observer(function HomePeekOverviewsRoot() {
const { peekIssue } = useIssueDetail();
return peekIssue ? <IssuePeekOverview /> : null;
});
+14 -7
View File
@@ -9,14 +9,15 @@ import {
SPACE_BASE_PATH,
SPACE_BASE_URL,
WORK_ITEM_TRACKER_ELEMENTS,
EProjectFeatureKey,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { WorkItemsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip";
// constants
import { HeaderFilters } from "@/components/issues/filters";
@@ -28,8 +29,8 @@ import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { CommonProjectBreadcrumbs } from "../breadcrumbs/common";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const IssuesHeader = observer(function IssuesHeader() {
// router
@@ -62,10 +63,16 @@ export const IssuesHeader = observer(function IssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -0,0 +1,2 @@
export * from "./use-navigation-items";
export * from "./top-navigation-root";
@@ -0,0 +1,38 @@
// components
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
export const TopNavigationRoot = observer(() => {
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
return (
<div
className={cn("flex items-center min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
"px-2": !showLabel,
})}
>
{/* Workspace Menu */}
<div className="shrink-0 flex-1">
<WorkspaceMenuRoot variant="top-navigation" />
</div>
{/* Power K Search */}
<div className="shrink-0">
<TopNavPowerK />
</div>
{/* Additional Actions */}
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
<HelpMenuRoot />
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
<UserMenuRoot size="xs" />
</div>
</div>
</div>
);
});
@@ -0,0 +1,109 @@
import { useMemo, useCallback } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { EUserProjectRoles, IPartialProject } from "@plane/types";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseNavigationItemsProps = {
workspaceSlug: string;
projectId: string;
project?: IPartialProject;
allowPermissions: (
access: EUserPermissions[] | EUserProjectRoles[],
level: EUserPermissionsLevel,
workspaceSlug: string,
projectId: string
) => boolean;
};
export const useNavigationItems = ({
workspaceSlug,
projectId,
project,
allowPermissions,
}: UseNavigationItemsProps): TNavigationItem[] => {
// Base navigation items
const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
i18n_key: "sidebar.work_items",
key: "work_items",
name: "Work items",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: WorkItemsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: true,
sortOrder: 1,
},
{
i18n_key: "sidebar.cycles",
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: CycleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.cycle_view,
sortOrder: 2,
},
{
i18n_key: "sidebar.modules",
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: ModuleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.module_view,
sortOrder: 3,
},
{
i18n_key: "sidebar.views",
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.issue_views_view,
sortOrder: 4,
},
{
i18n_key: "sidebar.pages",
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: PageIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.page_view,
sortOrder: 5,
},
{
i18n_key: "sidebar.intake",
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/intake`,
icon: IntakeIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.inbox_view,
sortOrder: 6,
},
],
[project]
);
// Combine, filter, and sort navigation items
const navigationItems = useMemo(() => {
const navItems = baseNavigation(workspaceSlug, projectId);
// Filter by permissions and shouldRender
const filteredItems = navItems.filter((item) => {
if (!item.shouldRender) return false;
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project?.id ?? "");
return hasAccess;
});
// Sort by sortOrder
return filteredItems.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}, [workspaceSlug, projectId, baseNavigation, allowPermissions, project?.id]);
return navigationItems;
};

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