Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/admin/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Config } from "@react-router/dev/config";
import { joinUrlPath } from "@plane/utils";

const basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
};

const basePath = normalizeBasePath(process.env.VITE_ADMIN_BASE_PATH ?? "");

export default {
appDirectory: "app",
Expand Down
9 changes: 7 additions & 2 deletions apps/admin/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";

const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
};

dotenv.config({ path: path.resolve(__dirname, ".env") });

Expand All @@ -15,7 +20,7 @@ const viteEnv = Object.keys(process.env)
return a;
}, {});

const basePath = joinUrlPath(process.env.VITE_ADMIN_BASE_PATH ?? "", "/") ?? "/";
const basePath = normalizeBasePath(process.env.VITE_ADMIN_BASE_PATH ?? "");

export default defineConfig(() => ({
base: basePath,
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/app/serializers/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Meta:
"logo_props",
"label_ids",
"project_ids",
"sort_order",
]
read_only_fields = ["workspace", "owned_by"]

Expand Down
7 changes: 5 additions & 2 deletions apps/api/plane/app/views/page/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def get_queryset(self):
.annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at"))
.prefetch_related("labels")
.order_by("-is_favorite", "-created_at")
.order_by("-is_favorite", "-sort_order", "-created_at")
Comment on lines 103 to +105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve explicit order_by requests here.

Line 103 already applies the caller's ?order_by=..., but Line 105 replaces it unconditionally, so every list request now falls back to -is_favorite, -sort_order, -created_at. If sort_order is only meant to be the default, gate this second ordering behind the absence of order_by instead of clobbering it.

Suggested fix
+        requested_order = self.request.GET.get("order_by")
+
         return self.filter_queryset(
             super()
             .get_queryset()
             .filter(workspace__slug=self.kwargs.get("slug"))
@@
             .select_related("workspace")
             .select_related("owned_by")
             .annotate(is_favorite=Exists(subquery))
-            .order_by(self.request.GET.get("order_by", "-created_at"))
             .prefetch_related("labels")
-            .order_by("-is_favorite", "-sort_order", "-created_at")
+            .order_by(
+                *(
+                    ["-is_favorite", requested_order]
+                    if requested_order
+                    else ["-is_favorite", "-sort_order", "-created_at"]
+                )
+            )
             .annotate(
                 project=Exists(
                     ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id"))
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/app/views/page/base.py` around lines 103 - 105, The current
queryset first applies the caller's ?order_by then unconditionally calls
.order_by("-is_favorite", "-sort_order", "-created_at"), which overwrites the
explicit ordering; change the logic in the view (around the queryset building
that calls self.request.GET.get("order_by", "-created_at") and the subsequent
.order_by("-is_favorite", "-sort_order", "-created_at")) so the latter default
ordering is only applied when no order_by parameter was provided (e.g., check if
self.request.GET.get("order_by") is falsy and only then call
.order_by("-is_favorite", "-sort_order", "-created_at")).

.annotate(
project=Exists(
ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id"))
Expand Down Expand Up @@ -160,7 +160,10 @@ def partial_update(self, request, slug, project_id, page_id):
project_pages__deleted_at__isnull=True,
)

if page.is_locked:
updatable_fields_when_locked = {"sort_order"}
is_sort_only_update = set(request.data.keys()).issubset(updatable_fields_when_locked)

if page.is_locked and not is_sort_only_update:
return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST)

parent = request.data.get("parent", None)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/plane/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

# Third party imports
from celery import Celery
from pythonjsonlogger.jsonlogger import JsonFormatter
from pythonjsonlogger.json import JsonFormatter
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python-json-logger is pinned to python-json-logger==3.3.0 (apps/api/requirements/base.txt) and the rest of the codebase references pythonjsonlogger.jsonlogger.JsonFormatter in LOGGING config. Importing JsonFormatter from pythonjsonlogger.json here is inconsistent and is likely to raise ModuleNotFoundError at worker startup. Please switch this import back to from pythonjsonlogger.jsonlogger import JsonFormatter (or update dependencies/config consistently if the new module path is intentional).

Suggested change
from pythonjsonlogger.json import JsonFormatter
from pythonjsonlogger.jsonlogger import JsonFormatter

Copilot uses AI. Check for mistakes.
from celery.signals import after_setup_logger, after_setup_task_logger
from celery.schedules import crontab

Expand Down
1 change: 0 additions & 1 deletion apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,6 @@
# Internationalization
LANGUAGE_CODE = "en-us"
USE_I18N = True
USE_L10N = True

# Timezones
USE_TZ = True
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
# Send it in a dummy outbox
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

# WhiteNoise checks STATIC_ROOT on middleware init; avoid static-dir warnings in tests.
MIDDLEWARE = [mw for mw in MIDDLEWARE if mw != "whitenoise.middleware.WhiteNoiseMiddleware"] # noqa

INSTALLED_APPS.append( # noqa
"plane.tests"
)
122 changes: 122 additions & 0 deletions apps/api/plane/tests/contract/app/test_pages_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

import pytest
from rest_framework import status

from plane.db.models import Page, Project, ProjectMember, ProjectPage


@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Pages Project",
identifier="PGS",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
project=project,
member=create_user,
role=20,
is_active=True,
)
return project


@pytest.fixture
def project_pages(db, workspace, project, create_user):
page_low = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="Low",
access=Page.PUBLIC_ACCESS,
sort_order=100,
)
page_mid = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="Mid",
access=Page.PUBLIC_ACCESS,
sort_order=150,
)
page_high = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="High",
access=Page.PUBLIC_ACCESS,
sort_order=200,
)

for page in [page_low, page_mid, page_high]:
ProjectPage.objects.create(
project=project,
page=page,
workspace=workspace,
created_by=create_user,
)

return {"low": page_low, "mid": page_mid, "high": page_high}


@pytest.mark.contract
class TestProjectPagesAPI:
def get_pages_url(self, workspace_slug: str, project_id: str) -> str:
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/pages/"

def get_page_url(self, workspace_slug: str, project_id: str, page_id: str) -> str:
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/"

@pytest.mark.django_db
def test_list_pages_sorted_by_sort_order_desc(self, session_client, workspace, project, project_pages):
url = self.get_pages_url(workspace.slug, project.id)
response = session_client.get(url)

assert response.status_code == status.HTTP_200_OK
data = response.json()
assert [row["id"] for row in data] == [
str(project_pages["high"].id),
str(project_pages["mid"].id),
str(project_pages["low"].id),
]
assert all("sort_order" in row for row in data)

@pytest.mark.django_db
def test_patch_sort_order_updates_page(self, session_client, workspace, project, project_pages):
page = project_pages["mid"]
url = self.get_page_url(workspace.slug, project.id, page.id)
response = session_client.patch(url, {"sort_order": 250}, format="json")

assert response.status_code == status.HTTP_200_OK
page.refresh_from_db()
assert page.sort_order == 250
assert response.json()["sort_order"] == 250

@pytest.mark.django_db
def test_patch_sort_order_allowed_when_page_is_locked(self, session_client, workspace, project, project_pages):
page = project_pages["low"]
page.is_locked = True
page.save()

url = self.get_page_url(workspace.slug, project.id, page.id)
response = session_client.patch(url, {"sort_order": 175}, format="json")

assert response.status_code == status.HTTP_200_OK
page.refresh_from_db()
assert page.sort_order == 175

@pytest.mark.django_db
def test_patch_non_sort_field_blocked_when_page_is_locked(self, session_client, workspace, project, project_pages):
page = project_pages["high"]
page.is_locked = True
page.save()

url = self.get_page_url(workspace.slug, project.id, page.id)
response = session_client.patch(url, {"name": "Renamed while locked"}, format="json")

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["error"] == "Page is locked"

page.refresh_from_db()
assert page.name == "High"
123 changes: 123 additions & 0 deletions apps/api/plane/tests/contract/app/test_quick_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest.mock import patch

import pytest
from rest_framework import status

from plane.db.models import WorkspaceUserLink


def _quick_links_list_url(slug: str) -> str:
return f"/api/workspaces/{slug}/quick-links/"


def _quick_links_detail_url(slug: str, pk: str) -> str:
return f"/api/workspaces/{slug}/quick-links/{pk}/"


@pytest.mark.contract
class TestQuickLinksAPI:
"""Test workspace quick links (home widget) CRUD operations."""

@pytest.mark.django_db
def test_list_empty(self, session_client, workspace):
"""List quick links when none exist returns empty list."""
url = _quick_links_list_url(workspace.slug)
response = session_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data == []

@pytest.mark.django_db
def test_create_quick_link_success(self, session_client, workspace, create_user):
"""Create a quick link with valid url and optional title."""
url = _quick_links_list_url(workspace.slug)
payload = {"url": "https://example.com", "title": "Example"}
response = session_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["url"] == payload["url"]
assert response.data["title"] == payload["title"]
assert "id" in response.data
assert WorkspaceUserLink.objects.filter(workspace=workspace, owner=create_user).count() == 1

@pytest.mark.django_db
def test_create_quick_link_url_only(self, session_client, workspace, create_user):
"""Create a quick link with only url (title optional)."""
url = _quick_links_list_url(workspace.slug)
payload = {"url": "https://other.com"}
response = session_client.post(url, payload, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["url"] == payload["url"]
assert WorkspaceUserLink.objects.filter(workspace=workspace, owner=create_user).count() == 1

@pytest.mark.django_db
def test_create_quick_link_invalid_url(self, session_client, workspace):
"""Create with invalid url returns 400."""
url = _quick_links_list_url(workspace.slug)
response = session_client.post(url, {"url": "not-a-url"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST

@pytest.mark.django_db
def test_create_quick_link_empty_data(self, session_client, workspace):
"""Create with empty or missing url returns 400."""
url = _quick_links_list_url(workspace.slug)
response = session_client.post(url, {}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST

@pytest.mark.django_db
def test_retrieve_quick_link(self, session_client, workspace, create_user):
"""Retrieve a single quick link by id."""
create_url = _quick_links_list_url(workspace.slug)
create_resp = session_client.post(
create_url, {"url": "https://get.com", "title": "Get"}, format="json"
)
assert create_resp.status_code == status.HTTP_201_CREATED
link_id = create_resp.data["id"]
detail_url = _quick_links_detail_url(workspace.slug, link_id)
response = session_client.get(detail_url)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == link_id
assert response.data["url"] == "https://get.com"
assert response.data["title"] == "Get"

@pytest.mark.django_db
def test_partial_update_quick_link(self, session_client, workspace, create_user):
"""Update a quick link (partial update)."""
create_url = _quick_links_list_url(workspace.slug)
create_resp = session_client.post(
create_url, {"url": "https://update.com", "title": "Old"}, format="json"
)
assert create_resp.status_code == status.HTTP_201_CREATED
link_id = create_resp.data["id"]
detail_url = _quick_links_detail_url(workspace.slug, link_id)
response = session_client.patch(detail_url, {"title": "New"}, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "New"
assert response.data["url"] == "https://update.com"

@pytest.mark.django_db
@patch("plane.db.mixins.soft_delete_related_objects.delay")
def test_destroy_quick_link(self, mock_soft_delete_delay, session_client, workspace, create_user):
"""Delete a quick link returns 204 and removes the link."""
create_url = _quick_links_list_url(workspace.slug)
create_resp = session_client.post(
create_url, {"url": "https://delete.com"}, format="json"
)
assert create_resp.status_code == status.HTTP_201_CREATED
link_id = create_resp.data["id"]
detail_url = _quick_links_detail_url(workspace.slug, link_id)
response = session_client.delete(detail_url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not WorkspaceUserLink.objects.filter(id=link_id).exists()

@pytest.mark.django_db
def test_retrieve_nonexistent_returns_404(self, session_client, workspace):
"""Retrieve with invalid uuid returns 404."""
import uuid

fake_id = uuid.uuid4()
detail_url = _quick_links_detail_url(workspace.slug, str(fake_id))
response = session_client.get(detail_url)
assert response.status_code == status.HTTP_404_NOT_FOUND
9 changes: 7 additions & 2 deletions apps/space/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { Config } from "@react-router/dev/config";
import { joinUrlPath } from "@plane/utils";

const basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/";
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
};

const basePath = normalizeBasePath(process.env.VITE_SPACE_BASE_PATH ?? "");

export default {
appDirectory: "app",
Expand Down
9 changes: 7 additions & 2 deletions apps/space/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";

const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
Comment on lines +7 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Collapse repeated / characters inside the base path.

This only strips slashes at the ends, so VITE_SPACE_BASE_PATH="space//preview" normalizes to /space//preview/. That can bleed into generated asset URLs, and the same helper is copied into the admin/router configs too.

Suggested fix
 const normalizeBasePath = (value: string): string => {
   const trimmed = value.trim();
   if (!trimmed || trimmed === "/") return "/";
-  return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
+  const normalized = trimmed.replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
+  return normalized ? `/${normalized}/` : "/";
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
const normalized = trimmed.replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
return normalized ? `/${normalized}/` : "/";
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/space/vite.config.ts` around lines 7 - 10, normalizeBasePath currently
only trims leading/trailing slashes so inputs like "space//preview" become
"/space//preview/"; update the normalizeBasePath function to also collapse
repeated slashes inside the path by replacing consecutive "/" groups with a
single "/" (e.g., use a replace like value.replace(/\/{2,}/g, "/")) after
trimming and before adding the leading/trailing slash, and keep the function
signature and return behavior the same.

};
Comment on lines +7 to +11
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizeBasePath is duplicated across multiple config files (admin + space, vite + react-router). This increases the chance of the base-path normalization logic drifting over time. Consider extracting it into a small shared helper (e.g., in @plane/utils or a local config-utils module) and importing it from each config file.

Copilot uses AI. Check for mistakes.

dotenv.config({ path: path.resolve(__dirname, ".env") });

Expand All @@ -15,7 +20,7 @@ const viteEnv = Object.keys(process.env)
return a;
}, {});

const basePath = joinUrlPath(process.env.VITE_SPACE_BASE_PATH ?? "", "/") ?? "/";
const basePath = normalizeBasePath(process.env.VITE_SPACE_BASE_PATH ?? "");

export default defineConfig(() => ({
base: basePath,
Expand Down
9 changes: 9 additions & 0 deletions apps/web/core/components/home/widgets/links/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

export const QUICK_LINKS_OPEN_IN_SAME_TAB_KEY = "quick_links_open_in_same_tab";

export const DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB = false;
Loading
Loading