-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Feature/reorder pages: Adds page ordering support as request of issue #4443 #8733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |
|
|
||
| # Third party imports | ||
| from celery import Celery | ||
| from pythonjsonlogger.jsonlogger import JsonFormatter | ||
| from pythonjsonlogger.json import JsonFormatter | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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}")
PYRepository: makeplane/plane Length of output: 513 Complete the This file imports via the new 🤖 Prompt for AI Agents |
||
| from celery.signals import after_setup_logger, after_setup_task_logger | ||
| from celery.schedules import crontab | ||
|
|
||
|
|
||
| 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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a keyboard-accessible reorder path. The new handle is a non-focusable 🤖 Prompt for AI Agents |
||
| )} | ||
| {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> | ||
| ); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant
order_bycall 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 firstorder_byhas no effect.If the user-supplied
order_byparameter 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
🤖 Prompt for AI Agents