From bdc77e8a38414d054f5887224764c704489e4607 Mon Sep 17 00:00:00 2001 From: yevin215 <66778009+yevin215@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:57:51 -0800 Subject: [PATCH 1/3] Bug fix: Start up with pnpm error fixed --- apps/admin/react-router.config.ts | 9 +++++++-- apps/admin/vite.config.ts | 9 +++++++-- apps/space/react-router.config.ts | 9 +++++++-- apps/space/vite.config.ts | 9 +++++++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/admin/react-router.config.ts b/apps/admin/react-router.config.ts index a4cef08832d..a2fbe61d82c 100644 --- a/apps/admin/react-router.config.ts +++ b/apps/admin/react-router.config.ts @@ -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", diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index c9d97157f41..b5bccf75c85 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -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") }); @@ -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, diff --git a/apps/space/react-router.config.ts b/apps/space/react-router.config.ts index 78a17439887..cb6db0d17a9 100644 --- a/apps/space/react-router.config.ts +++ b/apps/space/react-router.config.ts @@ -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", diff --git a/apps/space/vite.config.ts b/apps/space/vite.config.ts index 5368af0761c..0ef4a333b37 100644 --- a/apps/space/vite.config.ts +++ b/apps/space/vite.config.ts @@ -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") }); @@ -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, From 8cbf84c3db00a46ce81c676fe94487c99ad585fe Mon Sep 17 00:00:00 2001 From: yevin215 <66778009+yevin215@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:49:06 -0800 Subject: [PATCH 2/3] Feature: Added ability to order pages --- apps/api/plane/app/serializers/page.py | 1 + apps/api/plane/app/views/page/base.py | 7 +- apps/web/core/components/pages/list/block.tsx | 115 +++++++++++++++--- .../core/components/pages/list/order-by.tsx | 5 +- apps/web/core/components/pages/list/root.tsx | 50 +++++++- apps/web/core/store/pages/base-page.ts | 4 + .../core/store/pages/project-page.store.ts | 57 ++++++++- packages/types/src/page/core.ts | 3 +- packages/utils/src/page.ts | 10 +- 9 files changed, 220 insertions(+), 32 deletions(-) diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index a9251129c32..2ca00d0bb73 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -55,6 +55,7 @@ class Meta: "logo_props", "label_ids", "project_ids", + "sort_order", ] read_only_fields = ["workspace", "owned_by"] diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index ec391afc1aa..d9c67c66031 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -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") .annotate( project=Exists( ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) @@ -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) diff --git a/apps/web/core/components/pages/list/block.tsx b/apps/web/core/components/pages/list/block.tsx index 5d658a42e22..12eee44ae4f 100644 --- a/apps/web/core/components/pages/list/block.tsx +++ b/apps/web/core/components/pages/list/block.tsx @@ -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(null); + const dragHandleRef = useRef(null); + // states + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(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 ( - - {logo_props?.in_use ? ( - - ) : ( - - )} - - } - title={getPageName(name)} - itemLink={getRedirectionLink()} - actionableItems={} - isMobile={isMobile} - parentRef={parentRef} - /> +
+ + + {isReorderEnabled && ( + + + + )} + {logo_props?.in_use ? ( + + ) : ( + + )} + + } + title={getPageName(name)} + itemLink={getRedirectionLink()} + actionableItems={} + isMobile={isMobile} + parentRef={parentRef} + className={isDragging ? "cursor-grabbing bg-layer-1" : ""} + /> + {isLastChild && } +
); }); diff --git a/apps/web/core/components/pages/list/order-by.tsx b/apps/web/core/components/pages/list/order-by.tsx index 60030c2e8fb..ec2fc19da08 100644 --- a/apps/web/core/components/pages/list/order-by.tsx +++ b/apps/web/core/components/pages/list/order-by.tsx @@ -4,11 +4,11 @@ * See the LICENSE file for details. */ -import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check } from "lucide-react"; +import { ArrowDownWideNarrow, ArrowUpWideNarrow } from "lucide-react"; // plane imports import { getButtonStyling } from "@plane/propel/button"; // types -import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons"; +import { CheckIcon } from "@plane/propel/icons"; import type { TPageFiltersSortBy, TPageFiltersSortKey } from "@plane/types"; import { CustomMenu } from "@plane/ui"; @@ -22,6 +22,7 @@ const PAGE_SORTING_KEY_OPTIONS: { key: TPageFiltersSortKey; label: string; }[] = [ + { key: "sort_order", label: "Manual order" }, { key: "name", label: "Name" }, { key: "created_at", label: "Date created" }, { key: "updated_at", label: "Date modified" }, diff --git a/apps/web/core/components/pages/list/root.tsx b/apps/web/core/components/pages/list/root.tsx index 3764e5bc19f..d83c3fcefac 100644 --- a/apps/web/core/components/pages/list/root.tsx +++ b/apps/web/core/components/pages/list/root.tsx @@ -5,6 +5,10 @@ */ import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { calculateTotalFilters } from "@plane/utils"; // types import type { TPageNavigationTabs } from "@plane/types"; // components @@ -22,16 +26,56 @@ type TPagesListRoot = { export const PagesListRoot = observer(function PagesListRoot(props: TPagesListRoot) { const { pageType, storeType } = props; + const { t } = useTranslation(); + const { workspaceSlug, projectId } = useParams(); // store hooks - const { getCurrentProjectFilteredPageIdsByTab } = usePageStore(storeType); + const { filters, canCurrentUserCreatePage, getCurrentProjectFilteredPageIdsByTab, reorderPage } = + usePageStore(storeType); // derived values const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType); + const canReorderPages = + pageType !== "archived" && + filters.sortKey === "sort_order" && + filters.searchQuery.trim().length === 0 && + calculateTotalFilters(filters.filters ?? {}) === 0 && + canCurrentUserCreatePage; + + const handlePageDrop = async ( + sourcePageId: string, + destinationPageId: string, + edge: "reorder-above" | "reorder-below" + ) => { + if (!workspaceSlug || !projectId || sourcePageId === destinationPageId || !filteredPageIds?.length) return; + try { + await reorderPage( + workspaceSlug.toString(), + projectId.toString(), + sourcePageId, + destinationPageId, + edge, + filteredPageIds + ); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: "Failed to reorder page. Please try again.", + }); + } + }; if (!filteredPageIds) return <>; return ( - {filteredPageIds.map((pageId) => ( - + {filteredPageIds.map((pageId, index) => ( + 1} + onPageDrop={handlePageDrop} + /> ))} ); diff --git a/apps/web/core/store/pages/base-page.ts b/apps/web/core/store/pages/base-page.ts index 1cde3950bf5..3ab57de2c8b 100644 --- a/apps/web/core/store/pages/base-page.ts +++ b/apps/web/core/store/pages/base-page.ts @@ -97,6 +97,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { archived_at: string | null | undefined; workspace: string | undefined; project_ids?: string[] | undefined; + sort_order: number | undefined; created_by: string | undefined; updated_by: string | undefined; created_at: Date | undefined; @@ -134,6 +135,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { this.archived_at = page?.archived_at || undefined; this.workspace = page?.workspace || undefined; this.project_ids = page?.project_ids || undefined; + this.sort_order = page?.sort_order ?? undefined; this.created_by = page?.created_by || undefined; this.updated_by = page?.updated_by || undefined; this.created_at = page?.created_at || undefined; @@ -159,6 +161,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { archived_at: observable.ref, workspace: observable.ref, project_ids: observable, + sort_order: observable.ref, created_by: observable.ref, updated_by: observable.ref, created_at: observable.ref, @@ -235,6 +238,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { archived_at: this.archived_at, workspace: this.workspace, project_ids: this.project_ids, + sort_order: this.sort_order, created_by: this.created_by, updated_by: this.updated_by, created_at: this.created_at, diff --git a/apps/web/core/store/pages/project-page.store.ts b/apps/web/core/store/pages/project-page.store.ts index c84dad1b5d9..3ffc3d27885 100644 --- a/apps/web/core/store/pages/project-page.store.ts +++ b/apps/web/core/store/pages/project-page.store.ts @@ -63,6 +63,14 @@ export interface IProjectPageStore { options?: { trackVisit?: boolean } ) => Promise; createPage: (pageData: Partial) => Promise; + reorderPage: ( + workspaceSlug: string, + projectId: string, + pageId: string, + destinationPageId: string, + edge: "reorder-above" | "reorder-below", + orderedPageIds: string[] + ) => Promise; removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise; movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise; } @@ -74,7 +82,7 @@ export class ProjectPageStore implements IProjectPageStore { error: TError | undefined = undefined; filters: TPageFilters = { searchQuery: "", - sortKey: "updated_at", + sortKey: "sort_order", sortBy: "desc", }; // service @@ -98,6 +106,7 @@ export class ProjectPageStore implements IProjectPageStore { fetchPagesList: action, fetchPageDetails: action, createPage: action, + reorderPage: action, removePage: action, movePage: action, }); @@ -324,11 +333,55 @@ export class ProjectPageStore implements IProjectPageStore { } }; + reorderPage = async ( + workspaceSlug: string, + projectId: string, + pageId: string, + destinationPageId: string, + edge: "reorder-above" | "reorder-below", + orderedPageIds: string[] + ) => { + const page = this.getPageById(pageId); + const destinationPage = this.getPageById(destinationPageId); + if (!page || !destinationPage || orderedPageIds.length === 0) return; + + const pageSortOrderBeforeUpdate = page.sort_order; + let resultSequence = 10000; + + const destinationSequence = destinationPage.sort_order; + if (typeof destinationSequence === "number") { + const destinationIndex = orderedPageIds.findIndex((id) => id === destinationPageId); + if (destinationIndex >= 0) { + if (edge === "reorder-above") { + const prevPageId = orderedPageIds[destinationIndex - 1]; + const prevSequence = prevPageId ? this.getPageById(prevPageId)?.sort_order : undefined; + if (typeof prevSequence === "number") resultSequence = (destinationSequence + prevSequence) / 2; + else resultSequence = destinationSequence + resultSequence; + } else { + resultSequence = destinationSequence - resultSequence; + } + } + } + + runInAction(() => { + page.mutateProperties({ sort_order: resultSequence }, false); + }); + + try { + await this.service.update(workspaceSlug, projectId, pageId, { sort_order: resultSequence }); + } catch (error) { + runInAction(() => { + page.mutateProperties({ sort_order: pageSortOrderBeforeUpdate }, false); + }); + throw error; + } + }; + /** * @description delete a page * @param {string} pageId */ - removePage = async ({ pageId, shouldSync = true }: { pageId: string; shouldSync?: boolean }) => { + removePage = async ({ pageId, shouldSync: _shouldSync = true }: { pageId: string; shouldSync?: boolean }) => { try { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !pageId) return undefined; diff --git a/packages/types/src/page/core.ts b/packages/types/src/page/core.ts index 4677b29727a..80b7a9388fd 100644 --- a/packages/types/src/page/core.ts +++ b/packages/types/src/page/core.ts @@ -23,6 +23,7 @@ export type TPage = { name: string | undefined; owned_by: string | undefined; project_ids?: string[] | undefined; + sort_order: number | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; @@ -33,7 +34,7 @@ export type TPage = { // page filters export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at"; +export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at" | "sort_order"; export type TPageFiltersSortBy = "asc" | "desc"; diff --git a/packages/utils/src/page.ts b/packages/utils/src/page.ts index b566c5b6d3e..ea77629cd3b 100644 --- a/packages/utils/src/page.ts +++ b/packages/utils/src/page.ts @@ -48,15 +48,19 @@ export const orderPages = ( if (sortByKey === "name") { orderedPages = sortBy(pages, [(m) => m.name?.toLowerCase()]); - if (sortByOrder === "desc") orderedPages = orderedPages.reverse(); + if (sortByOrder === "desc") orderedPages = orderedPages.toReversed(); } if (sortByKey === "created_at") { orderedPages = sortBy(pages, [(m) => m.created_at]); - if (sortByOrder === "desc") orderedPages = orderedPages.reverse(); + if (sortByOrder === "desc") orderedPages = orderedPages.toReversed(); } if (sortByKey === "updated_at") { orderedPages = sortBy(pages, [(m) => m.updated_at]); - if (sortByOrder === "desc") orderedPages = orderedPages.reverse(); + if (sortByOrder === "desc") orderedPages = orderedPages.toReversed(); + } + if (sortByKey === "sort_order") { + orderedPages = sortBy(pages, [(m) => m.sort_order ?? 0]); + if (sortByOrder === "desc") orderedPages = orderedPages.toReversed(); } return orderedPages; From 035937749acc6712b999fdbcc7390ad43ed8b398 Mon Sep 17 00:00:00 2001 From: yevin215 <66778009+yevin215@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:00:11 -0800 Subject: [PATCH 3/3] Tests: Implemented tests for page reordering --- apps/api/plane/celery.py | 2 +- apps/api/plane/settings/common.py | 1 - apps/api/plane/settings/test.py | 3 + .../tests/contract/app/test_pages_app.py | 122 ++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_pages_app.py diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 562d04856f5..c2455a9a51c 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -8,7 +8,7 @@ # Third party imports from celery import Celery -from pythonjsonlogger.jsonlogger import JsonFormatter +from pythonjsonlogger.json import JsonFormatter from celery.signals import after_setup_logger, after_setup_task_logger from celery.schedules import crontab diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 9d651bd1b4c..5f48bbada3c 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -224,7 +224,6 @@ # Internationalization LANGUAGE_CODE = "en-us" USE_I18N = True -USE_L10N = True # Timezones USE_TZ = True diff --git a/apps/api/plane/settings/test.py b/apps/api/plane/settings/test.py index a8e431338b7..8cee6f72e8a 100644 --- a/apps/api/plane/settings/test.py +++ b/apps/api/plane/settings/test.py @@ -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" ) diff --git a/apps/api/plane/tests/contract/app/test_pages_app.py b/apps/api/plane/tests/contract/app/test_pages_app.py new file mode 100644 index 00000000000..9089fa81cdd --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_pages_app.py @@ -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"