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/api/plane/api/views/sticky.py b/apps/api/plane/api/views/sticky.py index f6b4298f668..1abb8c6a8fd 100644 --- a/apps/api/plane/api/views/sticky.py +++ b/apps/api/plane/api/views/sticky.py @@ -65,7 +65,7 @@ def create(self, request, slug): ) def list(self, request, slug): query = request.query_params.get("query", False) - stickies = self.get_queryset().order_by("-created_at") + stickies = self.get_queryset().order_by("-sort_order", "-created_at") if query: stickies = stickies.filter(description_stripped__icontains=query) 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/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/api/test_sticky.py b/apps/api/plane/tests/contract/api/test_sticky.py new file mode 100644 index 00000000000..f483fb0950c --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_sticky.py @@ -0,0 +1,43 @@ +import pytest +from plane.db.models import Sticky, Workspace + + +@pytest.mark.django_db +def test_default_sort_order_assignment(workspace, session_client): + # Create the first sticky + s1 = Sticky.objects.create( + workspace=workspace, + owner=session_client.user, + name="Sticky 1", + ) + assert s1.sort_order == 65535 + + # Create the second sticky + s2 = Sticky.objects.create( + workspace=workspace, + owner=session_client.user, + name="Sticky 2", + ) + # According to the model logic, sort_order increments by +10000 + assert s2.sort_order == 65535 + 10000 + + +@pytest.mark.django_db +def test_api_returns_ordered_list(workspace, session_client): + Sticky.objects.create( + workspace=workspace, + owner=session_client.user, + name="Last", + sort_order=200, + ) + Sticky.objects.create( + workspace=workspace, + owner=session_client.user, + name="First", + sort_order=100, + ) + + res = session_client.get(f"/api/workspaces/{workspace.slug}/stickies/") + results = res.data + assert results[0]["name"] == "First" + assert results[1]["name"] == "Last" \ No newline at end of file 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" 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, 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/components/stickies/layout/stickies-list.tsx b/apps/web/core/components/stickies/layout/stickies-list.tsx index 6a9e78ea2d1..80ffb47d412 100644 --- a/apps/web/core/components/stickies/layout/stickies-list.tsx +++ b/apps/web/core/components/stickies/layout/stickies-list.tsx @@ -13,7 +13,6 @@ import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/eleme import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; -import Masonry from "react-masonry-component"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; @@ -47,6 +46,20 @@ type TProps = TStickiesLayout & { columnCount: number; }; +function noopLayout() { + // No-op for grid layout (was used for Masonry reflow) +} + +function getColumnCount(width: number | null): number { + if (width === null) return 4; + + if (width < 640) return 2; // sm + if (width < 850) return 3; // md + if (width < 1024) return 4; // lg + if (width < 1280) return 5; // xl + return 6; // 2xl and above +} + export const StickiesList = observer(function StickiesList(props: TProps) { const { workspaceSlug, intersectionElement, columnCount } = props; // navigation @@ -62,7 +75,7 @@ export const StickiesList = observer(function StickiesList(props: TProps) { const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); // derived values const workspaceStickyIds = getWorkspaceStickyIds(workspaceSlug?.toString()); - const itemWidth = `${100 / columnCount}%`; + const itemWidth = "100%"; const totalRows = Math.ceil(workspaceStickyIds.length / columnCount); const isStickiesPage = pathname?.includes("stickies"); const hasGuestLevelPermissions = allowPermissions( @@ -71,14 +84,6 @@ export const StickiesList = observer(function StickiesList(props: TProps) { ); const stickiesResolvedPath = resolvedTheme === "light" ? lightStickiesAsset : darkStickiesAsset; const stickiesSearchResolvedPath = resolvedTheme === "light" ? lightStickiesSearchAsset : darkStickiesSearchAsset; - const masonryRef = useRef(null); - - const handleLayout = () => { - if (masonryRef.current) { - // Force reflow - masonryRef.current.performLayout(); - } - }; // Function to determine if an item is in first or last row const getRowPositions = (index: number) => { @@ -148,27 +153,27 @@ export const StickiesList = observer(function StickiesList(props: TProps) { } return ( -
- {/* @ts-expect-error type mismatch here */} - - {workspaceStickyIds.map((stickyId, index) => { - const { isInFirstRow, isInLastRow } = getRowPositions(index); - return ( - - ); - })} - {intersectionElement &&
{intersectionElement}
} -
+
+ {workspaceStickyIds.map((stickyId, index) => { + const { isInFirstRow, isInLastRow } = getRowPositions(index); + return ( + + ); + })} + {intersectionElement &&
{intersectionElement}
}
); }); @@ -194,15 +199,6 @@ export function StickiesLayout(props: TStickiesLayout) { return () => resizeObserver.disconnect(); }, []); - const getColumnCount = (width: number | null): number => { - if (width === null) return 4; - - if (width < 640) return 2; // sm - if (width < 850) return 3; // md - if (width < 1024) return 4; // lg - if (width < 1280) return 5; // xl - return 6; // 2xl and above - }; const columnCount = getColumnCount(containerWidth); return ( diff --git a/apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx b/apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx index 82e20140138..d3b8f240d7c 100644 --- a/apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx +++ b/apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx @@ -20,6 +20,9 @@ import { usePathname } from "next/navigation"; import { createRoot } from "react-dom/client"; // plane types import type { InstructionType } from "@plane/types"; +// plane ui +import { DragHandle, DropIndicator } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { StickyNote } from "../sticky"; // helpers @@ -37,26 +40,38 @@ type Props = { }; export const StickyDNDWrapper = observer(function StickyDNDWrapper(props: Props) { - const { stickyId, workspaceSlug, itemWidth, isLastChild, handleDrop, handleLayout } = props; + const { + stickyId, + workspaceSlug, + itemWidth, + isLastChild, + isInFirstRow, + isInLastRow: _isInLastRow, + handleDrop, + handleLayout, + } = props; // states const [isDragging, setIsDragging] = useState(false); - const [_instruction, setInstruction] = useState(undefined); + const [instruction, setInstruction] = useState(undefined); // refs const elementRef = useRef(null); + const dragHandleRef = useRef(null); // navigation const pathname = usePathname(); + const isStickiesPage = pathname?.includes("stickies"); useEffect(() => { - const element = elementRef.current; - if (!element) return; + const wrapperElement = elementRef.current; + const dragHandle = dragHandleRef.current; + if (!wrapperElement) return; const initialData = { id: stickyId, type: "sticky" }; - if (pathname.includes("stickies")) + if (isStickiesPage) return combine( draggable({ - element, - dragHandle: element, + element: wrapperElement, + dragHandle: dragHandle ?? wrapperElement, getInitialData: () => initialData, onDragStart: () => { setIsDragging(true); @@ -88,9 +103,9 @@ export const StickyDNDWrapper = observer(function StickyDNDWrapper(props: Props) }, }), dropTargetForElements({ - element, + element: wrapperElement, canDrop: ({ source }) => source.data?.type === "sticky", - getData: ({ input, element }) => { + getData: ({ input, element: targetElement }) => { const blockedStates: InstructionType[] = ["make-child"]; if (!isLastChild) { blockedStates.push("reorder-below"); @@ -98,7 +113,7 @@ export const StickyDNDWrapper = observer(function StickyDNDWrapper(props: Props) return attachInstruction(initialData, { input, - element, + element: targetElement, currentLevel: 1, indentPerLevel: 0, mode: isLastChild ? "last-in-group" : "standard", @@ -106,8 +121,8 @@ export const StickyDNDWrapper = observer(function StickyDNDWrapper(props: Props) }); }, onDrag: ({ self, source, location }) => { - const instruction = getInstructionFromPayload(self, source, location); - setInstruction(instruction); + const nextInstruction = getInstructionFromPayload(self, source, location); + setInstruction(nextInstruction); }, onDragLeave: () => { setInstruction(undefined); @@ -118,23 +133,39 @@ export const StickyDNDWrapper = observer(function StickyDNDWrapper(props: Props) }, }) ); - }, [handleDrop, isDragging, isLastChild, pathname, stickyId, workspaceSlug]); + }, [handleDrop, isDragging, isLastChild, isStickiesPage, stickyId, workspaceSlug]); return (
- {/* {!isInFirstRow && } */} + {!isInFirstRow && } + {isStickiesPage && ( +
+ +
+ )} - {/* {!isInLastRow && } */} + {!isLastChild && }
); }); 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/apps/web/core/store/sticky/sticky.store.ts b/apps/web/core/store/sticky/sticky.store.ts index 09833214505..4b5bf1d79e7 100644 --- a/apps/web/core/store/sticky/sticky.store.ts +++ b/apps/web/core/store/sticky/sticky.store.ts @@ -205,7 +205,10 @@ export class StickyStore implements IStickyStore { } catch (error) { console.error("Error in updating sticky:", error); this.stickies[id] = sticky; - throw new Error(); + if (error instanceof Error) { + throw error; + } + throw new Error("Error updating sticky", { cause: error }); } }; @@ -232,12 +235,15 @@ export class StickyStore implements IStickyStore { destinationId: string, edge: InstructionType ) => { - const previousSortOrder = this.stickies[stickyId].sort_order; + if (stickyId === destinationId) return; + const sticky = this.stickies[stickyId]; + if (!sticky) return; + const previousSortOrder = sticky.sort_order; try { let resultSequence = 10000; const workspaceStickies = this.workspaceStickies[workspaceSlug] || []; const stickies = workspaceStickies.map((id) => this.stickies[id]); - const sortedStickies = orderBy(stickies, "sort_order", "desc").map((sticky) => sticky.id); + const sortedStickies = orderBy(stickies, "sort_order", "desc").map((s) => s.id); const destinationSequence = this.stickies[destinationId]?.sort_order || undefined; if (destinationSequence) { 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;