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/5] 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/5] 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/5] 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" From 43ee5183a8cb6ee971fe230e81d1b30d4fb1b039 Mon Sep 17 00:00:00 2001 From: Ivan Quintero Date: Sun, 8 Mar 2026 17:13:55 -0700 Subject: [PATCH 4/5] Fix #8621: Option to open quick links in same page. Added the functionality of opening quicklinks in same page. Can toggle the preference (same page or new tab) with a button click and can also choose individually by link. --- .../home/widgets/links/constants.ts | 9 ++++ .../home/widgets/links/link-detail.tsx | 34 ++++++++++++- .../components/home/widgets/links/root.tsx | 48 ++++++++++++++++--- packages/i18n/src/locales/en/translations.ts | 3 ++ 4 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 apps/web/core/components/home/widgets/links/constants.ts diff --git a/apps/web/core/components/home/widgets/links/constants.ts b/apps/web/core/components/home/widgets/links/constants.ts new file mode 100644 index 00000000000..fc3299b5a5a --- /dev/null +++ b/apps/web/core/components/home/widgets/links/constants.ts @@ -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; diff --git a/apps/web/core/components/home/widgets/links/link-detail.tsx b/apps/web/core/components/home/widgets/links/link-detail.tsx index e2a7bc72743..18904aed657 100644 --- a/apps/web/core/components/home/widgets/links/link-detail.tsx +++ b/apps/web/core/components/home/widgets/links/link-detail.tsx @@ -15,8 +15,10 @@ import { LinkItemBlock } from "@plane/ui"; // plane utils import { copyTextToClipboard } from "@plane/utils"; // hooks +import useLocalStorage from "@/hooks/use-local-storage"; import { useHome } from "@/hooks/store/use-home"; // types +import { DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB, QUICK_LINKS_OPEN_IN_SAME_TAB_KEY } from "./constants"; import type { TLinkOperations } from "./use-links"; export type TProjectLinkDetail = { @@ -32,6 +34,10 @@ export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TPro quickLinks: { getLinkById, toggleLinkModal, setLinkData }, } = useHome(); const { t } = useTranslation(); + const { storedValue: openInSameTab } = useLocalStorage( + QUICK_LINKS_OPEN_IN_SAME_TAB_KEY, + DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB + ); // derived values const linkDetail = getLinkById(linkId); const linkUrl = linkDetail?.url; @@ -53,13 +59,31 @@ export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TPro title: t("link_copied"), message: t("view_link_copied_to_clipboard"), }); + return; }); }, [linkUrl, t]); const handleOpenInNewTab = useCallback(() => { + if (!linkUrl) return; window.open(linkUrl, "_blank", "noopener,noreferrer"); }, [linkUrl]); + const handleOpenInSameTab = useCallback(() => { + if (!linkUrl) return; + window.location.href = linkUrl; + }, [linkUrl]); + + const openInSameTabResolved = openInSameTab ?? DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB; + + const handlePrimaryClick = useCallback(() => { + if (!linkUrl) return; + if (openInSameTabResolved) { + window.location.href = linkUrl; + } else { + window.open(linkUrl, "_blank", "noopener,noreferrer"); + } + }, [linkUrl, openInSameTabResolved]); + const handleDelete = useCallback(() => { linkOperations.remove(linkId); }, [linkId, linkOperations]); @@ -79,6 +103,12 @@ export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TPro title: t("open_in_new_tab"), icon: NewTabIcon, }, + { + key: "open-same-tab", + action: handleOpenInSameTab, + title: t("home.quick_links.open_in_same_tab"), + icon: LinkIcon, + }, { key: "copy-link", action: handleCopyText, @@ -92,7 +122,7 @@ export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TPro icon: TrashIcon, }, ], - [handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t] + [handleEdit, handleOpenInNewTab, handleOpenInSameTab, handleCopyText, handleDelete, t] ); if (!linkDetail) return null; @@ -103,7 +133,7 @@ export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TPro url={linkDetail.url} createdAt={linkDetail.created_at} menuItems={menuItems} - onClick={handleOpenInNewTab} + onClick={handlePrimaryClick} /> ); }); diff --git a/apps/web/core/components/home/widgets/links/root.tsx b/apps/web/core/components/home/widgets/links/root.tsx index 3eb9b92941f..e91cfb1af68 100644 --- a/apps/web/core/components/home/widgets/links/root.tsx +++ b/apps/web/core/components/home/widgets/links/root.tsx @@ -9,10 +9,13 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; -import { PlusIcon } from "@plane/propel/icons"; +import { LinkIcon, NewTabIcon, PlusIcon } from "@plane/propel/icons"; +import { Tooltip } from "@plane/propel/tooltip"; import type { THomeWidgetProps } from "@plane/types"; import { useHome } from "@/hooks/store/use-home"; +import useLocalStorage from "@/hooks/use-local-storage"; import { LinkCreateUpdateModal } from "./create-update-link-modal"; +import { DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB, QUICK_LINKS_OPEN_IN_SAME_TAB_KEY } from "./constants"; import { ProjectLinkList } from "./links"; import { useLinks } from "./use-links"; @@ -23,12 +26,20 @@ export const DashboardQuickLinks = observer(function DashboardQuickLinks(props: quickLinks: { isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks }, } = useHome(); const { t } = useTranslation(); + const { storedValue: openInSameTab, setValue: setOpenInSameTab } = useLocalStorage( + QUICK_LINKS_OPEN_IN_SAME_TAB_KEY, + DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB + ); const handleCreateLinkModal = useCallback(() => { toggleLinkModal(true); setLinkData(undefined); }, [toggleLinkModal, setLinkData]); + const handleToggleOpenInSameTab = useCallback(() => { + setOpenInSameTab(!(openInSameTab ?? DEFAULT_QUICK_LINKS_OPEN_IN_SAME_TAB)); + }, [openInSameTab, setOpenInSameTab]); + useSWR( workspaceSlug ? `HOME_LINKS_${workspaceSlug}` : null, workspaceSlug ? () => fetchLinks(workspaceSlug.toString()) : null, @@ -49,12 +60,35 @@ export const DashboardQuickLinks = observer(function DashboardQuickLinks(props:
{t("home.quick_links.title_plural")}
- +
+ + + + +
{/* rendering links */} diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 9f3eff04d1a..5190ed2d2fd 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -439,6 +439,9 @@ export default { add: "Add quick Link", title: "Quicklink", title_plural: "Quicklinks", + open_in_same_tab: "Open in same tab", + open_links_in_new_tab: "Open links in new tab", + open_links_in_same_tab: "Open links in same tab", }, recents: { title: "Recents", From 1ba743b71d189c976b3d97500b873f34e90d2b90 Mon Sep 17 00:00:00 2001 From: Ivan Quintero Date: Sun, 8 Mar 2026 18:30:38 -0700 Subject: [PATCH 5/5] Adding the tests for quicklinks. --- .../tests/contract/app/test_quick_links.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/api/plane/tests/contract/app/test_quick_links.py diff --git a/apps/api/plane/tests/contract/app/test_quick_links.py b/apps/api/plane/tests/contract/app/test_quick_links.py new file mode 100644 index 00000000000..11d5a4b4248 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_quick_links.py @@ -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