Compare commits

..
Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
103 changed files with 693 additions and 1085 deletions
+1 -2
View File
@@ -54,5 +54,4 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .member import ProjectMemberSerializer
-30
View File
@@ -1,30 +0,0 @@
from rest_framework import serializers
from .base import BaseSerializer
from plane.db.models import Sticky
from plane.utils.content_validator import validate_html_content, validate_binary_data
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
-2
View File
@@ -9,7 +9,6 @@ from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
urlpatterns = [
*asset_patterns,
@@ -23,5 +22,4 @@ urlpatterns = [
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
]
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from plane.api.views import StickyViewSet
router = DefaultRouter()
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
+1 -3
View File
@@ -54,6 +54,4 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet
from .invite import WorkspaceInvitationsViewset
-109
View File
@@ -1,109 +0,0 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Sticky, Workspace
from plane.api.serializers import StickySerializer
# OpenAPI imports
from plane.utils.openapi.decorators import sticky_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
STICKY_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
)
class StickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
permission_classes = [WorkspaceUserPermission]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.distinct()
)
@sticky_docs(
operation_id="create_sticky",
summary="Create a new sticky",
description="Create a new sticky in the workspace",
request=OpenApiRequest(request=StickySerializer),
responses={
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="list_stickies",
summary="List stickies",
description="List all stickies in the workspace",
responses={
200: create_paginated_response(
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
)
},
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@sticky_docs(
operation_id="retrieve_sticky",
summary="Retrieve a sticky",
description="Retrieve a sticky by its ID",
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def retrieve(self, request, slug, pk):
sticky = self.get_object()
return Response(StickySerializer(sticky).data)
@sticky_docs(
operation_id="update_sticky",
summary="Update a sticky",
description="Update a sticky by its ID",
request=OpenApiRequest(request=StickySerializer),
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def partial_update(self, request, slug, pk):
sticky = self.get_object()
serializer = StickySerializer(sticky, 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)
@sticky_docs(
operation_id="delete_sticky",
summary="Delete a sticky",
description="Delete a sticky by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
sticky = self.get_object()
sticky.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+22 -29
View File
@@ -1,44 +1,43 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
import boto3
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
DeployBoardSerializer,
ProjectListSerializer,
ProjectSerializer,
ProjectListSerializer,
DeployBoardSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.db.models import (
UserFavorite,
DeployBoard,
Intake,
DeployBoard,
IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
@@ -211,25 +210,19 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
project = (
self.get_queryset()
.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
).first()
if project is None:
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
member_ids = [str(project_member.member_id) for project_member in project.members_list]
if str(request.user.id) not in member_ids:
if project.network == ProjectNetwork.SECRET.value:
return Response(
{"error": "You do not have permission"},
status=status.HTTP_403_FORBIDDEN,
)
else:
return Response(
{"error": "You are not a member of this project"},
status=status.HTTP_409_CONFLICT,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
+9 -1
View File
@@ -2,6 +2,8 @@
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
@@ -149,7 +151,13 @@ class UserEndpoint(BaseViewSet):
# 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 = str(secrets.randbelow(900000) + 100000)
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)
@@ -1,7 +1,8 @@
# Python imports
import json
import os
import secrets
import random
import string
# Module imports
@@ -49,7 +50,13 @@ class MagicCodeProvider(CredentialAdapter):
def initiate(self):
## Generate a random token
token = str(secrets.randbelow(900000) + 100000)
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
@@ -1,42 +0,0 @@
# Generated by Django 4.2.25 on 2025-12-02 14:16
from django.db import migrations, models
import plane.db.models.workspace
def get_default_feature_tours():
return {
"work_items": True,
"cycles": True,
"modules": True,
"intake": True,
"pages": True,
}
def populate_feature_tours(apps, _schema_editor):
WorkspaceUserProperties = apps.get_model('db', 'WorkspaceUserProperties')
default_value = get_default_feature_tours()
# Use bulk update for better performance
WorkspaceUserProperties.objects.all().update(feature_tours=default_value)
class Migration(migrations.Migration):
dependencies = [
('db', '0112_auto_20251124_0603'),
]
operations = [
migrations.AddField(
model_name='profile',
name='is_navigation_tour_completed',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='workspaceuserproperties',
name='feature_tours',
field=models.JSONField(default=plane.db.models.workspace.get_default_feature_tours),
),
migrations.RunPython(populate_feature_tours, reverse_code=migrations.RunPython.noop),
]
-1
View File
@@ -52,7 +52,6 @@ from .project import (
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
)
from .session import Session
-3
View File
@@ -214,9 +214,6 @@ class Profile(TimeAuditModel):
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
# navigation tour
is_navigation_tour_completed = models.BooleanField(default=False)
# marketing
has_marketing_email_consent = models.BooleanField(default=False)
-11
View File
@@ -112,16 +112,6 @@ def slug_validator(value):
raise ValidationError("Slug is not valid")
def get_default_feature_tours():
return {
"work_items": False,
"cycles": False,
"modules": False,
"intake": False,
"pages": False,
}
class Workspace(BaseModel):
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
@@ -335,7 +325,6 @@ class WorkspaceUserProperties(BaseModel):
choices=NavigationControlPreference.choices,
default=NavigationControlPreference.ACCORDION,
)
feature_tours = models.JSONField(default=get_default_feature_tours)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
-2
View File
@@ -140,7 +140,6 @@ from .examples import (
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
)
# Helper decorators
@@ -293,7 +292,6 @@ __all__ = [
"WORKSPACE_MEMBER_EXAMPLE",
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -262,18 +262,3 @@ def state_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def sticky_docs(**kwargs):
"""Decorator for sticky management endpoints"""
defaults = {
"tags": ["Stickies"],
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
"parameters": [WORKSPACE_SLUG_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
-17
View File
@@ -672,15 +672,6 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
},
)
STICKY_EXAMPLE = OpenApiExample(
name="Sticky",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
},
)
# Sample data for different entity types
SAMPLE_ISSUE = {
@@ -790,13 +781,6 @@ SAMPLE_CYCLE_ISSUE = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_STICKY = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -811,7 +795,6 @@ SCHEMA_EXAMPLES = {
"Activity": SAMPLE_ACTIVITY,
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
}
@@ -113,7 +113,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
name="code"
value={uniqueCodeFormData.code}
onChange={(e) => handleFormChange("code", e.target.value)}
placeholder="123456"
placeholder="gets-sets-flys"
className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
autoFocus
/>
@@ -82,7 +82,7 @@ function IssueDetailsPage({ params }: Route.ComponentProps) {
return (
<>
<PageHead title={pageTitle} />
{error && !issueLoader ? (
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
@@ -11,7 +11,6 @@ import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-butt
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// local imports
import type { Route } from "./+types/layout";
@@ -45,9 +44,7 @@ function ProjectLayout({ params }: Route.ComponentProps) {
</Row>
</div>
)}
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
<Outlet />
</>
);
}
@@ -0,0 +1,14 @@
import { Outlet } from "react-router";
// plane web layouts
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import type { Route } from "./+types/layout";
export default function ProjectDetailLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
return (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
);
}
@@ -1,6 +1,7 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { ProfileForm } from "@/components/profile/form";
// hooks
@@ -11,7 +12,13 @@ function ProfileSettingsPage() {
// store hooks
const { data: currentUser, userProfile } = useUser();
if (!currentUser) return <></>;
if (!currentUser)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { PreferencesList } from "@/components/preferences/list";
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
@@ -15,23 +16,30 @@ function ProfileAppearancePage() {
// hooks
const { data: userProfile } = useUserProfile();
if (!userProfile) return <></>;
return (
<>
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
{userProfile ? (
<>
<div className="flex flex-col gap-4 w-full">
<div>
<SettingsHeading
title={t("account_settings.preferences.heading")}
description={t("account_settings.preferences.description")}
/>
<PreferencesList />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
</>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
<div>
<ProfileSettingContentHeader title={t("language_and_time")} />
<LanguageTimezone />
</div>
</div>
)}
</>
);
}
@@ -1,33 +0,0 @@
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
// plane web imports
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
// types
import type { Route } from "./+types/layout";
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router
const pathname = usePathname();
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
</ProjectAuthWrapper>
</div>
</>
);
}
export default observer(ProjectDetailSettingsLayout);
@@ -1,5 +1,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
// components
@@ -23,8 +24,12 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { currentProjectDetails } = useProject();
const { currentProjectDetails, fetchProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// api call to fetch project details
// TODO: removed this API if not necessary
const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId));
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
@@ -51,7 +56,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
)}
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails ? (
{currentProjectDetails && !isLoading ? (
<ProjectDetailsForm
project={currentProjectDetails}
workspaceSlug={workspaceSlug}
@@ -1,17 +1,21 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Outlet } from "react-router";
// hooks
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// types
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import type { Route } from "./+types/layout";
function ProjectSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router
const router = useAppRouter();
// store hooks
const pathname = usePathname();
const { workspaceSlug, projectId } = params;
const { joinedProjectIds } = useProject();
useEffect(() => {
@@ -21,7 +25,19 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) {
}
}, [joinedProjectIds, router, workspaceSlug, projectId]);
return <Outlet />;
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
</div>
</ProjectAuthWrapper>
</>
);
}
export default observer(ProjectSettingsLayout);
@@ -1,14 +1,8 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useTheme } from "next-themes";
// plane imports
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { Button, getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
// assets
import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url";
import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
function ProjectSettingsPage() {
@@ -16,10 +10,13 @@ function ProjectSettingsPage() {
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
// derived values
const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState;
const resolvedPath =
resolvedTheme === "dark"
? "/empty-state/project-settings/no-projects-dark.png"
: "/empty-state/project-settings/no-projects-light.png";
return (
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
<img src={resolvedPath} alt="No projects yet" />
<img src={resolvedPath} className="w-full h-full object-contain" alt="No projects yet" />
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
<div className="text-sm text-custom-text-350 text-center">
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
@@ -41,4 +38,4 @@ function ProjectSettingsPage() {
);
}
export default observer(ProjectSettingsPage);
export default ProjectSettingsPage;
+156 -149
View File
@@ -108,13 +108,9 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
":workspaceSlug/projects/archives",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
),
]),
// ====================================================================
// PROJECT LEVEL ROUTES
// ====================================================================
// --------------------------------------------------------------------
// PROJECT LEVEL ROUTES
@@ -126,123 +122,136 @@ export const coreRoutes: RouteConfigEntry[] = [
]),
// Project Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
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",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
":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(
":workspaceSlug/projects/archives",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
),
]),
// Project Archives - Issues - Detail
layout(
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx",
[
route(
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
),
]
),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
// Project Archives - Cycles
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
":workspaceSlug/projects/:projectId/archives/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
),
]),
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
// Project Archives - Modules
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
":workspaceSlug/projects/:projectId/archives/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/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"
),
]),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
),
]),
// Project Archives - Issues - Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
),
]),
// Project Archives - Cycles
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
),
]),
// Project Archives - Modules
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/archives/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
),
]),
]),
@@ -311,46 +320,44 @@ export const coreRoutes: RouteConfigEntry[] = [
// --------------------------------------------------------------------
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [
// No Projects available page
// CORE Routes
// Project Settings
route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"),
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [
// Project Settings
route(
":workspaceSlug/settings/projects/:projectId",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
),
// Project Members
route(
":workspaceSlug/settings/projects/:projectId/members",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
),
// Project Features
route(
":workspaceSlug/settings/projects/:projectId/features",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
),
// Project States
route(
":workspaceSlug/settings/projects/:projectId/states",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
),
// Project Labels
route(
":workspaceSlug/settings/projects/:projectId/labels",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
),
// Project Estimates
route(
":workspaceSlug/settings/projects/:projectId/estimates",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
),
// Project Automations
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
route(
":workspaceSlug/settings/projects/:projectId",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
":workspaceSlug/settings/projects/:projectId/automations",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
),
// Project Members
route(
":workspaceSlug/settings/projects/:projectId/members",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
),
// Project Features
route(
":workspaceSlug/settings/projects/:projectId/features",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
),
// Project States
route(
":workspaceSlug/settings/projects/:projectId/states",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
),
// Project Labels
route(
":workspaceSlug/settings/projects/:projectId/labels",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
),
// Project Estimates
route(
":workspaceSlug/settings/projects/:projectId/estimates",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
),
// Project Automations
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
route(
":workspaceSlug/settings/projects/:projectId/automations",
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
),
]),
]),
]),
]),
+2 -1
View File
@@ -1,10 +1,11 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// layouts
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
export type IProjectAuthWrapper = {
workspaceSlug: string;
projectId: string;
projectId?: string;
children: React.ReactNode;
};
@@ -0,0 +1,67 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import { ClipboardList } from "lucide-react";
// plane imports
import { Button } from "@plane/propel/button";
// assets
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
type Props = {
projectId?: string;
isPrivateProject?: boolean;
};
export function JoinProject(props: Props) {
const { projectId, isPrivateProject = false } = props;
// states
const [isJoiningProject, setIsJoiningProject] = useState(false);
// store hooks
const { joinProject } = useUserPermissions();
const { fetchProjectDetails } = useProject();
const { workspaceSlug } = useParams();
const handleJoin = () => {
if (!workspaceSlug || !projectId) return;
setIsJoiningProject(true);
joinProject(workspaceSlug.toString(), projectId.toString())
.then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
.finally(() => setIsJoiningProject(false));
};
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
<div className="h-44 w-72">
<img src={Unauthorized} className="h-[176px] w-[288px] object-contain" alt="JoinProject" />
</div>
<h1 className="text-xl font-medium text-custom-text-100">
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
</h1>
<div className="w-full max-w-md text-base text-custom-text-200">
<p className="mx-auto w-full text-sm md:w-3/4">
{!isPrivateProject
? `Click the button below to join it.`
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
</p>
</div>
{!isPrivateProject && (
<div>
<Button
variant="primary"
prependIcon={<ClipboardList color="white" />}
loading={isJoiningProject}
onClick={handleJoin}
>
{isJoiningProject ? "Taking you in" : "Click to join"}
</Button>
</div>
)}
</div>
);
}
@@ -1,70 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
type TProps = {
isWorkspaceAdmin: boolean;
handleJoinProject: () => void;
isJoinButtonDisabled: boolean;
errorStatusCode: number | undefined;
};
export const ProjectAccessRestriction = observer(function ProjectAccessRestriction(props: TProps) {
const { isWorkspaceAdmin, handleJoinProject, isJoinButtonDisabled, errorStatusCode } = props;
// plane hooks
const { t } = useTranslation();
// Show join project screen if:
// - User lacks project membership (409 Conflict)
// - User lacks permission to access the private project (403 Forbidden) but is a workspace admin (can join any project)
if (errorStatusCode === 409 || (errorStatusCode === 403 && isWorkspaceAdmin))
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.join_description")}
assetKey="no-access"
assetClassName="size-40"
actions={[
{
label: isJoinButtonDisabled
? t("project_empty_state.no_access.cta_loading")
: t("project_empty_state.no_access.cta_primary"),
onClick: handleJoinProject,
disabled: isJoinButtonDisabled,
},
]}
/>
</div>
);
// Show no access screen if:
// - User lacks permission to access the private project (403 Forbidden)
if (errorStatusCode === 403) {
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.no_access.title")}
description={t("project_empty_state.no_access.restricted_description")}
assetKey="no-access"
assetClassName="size-40"
/>
</div>
);
}
// Show empty state screen if:
// - Project not found (404 Not Found)
// - Any other error status code
return (
<div className="grid h-full w-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("project_empty_state.invalid_project.title")}
description={t("project_empty_state.invalid_project.description")}
assetKey="project"
assetClassName="size-40"
/>
</div>
);
});
+4 -3
View File
@@ -4,14 +4,15 @@ import useSWR from "swr";
// plane imports
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
import { ContentWrapper } from "@plane/ui";
// components
import { TourRoot } from "@/components/onboarding/tour";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { useUserProfile, useUser } from "@/hooks/store/user";
// plane web imports
// plane web components
import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
import { TourRoot } from "@/plane-web/components/onboarding/tour/root";
// local imports
import { DashboardWidgets } from "./home-dashboard-widgets";
import { UserGreetingsView } from "./user-greetings";
@@ -52,7 +53,7 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
return (
<>
{currentUserProfile && !currentUserProfile.is_tour_completed && (
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity overflow-y-auto">
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
<TourRoot onComplete={handleTourCompleted} />
</div>
)}
@@ -6,6 +6,7 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
// hooks
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -25,7 +26,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
// derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
@@ -35,7 +36,15 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
<ProjectLevelWorkItemFiltersHOC
@@ -7,6 +7,7 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { TransferIssues } from "@/components/cycles/transfer-issues";
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
// hooks
@@ -58,7 +59,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
async () => {
if (workspaceSlug && projectId && cycleId) {
@@ -77,7 +78,15 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
: 0;
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
if (!workspaceSlug || !projectId || !cycleId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
<ProjectLevelWorkItemFiltersHOC
@@ -6,6 +6,8 @@ import useSWR from "swr";
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
import { Row, ERowVariant } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -48,7 +50,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && moduleId
? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}`
: null,
@@ -60,7 +62,15 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId || !moduleId || !workItemFilters) return <></>;
if (!workspaceSlug || !projectId || !moduleId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
<ProjectLevelWorkItemFiltersHOC
@@ -1,3 +1,4 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
@@ -6,6 +7,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@p
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
import { Spinner } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
@@ -47,7 +49,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout;
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
async () => {
if (workspaceSlug && projectId) {
@@ -57,7 +59,15 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
if (!workspaceSlug || !projectId) return <></>;
if (isLoading && !workItemFilters)
return (
<div className="h-full w-full flex items-center justify-center">
<LogoSpinner />
</div>
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
<ProjectLevelWorkItemFiltersHOC
@@ -5,6 +5,8 @@ import useSWR from "swr";
// plane constants
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
@@ -58,7 +60,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
}
: undefined;
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
async () => {
if (workspaceSlug && projectId && viewId) {
@@ -76,7 +78,16 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
[issuesFilter, workspaceSlug, viewId]
);
if (!workspaceSlug || !projectId || !viewId || !workItemFilters) return <></>;
if (!workspaceSlug || !projectId || !viewId) return <></>;
if (isLoading && !workItemFilters) {
return (
<div className="relative flex h-screen w-full items-center justify-center">
<LogoSpinner />
</div>
);
}
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
<ProjectLevelWorkItemFiltersHOC
@@ -6,6 +6,7 @@ import { SPREADSHEET_SELECT_GROUP, SPREADSHEET_PROPERTY_LIST } from "@plane/cons
import type { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { MultipleSelectGroup } from "@/components/core/multiple-select";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -71,7 +72,13 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
return true;
});
if (!issueIds || issueIds.length === 0) return <></>;
if (!issueIds || issueIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<LogoSpinner />
</div>
);
return (
<div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200">
<div ref={portalRef} className="spreadsheet-menu-portal" />
@@ -1,8 +1,9 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useOutsideClickDetector } from "@plane/hooks";
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// power-k
@@ -13,7 +14,6 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
export const TopNavPowerK = observer(() => {
// router
@@ -22,6 +22,7 @@ export const TopNavPowerK = observer(() => {
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
// states
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
@@ -31,25 +32,6 @@ export const TopNavPowerK = observer(() => {
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
const { data: currentUser } = useUser();
const handleOnClose = useCallback(() => {
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
}, [setSearchTerm, setActivePage, setActiveCommand]);
// expandable search hook
const {
isOpen,
containerRef,
inputRef,
handleClose: closePanel,
handleMouseDown,
handleFocus,
openPanel,
} = useExpandableSearch({
onClose: handleOnClose,
});
// derived values
const {
issue: { getIssueById, getIssueIdByIdentifier },
@@ -72,7 +54,12 @@ export const TopNavPowerK = observer(() => {
projectId,
},
router,
closePalette: closePanel,
closePalette: () => {
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
setActiveCommand(null);
},
setActiveCommand,
setActivePage,
}),
@@ -85,10 +72,12 @@ export const TopNavPowerK = observer(() => {
projectId,
router,
setActivePage,
closePanel,
]
);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Register input ref with PowerK store for keyboard shortcut access
useEffect(() => {
setTopNavInputRef(inputRef);
@@ -97,6 +86,18 @@ export const TopNavPowerK = observer(() => {
};
}, [setTopNavInputRef]);
useOutsideClickDetector(containerRef, () => {
if (isOpen) {
setIsOpen(false);
setActivePage(null);
setActiveCommand(null);
}
});
const handleFocus = () => {
setIsOpen(true);
};
const handleClear = () => {
setSearchTerm("");
inputRef.current?.focus();
@@ -135,7 +136,10 @@ export const TopNavPowerK = observer(() => {
// Cmd/Ctrl+K closes the search dropdown
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
closePanel();
setIsOpen(false);
setSearchTerm("");
setActivePage(null);
context.setActiveCommand(null);
return;
}
@@ -144,7 +148,9 @@ export const TopNavPowerK = observer(() => {
if (searchTerm) {
setSearchTerm("");
}
closePanel();
setIsOpen(false);
inputRef.current?.blur();
return;
}
@@ -197,7 +203,7 @@ export const TopNavPowerK = observer(() => {
return;
}
},
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
);
return (
@@ -222,11 +228,7 @@ export const TopNavPowerK = observer(() => {
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
@@ -42,10 +42,9 @@ export const useResponsiveTabLayout = ({
const gap = 4; // gap-1 = 4px
const overflowButtonWidth = 40;
const container = containerRef?.current;
// ResizeObserver to measure container width
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
@@ -59,7 +58,7 @@ export const useResponsiveTabLayout = ({
return () => {
resizeObserver.disconnect();
};
}, [container]);
}, []);
// Calculate how many items can fit
useEffect(() => {
@@ -0,0 +1 @@
export * from "./root";
@@ -18,7 +18,7 @@ import { useUser } from "@/hooks/store/user";
// local imports
import { TourSidebar } from "./sidebar";
export type TOnboardingTourProps = {
type Props = {
onComplete: () => void;
};
@@ -28,7 +28,7 @@ const TOUR_STEPS: {
key: TTourSteps;
title: string;
description: string;
image: string;
image: any;
prevStep?: TTourSteps;
nextStep?: TTourSteps;
}[] = [
@@ -75,7 +75,7 @@ const TOUR_STEPS: {
},
];
export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps) {
export const TourRoot = observer(function TourRoot(props: Props) {
const { onComplete } = props;
// states
const [step, setStep] = useState<TTourSteps>("welcome");
@@ -89,12 +89,12 @@ export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps)
return (
<>
{step === "welcome" ? (
<div className="w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
<div className="h-3/4 w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
<div className="h-full overflow-hidden">
<div className="grid h-64 place-items-center bg-custom-primary-100">
<PlaneLockup className="h-10 w-auto text-white" />
<div className="grid h-3/5 place-items-center bg-custom-primary-100">
<PlaneLockup className="h-10 w-auto text-custom-text-100" />
</div>
<div className="flex flex-col overflow-y-auto p-6">
<div className="flex h-2/5 flex-col overflow-y-auto p-6">
<h3 className="font-semibold sm:text-xl">
Welcome to Plane, {currentUser?.first_name} {currentUser?.last_name}
</h3>
@@ -103,7 +103,7 @@ export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps)
started by creating a project.
</p>
<div className="flex h-full items-end">
<div className="mt-12 flex items-center gap-6">
<div className="mt-8 flex items-center gap-6">
<Button
variant="primary"
onClick={() => {
@@ -1,13 +1,12 @@
// plane imports
// icons
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
// types
import type { TTourSteps } from "./root";
const sidebarOptions: {
key: TTourSteps;
label: string;
Icon: React.FC<ISvgIcons>;
Icon: any;
}[] = [
{
key: "work-items",
@@ -49,18 +49,16 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
<div className="flex items-center justify-between gap-2 px-2">
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
{title === "Projects" && (
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
onClick={() => setIsCustomizeNavDialogOpen(true)}
>
<PreferencesIcon className="size-4" />
</button>
<AppSidebarToggleButton />
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
onClick={() => setIsCustomizeNavDialogOpen(true)}
>
<PreferencesIcon className="size-4" />
</button>
<AppSidebarToggleButton />
</div>
</div>
{/* Quick actions */}
{quickActions}
@@ -17,6 +17,7 @@ import type { IWorkspace } from "@plane/types";
import { CustomSelect, Input } from "@plane/ui";
import { copyUrlToClipboard, getFileURL } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
@@ -128,7 +129,13 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
if (!currentWorkspace) return <></>;
if (!currentWorkspace)
return (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
);
return (
<>
<Controller
@@ -7,7 +7,6 @@ import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { createRoot } from "react-dom/client";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import { LinkIcon, Settings, Share2, LogOut, MoreHorizontal } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
@@ -226,29 +225,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (URLProjectId === project?.id) {
setIsProjectListOpen(true);
// Scroll to active project
if (projectRef.current) {
timeoutId = setTimeout(() => {
if (projectRef.current) {
scrollIntoView(projectRef.current, {
behavior: "smooth",
block: "center",
scrollMode: "if-needed",
});
}
}, 200);
}
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
if (URLProjectId === project?.id) setIsProjectListOpen(true);
}, [URLProjectId, project?.id, setIsProjectListOpen]);
if (!project) return null;
@@ -46,7 +46,6 @@ export const useEditorMention = (args: TArgs) => {
name={user.member__display_name}
/>
),
id: user.member__id,
entity_identifier: user.member__id,
entity_name: "user_mention",
title: user.member__display_name,
@@ -1,75 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { useOutsideClickDetector } from "@plane/hooks";
type UseExpandableSearchOptions = {
onClose?: () => void;
};
/**
* Custom hook for expandable search input behavior
* Handles focus management to prevent unwanted opening on programmatic focus restoration
*/
export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
const { onClose } = options || {};
// states
const [isOpen, setIsOpen] = useState(false);
// refs
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const wasClickedRef = useRef<boolean>(false);
// Handle close
const handleClose = useCallback(() => {
setIsOpen(false);
inputRef.current?.blur();
onClose?.();
}, [onClose]);
// Outside click handler - memoized to prevent unnecessary re-registrations
const handleOutsideClick = useCallback(() => {
if (isOpen) {
handleClose();
}
}, [isOpen, handleClose]);
// Outside click detection
useOutsideClickDetector(containerRef, handleOutsideClick);
// Track explicit clicks
const handleMouseDown = useCallback(() => {
wasClickedRef.current = true;
}, []);
// Only open on explicit clicks, not programmatic focus
const handleFocus = useCallback(() => {
if (wasClickedRef.current) {
setIsOpen(true);
wasClickedRef.current = false;
}
}, []);
// Helper to open panel (for typing/onChange)
const openPanel = useCallback(() => {
if (!isOpen) {
setIsOpen(true);
}
}, [isOpen]);
return {
// State
isOpen,
setIsOpen,
// Refs
containerRef,
inputRef,
// Handlers
handleClose,
handleMouseDown,
handleFocus,
openPanel,
};
};
@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { GANTT_TIMELINE_TYPE } from "@plane/types";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { EProjectNetwork, GANTT_TIMELINE_TYPE } from "@plane/types";
// components
import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction";
import { JoinProject } from "@/components/auth-screens/project/join-project";
import { LogoSpinner } from "@/components/common/logo-spinner";
import {
PROJECT_DETAILS,
PROJECT_ME_INFORMATION,
@@ -20,8 +23,10 @@ import {
PROJECT_VIEWS,
PROJECT_INTAKE_STATE,
} from "@/constants/fetch-keys";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
@@ -34,19 +39,19 @@ import { useTimeLineChart } from "@/hooks/use-timeline-chart";
interface IProjectAuthWrapper {
workspaceSlug: string;
projectId: string;
projectId?: string;
children: ReactNode;
isLoading?: boolean;
}
export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IProjectAuthWrapper) {
const { workspaceSlug, projectId, children, isLoading: isParentLoading = false } = props;
// states
const [isJoiningProject, setIsJoiningProject] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { fetchUserProjectInfo, allowPermissions } = useUserPermissions();
const { fetchProjectDetails } = useProject();
const { joinProject } = useUserPermissions();
const { toggleCreateProjectModal } = useCommandPalette();
const { fetchUserProjectInfo, allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { loader, getProjectById, fetchProjectDetails } = useProject();
const { fetchAllCycles } = useCycle();
const { fetchModulesSlim, fetchModules } = useModule();
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
@@ -58,7 +63,10 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
const { data: currentUserData } = useUser();
const { fetchProjectLabels } = useLabel();
const { getProjectEstimates } = useProjectEstimates();
// derived values
const projectExists = projectId ? getProjectById(projectId) : null;
const projectMemberInfo = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const hasPermissionToCurrentProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT,
@@ -74,84 +82,120 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
}, []);
// fetching project details
const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR(
PROJECT_DETAILS(workspaceSlug, projectId),
() => fetchProjectDetails(workspaceSlug, projectId)
useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
// fetching user project member information
useSWR(PROJECT_ME_INFORMATION(workspaceSlug, projectId), () => fetchUserProjectInfo(workspaceSlug, projectId));
// fetching project member preferences
useSWR(
currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
currentUserData?.id ? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id) : null,
workspaceSlug && projectId && currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
workspaceSlug && projectId && currentUserData?.id
? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id)
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project labels
useSWR(PROJECT_LABELS(workspaceSlug, projectId), () => fetchProjectLabels(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_LABELS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project members
useSWR(PROJECT_MEMBERS(workspaceSlug, projectId), () => fetchProjectMembers(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectMembers(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project states
useSWR(PROJECT_STATES(workspaceSlug, projectId), () => fetchProjectStates(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_STATES(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project intake state
useSWR(PROJECT_INTAKE_STATE(workspaceSlug, projectId), () => fetchProjectIntakeState(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_INTAKE_STATE(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectIntakeState(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project estimates
useSWR(PROJECT_ESTIMATES(workspaceSlug, projectId), () => getProjectEstimates(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => getProjectEstimates(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project cycles
useSWR(PROJECT_ALL_CYCLES(workspaceSlug, projectId), () => fetchAllCycles(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_ALL_CYCLES(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project modules
useSWR(
PROJECT_MODULES(workspaceSlug, projectId),
async () => {
await Promise.all([fetchModulesSlim(workspaceSlug, projectId), fetchModules(workspaceSlug, projectId)]);
},
workspaceSlug && projectId ? PROJECT_MODULES(workspaceSlug, projectId) : null,
workspaceSlug && projectId
? async () => {
await fetchModulesSlim(workspaceSlug, projectId);
await fetchModules(workspaceSlug, projectId);
}
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project views
useSWR(PROJECT_VIEWS(workspaceSlug, projectId), () => fetchViews(workspaceSlug, projectId), {
revalidateIfStale: false,
revalidateOnFocus: false,
});
useSWR(
workspaceSlug && projectId ? PROJECT_VIEWS(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchViews(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// handle join project
const handleJoinProject = () => {
setIsJoiningProject(true);
joinProject(workspaceSlug, projectId)
.then(() => fetchProjectDetails(workspaceSlug, projectId))
.finally(() => setIsJoiningProject(false));
};
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isProjectLoading = (isParentLoading || isProjectDetailsLoading) && !projectDetailsError;
if (isProjectLoading) return null;
if (!isProjectLoading && hasPermissionToCurrentProject === false) {
// check if the project member apis is loading
if (isParentLoading || (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null))
return (
<ProjectAccessRestriction
errorStatusCode={projectDetailsError?.status}
isWorkspaceAdmin={isWorkspaceAdmin}
handleJoinProject={handleJoinProject}
isJoinButtonDisabled={isJoiningProject}
/>
<div className="grid h-full place-items-center bg-custom-background-100 p-4 rounded-lg border border-custom-border-200">
<div className="flex flex-col items-center gap-3 text-center">
<LogoSpinner />
</div>
</div>
);
// check if the user don't have permission to access the project
if (
((projectExists?.network && projectExists?.network !== EProjectNetwork.PRIVATE) || isWorkspaceAdmin) &&
projectId &&
hasPermissionToCurrentProject === false
)
return <JoinProject projectId={projectId} />;
// check if the project info is not found.
if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false)
return (
<div className="grid h-full place-items-center bg-custom-background-100">
<EmptyStateDetailed
title={t("workspace_projects.empty_state.general.title")}
description={t("workspace_projects.empty_state.general.description")}
assetKey="project"
assetClassName="size-40"
actions={[
{
label: t("workspace_projects.empty_state.general.primary_button.text"),
onClick: () => {
toggleCreateProjectModal(true);
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
},
disabled: !canPerformEmptyStateActions,
variant: "primary",
},
]}
/>
</div>
);
}
return <>{children}</>;
});
@@ -57,7 +57,7 @@ export class ProjectService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
throw error?.response?.data;
});
}
@@ -69,3 +69,5 @@ export const BLOCK_NODE_TYPES = [
CORE_EXTENSIONS.CALLOUT,
CORE_EXTENSIONS.WORK_ITEM_EMBED,
];
export const INLINE_NODE_TYPES = [CORE_EXTENSIONS.MENTION];
@@ -1,6 +1,6 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import React, { useState } from "react";
import { useState } from "react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
@@ -33,6 +33,7 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
style={{
backgroundColor: activeBackgroundColor,
}}
key={`callout-block-${node.attrs.id}`}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
@@ -1,4 +1,5 @@
import type { Node as ProseMirrorNode } from "@tiptap/core";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color",
@@ -20,7 +21,7 @@ export type TCalloutBlockEmojiAttributes = {
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
export type TCalloutBlockAttributes = TBlockNodeBaseAttributes & {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
@@ -1,4 +1,4 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
@@ -8,16 +8,20 @@ import { useState } from "react";
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TCodeBlockAttributes } from "./types";
// we just have ts support for now
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
type Props = {
node: ProseMirrorNode;
export type CodeBlockNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCodeBlockAttributes;
};
};
export function CodeBlockComponent({ node }: Props) {
export function CodeBlockComponent({ node }: CodeBlockNodeViewProps) {
const [copied, setCopied] = useState(false);
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -33,7 +37,7 @@ export function CodeBlockComponent({ node }: Props) {
};
return (
<NodeViewWrapper className="code-block relative group/code">
<NodeViewWrapper className="code-block relative group/code" key={`code-block-${node.attrs.id}`}>
<Tooltip tooltipContent="Copy code">
<button
type="button"
@@ -5,13 +5,16 @@ import { common, createLowlight } from "lowlight";
// components
import { CodeBlockLowlight } from "./code-block-lowlight";
import { CodeBlockComponent } from "./code-block-node-view";
import type { CodeBlockNodeViewProps } from "./code-block-node-view";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
return ReactNodeViewRenderer((props) => (
<CodeBlockComponent {...props} node={props.node as CodeBlockNodeViewProps["node"]} />
));
},
addKeyboardShortcuts() {
@@ -0,0 +1,5 @@
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export type TCodeBlockAttributes = TBlockNodeBaseAttributes & {
language: string | null;
};
@@ -122,7 +122,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
return (
<NodeViewWrapper>
<NodeViewWrapper key={`image-block-${node.attrs.id}`}>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock
@@ -1,6 +1,7 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export enum ECustomImageAttributeNames {
ID = "id",
@@ -32,8 +33,7 @@ export enum ECustomImageStatus {
DUPLICATION_FAILED = "duplication-failed",
}
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
export type TCustomImageAttributes = TBlockNodeBaseAttributes & {
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
@@ -1,5 +1,7 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { v4 as uuidv4 } from "uuid";
// extension config
import type { TMentionExtensionOptions } from "./extension-config";
// extension types
@@ -19,7 +21,7 @@ export function MentionNodeView(props: MentionNodeViewProps) {
} = props;
return (
<NodeViewWrapper className="mention-component inline w-fit">
<NodeViewWrapper className="mention-component inline w-fit" key={`mention-${attrs.id}`}>
{(extension.options as TMentionExtensionOptions).renderComponent({
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
@@ -31,11 +31,9 @@ export const MentionsListDropdown = forwardRef(function MentionsListDropdown(pro
(sectionIndex: number, itemIndex: number) => {
try {
const item = sections?.[sectionIndex]?.items?.[itemIndex];
const transactionId = uuidv4();
if (item) {
command({
...item,
id: transactionId,
});
}
} catch (error) {
@@ -4,13 +4,12 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Transaction } from "@tiptap/pm/state";
import { v4 as uuidv4 } from "uuid";
// constants
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES } from "@/constants/extension";
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from "@/constants/extension";
import { ADDITIONAL_BLOCK_NODE_TYPES } from "@/plane-editor/constants/extensions";
import { createUniqueIDPlugin } from "./plugin";
import { createIdsForView } from "./utils";
// plane imports
const COMBINED_BLOCK_NODE_TYPES = [...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
const COMBINED_BLOCK_NODE_TYPES = [...INLINE_NODE_TYPES, ...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
export type UniqueIDGenerationContext = {
node: ProseMirrorNode;
pos: number;
@@ -0,0 +1,7 @@
/**
* Base attributes for all block nodes that have the unique-id extension.
* All block node attribute types should extend this.
*/
export interface TBlockNodeBaseAttributes {
id?: string | null;
}
@@ -2,6 +2,7 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
// local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config";
import type { TWorkItemEmbedAttributes } from "./types";
type Props = {
widgetCallback: ({
@@ -18,15 +19,18 @@ type Props = {
export function WorkItemEmbedExtension(props: Props) {
return WorkItemEmbedExtensionConfig.extend({
addNodeView() {
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
<NodeViewWrapper>
{props.widgetCallback({
issueId: issueProps.node.attrs.entity_identifier,
projectId: issueProps.node.attrs.project_identifier,
workspaceSlug: issueProps.node.attrs.workspace_identifier,
})}
</NodeViewWrapper>
));
return ReactNodeViewRenderer((issueProps: NodeViewProps) => {
const attrs = issueProps.node.attrs as TWorkItemEmbedAttributes;
return (
<NodeViewWrapper key={`work-item-embed-${attrs.id}`}>
{props.widgetCallback({
issueId: attrs.entity_identifier!,
projectId: attrs.project_identifier,
workspaceSlug: attrs.workspace_identifier,
})}
</NodeViewWrapper>
);
});
},
});
}
@@ -0,0 +1,8 @@
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
export type TWorkItemEmbedAttributes = TBlockNodeBaseAttributes & {
entity_identifier: string | undefined;
project_identifier: string | undefined;
workspace_identifier: string | undefined;
entity_name: string | undefined;
};
+1 -1
View File
@@ -5,7 +5,7 @@ export type TMentionSuggestion = {
entity_identifier: string;
entity_name: TSearchEntities;
icon: React.ReactNode;
id: string;
id?: string | null;
subTitle?: string;
title: string;
};
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Vypadá to, že nemáte přístup k tomuto projektu",
restricted_description: "Kontaktujte administrátora a požádejte o přístup, abyste zde mohli pokračovat.",
join_description: "Klikněte na tlačítko níže pro připojení k projektu.",
cta_primary: "Připojit se k projektu",
cta_loading: "Připojování k projektu",
},
invalid_project: {
title: "Projekt nebyl nalezen",
description: "Projekt, který hledáte, neexistuje.",
},
work_items: {
title: "Začněte s vaší první pracovní položkou.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Jedinečný kód",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Vložte kód zaslaný na váš e-mail",
requesting_new_code: "Žádám o nový kód",
sending_code: "Odesílám kód",
@@ -1544,7 +1544,7 @@ export default {
},
code: {
label: "Jedinečný kód",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Ověřovací kód byl odeslán na váš nový e-mail.",
errors: {
required: "Jedinečný kód je povinný",
@@ -26,18 +26,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Es scheint, als hätten Sie keinen Zugriff auf dieses Projekt",
restricted_description:
"Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.",
join_description: "Klicken Sie unten auf die Schaltfläche, um beizutreten.",
cta_primary: "Projekt beitreten",
cta_loading: "Projekt wird beigetreten",
},
invalid_project: {
title: "Projekt nicht gefunden",
description: "Das gesuchte Projekt existiert nicht.",
},
work_items: {
title: "Beginnen Sie mit Ihrem ersten Arbeitselement.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Einmaliger Code",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Fügen Sie den an Ihre E-Mail gesendeten Code ein",
requesting_new_code: "Neuen Code anfordern",
sending_code: "Code wird gesendet",
@@ -1562,7 +1562,7 @@ export default {
},
code: {
label: "Einmaliger Code",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Verifizierungscode wurde an deine neue E-Mail gesendet.",
errors: {
required: "Einmaliger Code ist erforderlich",
+1 -1
View File
@@ -75,7 +75,7 @@ export default {
},
unique_code: {
label: "Unique code",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Paste the code sent to your email",
requesting_new_code: "Requesting new code",
sending_code: "Sending code",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Seems like you dont have access to this Project",
restricted_description: "Contact admin to request for access and you can continue here.",
join_description: "Click the button below to join it.",
cta_primary: "Join project",
cta_loading: "Joining project",
},
invalid_project: {
title: "Project not found",
description: "The project you are looking for does not exist.",
},
work_items: {
title: "Start with your first work item.",
description:
+1 -1
View File
@@ -1378,7 +1378,7 @@ export default {
},
code: {
label: "Unique code",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Verification code sent to your new email.",
errors: {
required: "Unique code is required",
@@ -26,17 +26,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Parece que no tienes acceso a este proyecto",
restricted_description: "Contacta con el administrador para solicitar acceso y podrás continuar aquí.",
join_description: "Haz clic en el botón de abajo para unirte.",
cta_primary: "Unirse al proyecto",
cta_loading: "Uniéndose al proyecto",
},
invalid_project: {
title: "Proyecto no encontrado",
description: "El proyecto que buscas no existe.",
},
work_items: {
title: "Comienza con tu primer elemento de trabajo.",
description:
+1 -1
View File
@@ -1566,7 +1566,7 @@ export default {
},
code: {
label: "Código único",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Código de verificación enviado a tu nuevo correo electrónico.",
errors: {
required: "El código único es obligatorio",
@@ -27,17 +27,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Il semble que vous nayez pas accès à ce projet",
restricted_description: "Contactez ladministrateur pour demander laccès afin de pouvoir continuer ici.",
join_description: "Cliquez sur le bouton ci-dessous pour rejoindre le projet.",
cta_primary: "Rejoindre le projet",
cta_loading: "Rejoindre le projet…",
},
invalid_project: {
title: "Projet non trouvé",
description: "Le projet que vous recherchez nexiste pas.",
},
work_items: {
title: "Commencez avec votre premier élément de travail.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Code unique",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Collez le code envoyé à votre e-mail",
requesting_new_code: "Demande dun nouveau code",
sending_code: "Envoi du code",
@@ -1564,7 +1564,7 @@ export default {
},
code: {
label: "Code unique",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Code de vérification envoyé à votre nouvel e-mail.",
errors: {
required: "Le code unique est requis",
@@ -25,17 +25,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Sepertinya Anda tidak memiliki akses ke Proyek ini",
restricted_description: "Hubungi admin untuk meminta akses agar Anda dapat melanjutkan di sini.",
join_description: "Klik tombol di bawah ini untuk bergabung.",
cta_primary: "Bergabung dengan proyek",
cta_loading: "Sedang bergabung dengan proyek",
},
invalid_project: {
title: "Proyek tidak ditemukan",
description: "Proyek yang Anda cari tidak ada.",
},
work_items: {
title: "Mulai dengan item kerja pertama Anda.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Kode unik",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Tempelkan kode yang dikirim ke email anda",
requesting_new_code: "Meminta kode baru",
sending_code: "Mengirim kode",
@@ -1552,7 +1552,7 @@ export default {
},
code: {
label: "Kode unik",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Kode verifikasi dikirim ke email baru Anda.",
errors: {
required: "Kode unik wajib diisi",
@@ -27,17 +27,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Sembra che tu non abbia accesso a questo progetto",
restricted_description: "Contatta l'amministratore per richiedere l'accesso e potrai continuare qui.",
join_description: "Clicca sul pulsante qui sotto per unirti.",
cta_primary: "Unisciti al progetto",
cta_loading: "Unione al progetto in corso",
},
invalid_project: {
title: "Progetto non trovato",
description: "Il progetto che stai cercando non esiste.",
},
work_items: {
title: "Inizia con il tuo primo elemento di lavoro.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Codice unico",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Incolla il codice inviato alla tua email",
requesting_new_code: "Richiesta di nuovo codice",
sending_code: "Invio codice",
@@ -1556,7 +1556,7 @@ export default {
},
code: {
label: "Codice univoco",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Codice di verifica inviato alla tua nuova email.",
errors: {
required: "Il codice univoco è obbligatorio",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "このプロジェクトへのアクセス権がないようです",
restricted_description: "管理者に連絡してアクセス権をリクエストすると、ここで作業を続けられます。",
join_description: "下のボタンをクリックして参加してください。",
cta_primary: "プロジェクトに参加",
cta_loading: "プロジェクトに参加中",
},
invalid_project: {
title: "プロジェクトが見つかりません",
description: "お探しのプロジェクトは存在しません。",
},
work_items: {
title: "最初の作業項目から始めましょう。",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "ユニークコード",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "メールで送信されたコードを貼り付けてください",
requesting_new_code: "新しいコードをリクエスト中",
sending_code: "コードを送信中",
@@ -1543,7 +1543,7 @@ export default {
},
code: {
label: "認証コード",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "認証コードを新しいメールに送信しました。",
errors: {
required: "認証コードは必須です",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "이 프로젝트에 접근할 수 없는 것 같습니다",
restricted_description: "관리자에게 접근 권한을 요청하시면 여기서 계속 진행하실 수 있습니다.",
join_description: "아래 버튼을 클릭하여 프로젝트에 참여하세요.",
cta_primary: "프로젝트 참여",
cta_loading: "프로젝트 참여 중",
},
invalid_project: {
title: "프로젝트를 찾을 수 없습니다",
description: "찾으시는 프로젝트가 존재하지 않습니다.",
},
work_items: {
title: "첫 번째 작업 항목으로 시작하세요.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "고유 코드",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "이메일로 전송된 코드를 붙여넣기",
requesting_new_code: "새 코드 요청 중",
sending_code: "코드 전송 중",
@@ -1536,7 +1536,7 @@ export default {
},
code: {
label: "고유 코드",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "인증 코드가 새 이메일로 전송되었습니다.",
errors: {
required: "고유 코드는 필수입니다",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Wygląda na to, że nie masz dostępu do tego projektu",
restricted_description: "Skontaktuj się z administratorem, aby poprosić o dostęp i móc kontynuować tutaj.",
join_description: "Kliknij przycisk poniżej, aby dołączyć.",
cta_primary: "Dołącz do projektu",
cta_loading: "Dołączanie do projektu",
},
invalid_project: {
title: "Projekt nie został znaleziony",
description: "Projekt, którego szukasz, nie istnieje.",
},
work_items: {
title: "Zacznij od swojego pierwszego elementu roboczego.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Unikalny kod",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Wklej kod wysłany na Twój e-mail",
requesting_new_code: "Żądanie nowego kodu",
sending_code: "Wysyłanie kodu",
@@ -1547,7 +1547,7 @@ export default {
},
code: {
label: "Unikalny kod",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Kod weryfikacyjny wysłano na nowy e-mail.",
errors: {
required: "Unikalny kod jest wymagany",
@@ -26,18 +26,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Parece que você não tem acesso a este projeto",
restricted_description:
"Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.",
join_description: "Clique no botão abaixo para participar.",
cta_primary: "Participar do projeto",
cta_loading: "Participando do projeto",
},
invalid_project: {
title: "Projeto não encontrado",
description: "O projeto que você está procurando não existe.",
},
work_items: {
title: "Comece com seu primeiro item de trabalho.",
description:
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Código único",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Cole o código enviado para seu email",
requesting_new_code: "Solicitando novo código",
sending_code: "Enviando código",
@@ -1564,7 +1564,7 @@ export default {
},
code: {
label: "Código único",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Código de verificação enviado para o novo e-mail.",
errors: {
required: "O código único é obrigatório",
@@ -25,17 +25,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Se pare că nu aveți acces la acest proiect",
restricted_description: "Contactați administratorul pentru a solicita accesul și veți putea continua aici.",
join_description: "Faceți clic pe butonul de mai jos pentru a vă alătura.",
cta_primary: "Alăturați-vă proiectului",
cta_loading: "Se alătură proiectului",
},
invalid_project: {
title: "Proiect negăsit",
description: "Proiectul pe care îl căutați nu există.",
},
work_items: {
title: "Începeți cu primul dvs. element de lucru.",
description:
+1 -1
View File
@@ -1556,7 +1556,7 @@ export default {
},
code: {
label: "Cod unic",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Codul de verificare a fost trimis la noul e-mail.",
errors: {
required: "Codul unic este obligatoriu",
@@ -25,17 +25,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Похоже, у вас нет доступа к этому проекту",
restricted_description: "Свяжитесь с администратором, чтобы запросить доступ, и вы сможете продолжить здесь.",
join_description: "Нажмите кнопку ниже, чтобы присоединиться.",
cta_primary: "Присоединиться к проекту",
cta_loading: "Присоединение к проекту",
},
invalid_project: {
title: "Проект не найден",
description: "Проект, который вы ищете, не существует.",
},
work_items: {
title: "Начните с вашего первого рабочего элемента.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Уникальный код",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Вставьте код, отправленный на ваш email",
requesting_new_code: "Запрос нового кода",
sending_code: "Отправка кода",
@@ -1549,7 +1549,7 @@ export default {
},
code: {
label: "Уникальный код",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Код подтверждения отправлен на ваш новый email.",
errors: {
required: "Уникальный код обязателен",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Zdá sa, že nemáte prístup k tomuto projektu",
restricted_description: "Kontaktujte administrátora, aby ste požiadali o prístup, a potom tu môžete pokračovať.",
join_description: "Kliknite na tlačidlo nižšie, aby ste sa pripojili.",
cta_primary: "Pripojiť sa k projektu",
cta_loading: "Pripájanie k projektu",
},
invalid_project: {
title: "Projekt nebol nájdený",
description: "Projekt, ktorý hľadáte, neexistuje.",
},
work_items: {
title: "Začnite s vašou prvou pracovnou položkou.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Jedinečný kód",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Vložte kód zaslaný na váš e-mail",
requesting_new_code: "Žiadam o nový kód",
sending_code: "Odosielam kód",
@@ -1547,7 +1547,7 @@ export default {
},
code: {
label: "Jedinečný kód",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Overovací kód bol odoslaný na váš nový e-mail.",
errors: {
required: "Jedinečný kód je povinný",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Görünüşe göre bu projeye erişiminiz yok",
restricted_description: "Erişim talep etmek için yöneticiyle iletişime geçin, sonra burada devam edebilirsiniz.",
join_description: "Katılmak için aşağıdaki butona tıklayın.",
cta_primary: "Projeye katıl",
cta_loading: "Projeye katılınıyor",
},
invalid_project: {
title: "Proje bulunamadı",
description: "Aradığınız proje mevcut değil.",
},
work_items: {
title: "İlk iş öğenizle başlayın.",
description:
@@ -1551,7 +1551,7 @@ export default {
},
code: {
label: "Benzersiz kod",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Doğrulama kodu yeni e-postanıza gönderildi.",
errors: {
required: "Benzersiz kod zorunludur",
@@ -25,17 +25,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Схоже, у вас немає доступу до цього проєкту",
restricted_description: "Зверніться до адміністратора, щоб запросити доступ, і ви зможете продовжити тут.",
join_description: "Натисніть кнопку нижче, щоб приєднатися.",
cta_primary: "Приєднатися до проєкту",
cta_loading: "Приєднання до проєкту",
},
invalid_project: {
title: "Проєкт не знайдено",
description: "Проєкт, який ви шукаєте, не існує.",
},
work_items: {
title: "Почніть з вашого першого робочого елемента.",
description:
+2 -2
View File
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Унікальний код",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Вставте код, надісланий на вашу електронну пошту",
requesting_new_code: "Запитую новий код",
sending_code: "Надсилаю код",
@@ -1551,7 +1551,7 @@ export default {
},
code: {
label: "Унікальний код",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Код підтвердження надіслано на ваш новий email.",
errors: {
required: "Унікальний код є обов’язковим",
@@ -25,17 +25,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "Có vẻ như bạn không có quyền truy cập vào Dự án này",
restricted_description: "Liên hệ với quản trị viên để yêu cầu quyền truy cập và bạn có thể tiếp tục tại đây.",
join_description: "Nhấn nút bên dưới để tham gia.",
cta_primary: "Tham gia dự án",
cta_loading: "Đang tham gia dự án",
},
invalid_project: {
title: "Không tìm thấy dự án",
description: "Dự án bạn đang tìm kiếm không tồn tại.",
},
work_items: {
title: "Bắt đầu với mục công việc đầu tiên của bạn.",
description:
@@ -74,7 +74,7 @@ export default {
},
unique_code: {
label: "Mã duy nhất",
placeholder: "123456",
placeholder: "gets-sets-flys",
paste_code: "Dán mã xác minh đã gửi đến email của bạn",
requesting_new_code: "Đang yêu cầu mã mới",
sending_code: "Đang gửi mã",
@@ -1553,7 +1553,7 @@ export default {
},
code: {
label: "Mã duy nhất",
placeholder: "123456",
placeholder: "gets-sets-flys",
helper_text: "Mã xác minh đã được gửi tới email mới của bạn.",
errors: {
required: "Mã duy nhất là bắt buộc",
@@ -24,17 +24,6 @@ export default {
},
},
project_empty_state: {
no_access: {
title: "您似乎无权访问该项目",
restricted_description: "请联系管理员申请访问权限,通过后您可以在此继续。",
join_description: "点击下方按钮加入该项目。",
cta_primary: "加入项目",
cta_loading: "正在加入项目",
},
invalid_project: {
title: "未找到项目",
description: "您查找的项目不存在。",
},
work_items: {
title: "从您的第一个工作项开始。",
description: "工作项是项目的构建块 — 分配负责人、设置优先级并轻松跟踪进度。",

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