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/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/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/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 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/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: