Compare commits

...
Author SHA1 Message Date
pablohashescobar 2fbf0d6eda fix: uuid validation error 2025-02-19 16:36:58 +05:30
pablohashescobar 4e381c0943 fix: module activity 2025-02-18 21:16:20 +05:30
pablohashescobar 563ca2ff07 fix: meta endpoint to return correct error message 2025-02-18 21:04:31 +05:30
pablohashescobar 76c80ced14 fix: error handling for db based integrity errors 2025-02-18 18:51:13 +05:30
Akshita GoyalandGitHub a49d899ea1 Chore: search code splitting (#6628)
* fix: Handled workspace switcher closing on click

* chore: code splitting for search

* fix: refactor

* fix: quick link error validation

* fix: refactor

* fix: refactor
2025-02-18 15:11:44 +05:30
Aaryan KhandelwalandGitHub 3f6ef56a0f chore: add hslToHex and hexToHsl color helpers (#6629)
* chore: add more color helpers

* chore: added error handling
2025-02-18 13:18:45 +05:30
Akshita GoyalandGitHub cba27c348d fix: home quick start widget validation (#6626)
* fix: Handled workspace switcher closing on click

* fix: home quickstart widget
2025-02-18 12:37:00 +05:30
Anmol Singh BhatiaandGitHub ffe87cc3b4 chore: work item url redirection improvement (#6627) 2025-02-18 12:35:57 +05:30
19 changed files with 464 additions and 227 deletions
+79 -63
View File
@@ -1,6 +1,7 @@
# Django imports
from django.utils import timezone
from lxml import html
from django.db import IntegrityError
# Third party imports
from rest_framework import serializers
@@ -138,47 +139,56 @@ class IssueSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
try:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
except IntegrityError:
pass
if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
except IntegrityError:
pass
return issue
@@ -194,39 +204,45 @@ class IssueSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
+78 -62
View File
@@ -2,6 +2,7 @@
from django.utils import timezone
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Third Party imports
from rest_framework import serializers
@@ -134,47 +135,56 @@ class IssueCreateSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
except IntegrityError:
pass
if labels is not None and len(labels):
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
except IntegrityError:
pass
return issue
@@ -190,39 +200,45 @@ class IssueCreateSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
+19 -3
View File
@@ -5,6 +5,7 @@ import uuid
from django.conf import settings
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.db import IntegrityError
# Third party imports
from rest_framework import status
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
[self.save_project_cover(asset, project_id) for asset in assets]
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
assets.update(issue_id=entity_id)
# For some cases, the bulk api is called after the issue is deleted creating
# an integrity error
try:
assets.update(issue_id=entity_id)
except IntegrityError:
pass
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
assets.update(comment_id=entity_id)
# For some cases, the bulk api is called after the comment is deleted
# creating an integrity error
try:
assets.update(comment_id=entity_id)
except IntegrityError:
pass
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
assets.update(page_id=entity_id)
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
assets.update(draft_issue_id=entity_id)
# For some cases, the bulk api is called after the draft issue is deleted
# creating an integrity error
try:
assets.update(draft_issue_id=entity_id)
except IntegrityError:
pass
return Response(status=status.HTTP_204_NO_CONTENT)
+26 -17
View File
@@ -5,6 +5,7 @@ import json
from django.utils import timezone
from django.db.models import Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, comment_id):
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
try:
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
actor_id=request.user.id,
comment_id=comment_id,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Reaction already exists for the user"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def destroy(self, request, slug, project_id, comment_id, reaction_code):
+15 -1
View File
@@ -55,6 +55,20 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, *args, **kwargs):
# Check if the label name is unique within the project
if (
"name" in request.data
and Label.objects.filter(
project_id=kwargs["project_id"], name=request.data["name"]
)
.exclude(pk=kwargs["pk"])
.exists()
):
return Response(
{"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)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@@ -74,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
Label(
name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"),
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@@ -4,6 +4,7 @@ from rest_framework.response import Response
# Django modules
from django.db.models import Q
from django.db import IntegrityError
# Module imports
from plane.app.views.base import BaseAPIView
@@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
try:
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, favorite_id):
@@ -9,10 +9,11 @@ from celery import shared_task
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.utils.valid_uuid import is_valid_uuid
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
CommentReaction,
Cycle,
@@ -790,14 +791,15 @@ def create_cycle_issue_activity(
issue_id=updated_record.get("issue_id"),
actor_id=actor_id,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
old_value=old_cycle.name if old_cycle else "",
new_value=new_cycle.name if new_cycle else "",
field="cycles",
project_id=project_id,
workspace_id=workspace_id,
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
to {new_cycle.name if new_cycle else ""}""",
old_identifier=old_cycle.id if old_cycle else None,
new_identifier=new_cycle.id if new_cycle else None,
epoch=epoch,
)
)
@@ -893,11 +895,11 @@ def create_module_issue_activity(
actor_id=actor_id,
verb="created",
old_value="",
new_value=module.name,
new_value=module.name if module else "",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name}",
comment=f"added module {module.name if module else ''}",
new_identifier=requested_data.get("module_id"),
epoch=epoch,
)
@@ -1413,7 +1415,7 @@ def delete_issue_relation_activity(
),
project_id=project_id,
workspace_id=workspace_id,
comment=f'deleted {requested_data.get("relation_type")} relation',
comment=f"deleted {requested_data.get('relation_type')} relation",
old_identifier=requested_data.get("related_issue"),
epoch=epoch,
)
@@ -1567,6 +1569,10 @@ def issue_activity(
try:
issue_activities = []
# Validate UUIDs
if not is_valid_uuid(project_id):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
@@ -1,5 +1,6 @@
# Python imports
from django.utils import timezone
from django.db import DatabaseError
# Third party imports
from celery import shared_task
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
).first()
if recent_visited:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
# Check if the database is available
try:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
except DatabaseError:
pass
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
+2 -2
View File
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
).first()
)
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
+9
View File
@@ -0,0 +1,9 @@
import uuid
def is_valid_uuid(uuid_to_test, version=4):
try:
# check for validity of Uuid
uuid_obj = uuid.UUID(uuid_to_test, version=version)
except ValueError:
return False
return True
+80
View File
@@ -7,6 +7,8 @@
*/
export type RGB = { r: number; g: number; b: number };
export type HSL = { h: number; s: number; l: number };
/**
* @description Validates and clamps color values to RGB range (0-255)
* @param {number} value - The color value to validate
@@ -62,3 +64,81 @@ export const hexToRgb = (hex: string): RGB => {
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
*/
export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
/**
* Converts Hex values to HSL values
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
* @returns {HSL} An object containing the HSL values
* @example
* hexToHsl("#ff0000") // returns { h: 0, s: 100, l: 50 }
* hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 }
* hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 }
*/
export const hexToHsl = (hex: string): HSL => {
// return default value for invalid hex
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 };
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 360,
s: s * 100,
l: l * 100,
};
};
/**
* Converts HSL values to a hexadecimal color code
* @param {HSL} hsl - An object containing HSL values
* @param {number} hsl.h - Hue component (0-360)
* @param {number} hsl.s - Saturation component (0-100)
* @param {number} hsl.l - Lightness component (0-100)
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
* @example
* hslToHex({ h: 0, s: 100, l: 50 }) // returns "#ff0000"
* hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00"
* hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff"
*/
export const hslToHex = ({ h, s, l }: HSL): string => {
if (h < 0 || h > 360) return "#000000";
if (s < 0 || s > 100) return "#000000";
if (l < 0 || l > 100) return "#000000";
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
@@ -2,11 +2,17 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { redirect, useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common";
import { EmptyState, LogoSpinner } from "@/components/common";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// assets
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
// services
import { IssueService } from "@/services/issue/issue.service";
@@ -14,29 +20,42 @@ const issueService = new IssueService();
const IssueDetailsPage = observer(() => {
const router = useAppRouter();
const { t } = useTranslation();
const { workspaceSlug, projectId, issueId } = useParams();
const { resolvedTheme } = useTheme();
const { data, isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
useEffect(() => {
const redirectToBrowseUrl = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
try {
const meta = await issueService.getIssueMetaFromURL(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
);
router.push(`/${workspaceSlug}/browse/${meta.project_identifier}-${meta.sequence_id}`);
} catch (error) {
console.error(error);
}
};
redirectToBrowseUrl();
}, [workspaceSlug, projectId, issueId, router]);
if (data) {
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
}
}, [workspaceSlug, data]);
return (
<div className="flex items-center justify-center size-full">
<LogoSpinner />
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
}}
/>
) : isLoading ? (
<>
<LogoSpinner />
</>
) : (
<></>
)}
</div>
);
});
+1
View File
@@ -2,4 +2,5 @@ export * from "./edition-badge";
export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./sidebar";
export * from "./members";
@@ -0,0 +1,26 @@
import { observer } from "mobx-react";
import { Search } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useCommandPalette } from "@/hooks/store";
export const AppSearch = observer(() => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { toggleCommandPaletteModal } = useCommandPalette();
return (
<button
className={cn(
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none",
{
"border-[0.5px] border-custom-sidebar-border-300": !sidebarCollapsed,
}
)}
onClick={() => toggleCommandPaletteModal(true)}
>
<Search className="size-4 text-custom-sidebar-text-300" />
</button>
);
});
@@ -1,3 +1,4 @@
export * from "./app-search";
export * from "./extended-sidebar-item";
export * from "./helper";
export * from "./sidebar-item";
@@ -12,7 +12,14 @@ import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
import {
useCommandPalette,
useEventTracker,
useProject,
useUser,
useUserPermissions,
useWorkspace,
} from "@/hooks/store";
// plane web constants
export const NoProjectsEmptyState = observer(() => {
@@ -24,6 +31,7 @@ export const NoProjectsEmptyState = observer(() => {
const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser();
const { joinedProjectIds } = useProject();
const { currentWorkspace: activeWorkspace } = useWorkspace();
// local storage
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
hide: false,
@@ -37,6 +45,7 @@ export const NoProjectsEmptyState = observer(() => {
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const EMPTY_STATE_DATA = [
{
@@ -54,6 +63,7 @@ export const NoProjectsEmptyState = observer(() => {
setTrackElement("Sidebar");
toggleCreateProjectModal(true);
},
disabled: !canCreateProject,
},
},
{
@@ -65,6 +75,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.invite_team.cta",
link: `/${workspaceSlug}/settings/members`,
disabled: !isWorkspaceAdmin,
},
},
{
@@ -76,6 +87,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.configure_workspace.cta",
link: "settings",
disabled: !isWorkspaceAdmin,
},
},
{
@@ -104,6 +116,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.personalize_account.cta",
link: "/profile",
disabled: false,
},
},
];
@@ -112,7 +125,7 @@ export const NoProjectsEmptyState = observer(() => {
case "projects":
return joinedProjectIds?.length > 0;
case "visited_members":
return storedValue?.visited_members;
return (activeWorkspace?.total_members || 0) >= 2;
case "visited_workspace":
return storedValue?.visited_workspace;
case "visited_profile":
@@ -120,7 +133,7 @@ export const NoProjectsEmptyState = observer(() => {
}
};
if (storedValue?.hide) return null;
if (storedValue?.hide || (joinedProjectIds?.length > 0 && (activeWorkspace?.total_members || 0) >= 2)) return null;
return (
<div>
@@ -161,28 +174,35 @@ export const NoProjectsEmptyState = observer(() => {
<div className="flex items-center gap-2 bg-[#17a34a] rounded-full p-1">
<Check className="size-3 text-custom-primary-100 text-white" />
</div>
) : item.cta.link ? (
<Link
href={item.cta.link}
onClick={() => {
if (!storedValue) return;
setValue({
...storedValue,
[item.flag]: true,
});
}}
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
>
{t(item.cta.text)}
</Link>
) : (
<button
type="button"
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
onClick={item.cta.onClick}
>
{t(item.cta.text)}
</button>
!item.cta.disabled &&
(item.cta.link ? (
<Link
href={item.cta.link}
onClick={(e) => {
if (!storedValue) {
e.stopPropagation();
e.preventDefault();
return;
}
setValue({
...storedValue,
[item.flag]: true,
});
}}
className={cn("text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium", {})}
>
{t(item.cta.text)}
</Link>
) : (
<button
type="button"
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
onClick={item.cta.onClick}
>
{t(item.cta.text)}
</button>
))
)}
</div>
);
@@ -42,9 +42,9 @@ export const useLinks = (workspaceSlug: string) => {
});
toggleLinkModal(false);
} catch (error: any) {
console.error("error", error?.data?.url?.error);
console.error("error", error?.data?.error);
setToast({
message: error?.data?.url?.error ?? t("links.toasts.not_created.message"),
message: error?.data?.error ?? t("links.toasts.not_created.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_created.title"),
});
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronUp, PenSquare, Search } from "lucide-react";
import { PenSquare } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
@@ -14,6 +14,8 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web components
import { AppSearch } from "@/plane-web/components/workspace";
export const SidebarQuickActions = observer(() => {
const { t } = useTranslation();
@@ -27,7 +29,7 @@ export const SidebarQuickActions = observer(() => {
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// store hooks
const { toggleCreateIssueModal, toggleCommandPaletteModal } = useCommandPalette();
const { toggleCreateIssueModal } = useCommandPalette();
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { setTrackElement } = useEventTracker();
const { joinedProjectIds } = useProject();
@@ -98,17 +100,7 @@ export const SidebarQuickActions = observer(() => {
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
)}
</button>
<button
className={cn(
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none",
{
"border-[0.5px] border-custom-sidebar-border-300": !isSidebarCollapsed,
}
)}
onClick={() => toggleCommandPaletteModal(true)}
>
<Search className="size-4 text-custom-sidebar-text-300" />
</button>
<AppSearch />
</div>
</>
);
+1
View File
@@ -2,3 +2,4 @@ export * from "./edition-badge";
export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./sidebar";