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
2 changes: 1 addition & 1 deletion apps/api/plane/api/views/sticky.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def create(self, request, slug):
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
stickies = self.get_queryset().order_by("-sort_order", "-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)

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

Duplicate order_by() renders query parameter ineffective.

Line 103 applies order_by(self.request.GET.get("order_by", "-created_at")), but Line 105 immediately overrides it with order_by("-is_favorite", "-sort_order", "-created_at"). In Django, only the last order_by() call takes effect.

If the intention is to always sort by favorites, sort_order, and created_at, remove Line 103. If the query parameter should take precedence, remove Line 105.

🔧 Proposed fix (remove the unused order_by)
             .prefetch_related("labels")
-            .order_by(self.request.GET.get("order_by", "-created_at"))
             .order_by("-is_favorite", "-sort_order", "-created_at")
🤖 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 duplicate
ordering is overriding the query param: remove one of the order_by calls so
ordering behaves as intended — either delete the first
order_by(self.request.GET.get("order_by", \"-created_at\")) if you always want
to enforce .order_by("-is_favorite", "-sort_order", "-created_at"), or delete
the later .order_by("-is_favorite", "-sort_order", "-created_at") if the request
GET order_by should take precedence; update the queryset chain that uses
.prefetch_related("labels") and .order_by(...) accordingly so only a single
.order_by(...) remains.

.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.

Import path for JsonFormatter was changed to pythonjsonlogger.json, but the repo still references pythonjsonlogger.jsonlogger.JsonFormatter in Django LOGGING settings. This inconsistency is likely to cause an ImportError at runtime depending on the installed python-json-logger layout; align the import with the configured formatter module (or update all references consistently).

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"
)
43 changes: 43 additions & 0 deletions apps/api/plane/tests/contract/api/test_sticky.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import pytest
from plane.db.models import Sticky, Workspace


@pytest.mark.django_db
def test_default_sort_order_assignment(workspace, session_client):
# Create the first sticky
s1 = Sticky.objects.create(
workspace=workspace,
owner=session_client.user,
name="Sticky 1",
)
assert s1.sort_order == 65535

# Create the second sticky
s2 = Sticky.objects.create(
workspace=workspace,
owner=session_client.user,
name="Sticky 2",
)
# According to the model logic, sort_order increments by +10000
assert s2.sort_order == 65535 + 10000


@pytest.mark.django_db
def test_api_returns_ordered_list(workspace, session_client):
Sticky.objects.create(
workspace=workspace,
owner=session_client.user,
name="Last",
sort_order=200,
)
Sticky.objects.create(
workspace=workspace,
owner=session_client.user,
name="First",
sort_order=100,
)

res = session_client.get(f"/api/workspaces/{workspace.slug}/stickies/")
results = res.data
assert results[0]["name"] == "First"
assert results[1]["name"] == "Last"
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"
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 +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 in this PR (admin + space, vite + react-router). Consider extracting a shared helper (or reusing the existing joinUrlPath util if it meets the requirements) to avoid future drift between apps/configs.

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
Loading