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 | 🟡 Minor

Redundant order_by call on line 103.

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"). The first order_by has no effect.

If the user-supplied order_by parameter should be ignored in favor of the fixed ordering, consider removing line 103 to avoid confusion. If the intent was to allow user-controlled ordering, the logic needs restructuring.

Proposed fix to remove redundant order_by
             .prefetch_related("projects")
             .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")
📝 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
.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")
.prefetch_related("labels")
.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 first
.order_by(self.request.GET.get("order_by", "-created_at")) is redundant because
it gets overridden by the subsequent .order_by("-is_favorite", "-sort_order",
"-created_at"); remove the earlier .order_by(...) call (the one using
self.request.GET.get) from the queryset building that also calls
.prefetch_related("labels") and .order_by("-is_favorite", "-sort_order",
"-created_at"), or if user-controlled ordering is required, refactor the logic
so the user value is applied after/combined with the fixed ordering instead of
before it.

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

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Repo references =="
rg -n 'pythonjsonlogger\.(json|jsonlogger)\.JsonFormatter|from pythonjsonlogger\.(json|jsonlogger) import JsonFormatter' apps/api

echo
echo "== Pinned version =="
rg -n '^python-json-logger==' apps/api/requirements/base.txt

echo
echo "== Import check against pinned package =="
python -m pip install --quiet --disable-pip-version-check python-json-logger==3.3.0
python - <<'PY'
import importlib

for mod in ("pythonjsonlogger.json", "pythonjsonlogger.jsonlogger"):
    try:
        importlib.import_module(mod)
        print(f"OK   {mod}")
    except Exception as exc:
        print(f"FAIL {mod}: {type(exc).__name__}: {exc}")
PY

Repository: makeplane/plane

Length of output: 513


Complete the python-json-logger import path migration for consistency.

This file imports via the new pythonjsonlogger.json path, but apps/api/plane/settings/production.py:37 and apps/api/plane/settings/local.py:49 still use pythonjsonlogger.jsonlogger. Both paths are available in the pinned version (v3.3.0), but the codebase should use the same path consistently. Update the settings to match.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/celery.py` at line 11, The codebase mixes import paths for
python-json-logger; celery.py uses "from pythonjsonlogger.json import
JsonFormatter" while the settings modules still import from
"pythonjsonlogger.jsonlogger". Update the import statements in the settings
modules (where JsonFormatter is imported) to use "pythonjsonlogger.json" to
match celery.py (i.e., replace any "pythonjsonlogger.jsonlogger" imports with
"pythonjsonlogger.json" and keep the same symbol names such as JsonFormatter).

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"
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, "")}/`;
};

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
115 changes: 96 additions & 19 deletions apps/web/core/components/pages/list/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
* See the LICENSE file for details.
*/

import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import type { InstructionType } from "@plane/types";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { PageIcon } from "@plane/propel/icons";
import { DragHandle, DropIndicator } from "@plane/ui";
// plane imports
import { getPageName } from "@plane/utils";
// components
Expand All @@ -22,12 +27,19 @@ import { usePage } from "@/plane-web/hooks/store";
type TPageListBlock = {
pageId: string;
storeType: EPageStoreType;
isLastChild: boolean;
isReorderEnabled: boolean;
onPageDrop: (sourcePageId: string, destinationPageId: string, edge: "reorder-above" | "reorder-below") => void;
};

export const PageListBlock = observer(function PageListBlock(props: TPageListBlock) {
const { pageId, storeType } = props;
const { pageId, storeType, isLastChild, isReorderEnabled, onPageDrop } = props;
// refs
const parentRef = useRef(null);
const parentRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLSpanElement>(null);
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// hooks
const page = usePage({
pageId,
Expand All @@ -39,22 +51,87 @@ export const PageListBlock = observer(function PageListBlock(props: TPageListBlo
// derived values
const { name, logo_props, getRedirectionLink } = page;

useEffect(() => {
const rowElement = parentRef.current;
if (!rowElement || !isReorderEnabled) return;

const initialData = { id: pageId, dragInstanceId: "PROJECT_PAGES" };
return combine(
draggable({
element: rowElement,
dragHandle: dragHandleRef.current ?? undefined,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element: rowElement,
canDrop: ({ source }) => source?.data?.id !== pageId && source?.data?.dragInstanceId === "PROJECT_PAGES",
getData: ({ input, element: dropElement }) => {
const blockedStates: InstructionType[] = ["make-child"];
if (!isLastChild) blockedStates.push("reorder-below");
return attachInstruction(initialData, {
input,
element: dropElement,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ self }) => {
const currentInstruction = extractInstruction(self?.data)?.type;
setInstruction(
currentInstruction === "reorder-above" || currentInstruction === "reorder-below"
? currentInstruction
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const currentInstruction = extractInstruction(self?.data)?.type;
if (currentInstruction !== "reorder-above" && currentInstruction !== "reorder-below") return;
const sourcePageId = source?.data?.id as string | undefined;
if (!sourcePageId) return;
onPageDrop(sourcePageId, pageId, currentInstruction);
},
})
);
}, [isReorderEnabled, isLastChild, onPageDrop, pageId]);

return (
<ListItem
prependTitleElement={
<>
{logo_props?.in_use ? (
<Logo logo={logo_props} size={16} type="lucide" />
) : (
<PageIcon className="h-4 w-4 text-tertiary" />
)}
</>
}
title={getPageName(name)}
itemLink={getRedirectionLink()}
actionableItems={<BlockItemAction page={page} parentRef={parentRef} storeType={storeType} />}
isMobile={isMobile}
parentRef={parentRef}
/>
<div>
<DropIndicator isVisible={instruction === "reorder-above"} />
<ListItem
prependTitleElement={
<>
{isReorderEnabled && (
<span ref={dragHandleRef} className="flex cursor-grab items-center text-placeholder">
<DragHandle className="bg-transparent" />
</span>
Comment on lines +115 to +118
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

Add a keyboard-accessible reorder path.

The new handle is a non-focusable <span> and the feature only exposes pointer drag-and-drop, so keyboard users currently have no way to reorder pages. Please provide a focusable control with keyboard semantics or alternate move-up/move-down actions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/core/components/pages/list/block.tsx` around lines 115 - 118, The
drag handle (rendered when isReorderEnabled) currently uses a non-focusable
<span> (dragHandleRef) and only supports pointer drag via the DragHandle icon;
add keyboard accessibility by replacing or augmenting that span with a focusable
control (e.g., a button or div with tabIndex=0 and role="button") that uses the
same dragHandleRef and exposes keyboard handlers for reordering
(ArrowUp/ArrowDown, Enter/Space to lift/drop) or provide separate
move-up/move-down buttons that call the same reordering logic; ensure the
control has an accessible label (aria-label or visually hidden text) and
preserves existing pointer drag behavior and styling in block.tsx.

)}
{logo_props?.in_use ? (
<Logo logo={logo_props} size={16} type="lucide" />
) : (
<PageIcon className="h-4 w-4 text-tertiary" />
)}
</>
}
title={getPageName(name)}
itemLink={getRedirectionLink()}
actionableItems={<BlockItemAction page={page} parentRef={parentRef} storeType={storeType} />}
isMobile={isMobile}
parentRef={parentRef}
className={isDragging ? "cursor-grabbing bg-layer-1" : ""}
/>
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
);
});
Loading