-
Notifications
You must be signed in to change notification settings - Fork 3.7k
[Feature]- Option to open Quicklinks in same page (Issue #8621) #8734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Changes from all commits
bdc77e8
8cbf84c
0359377
43ee518
1ba743b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -8,7 +8,7 @@ | |||||
|
|
||||||
| # Third party imports | ||||||
| from celery import Celery | ||||||
| from pythonjsonlogger.jsonlogger import JsonFormatter | ||||||
| from pythonjsonlogger.json import JsonFormatter | ||||||
|
||||||
| from pythonjsonlogger.json import JsonFormatter | |
| from pythonjsonlogger.jsonlogger import JsonFormatter |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| import pytest | ||
| from rest_framework import status | ||
|
|
||
| from plane.db.models import Page, Project, ProjectMember, ProjectPage | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def project(db, workspace, create_user): | ||
| project = Project.objects.create( | ||
| name="Pages Project", | ||
| identifier="PGS", | ||
| workspace=workspace, | ||
| created_by=create_user, | ||
| ) | ||
| ProjectMember.objects.create( | ||
| project=project, | ||
| member=create_user, | ||
| role=20, | ||
| is_active=True, | ||
| ) | ||
| return project | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def project_pages(db, workspace, project, create_user): | ||
| page_low = Page.objects.create( | ||
| workspace=workspace, | ||
| owned_by=create_user, | ||
| name="Low", | ||
| access=Page.PUBLIC_ACCESS, | ||
| sort_order=100, | ||
| ) | ||
| page_mid = Page.objects.create( | ||
| workspace=workspace, | ||
| owned_by=create_user, | ||
| name="Mid", | ||
| access=Page.PUBLIC_ACCESS, | ||
| sort_order=150, | ||
| ) | ||
| page_high = Page.objects.create( | ||
| workspace=workspace, | ||
| owned_by=create_user, | ||
| name="High", | ||
| access=Page.PUBLIC_ACCESS, | ||
| sort_order=200, | ||
| ) | ||
|
|
||
| for page in [page_low, page_mid, page_high]: | ||
| ProjectPage.objects.create( | ||
| project=project, | ||
| page=page, | ||
| workspace=workspace, | ||
| created_by=create_user, | ||
| ) | ||
|
|
||
| return {"low": page_low, "mid": page_mid, "high": page_high} | ||
|
|
||
|
|
||
| @pytest.mark.contract | ||
| class TestProjectPagesAPI: | ||
| def get_pages_url(self, workspace_slug: str, project_id: str) -> str: | ||
| return f"/api/workspaces/{workspace_slug}/projects/{project_id}/pages/" | ||
|
|
||
| def get_page_url(self, workspace_slug: str, project_id: str, page_id: str) -> str: | ||
| return f"/api/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/" | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_list_pages_sorted_by_sort_order_desc(self, session_client, workspace, project, project_pages): | ||
| url = self.get_pages_url(workspace.slug, project.id) | ||
| response = session_client.get(url) | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| data = response.json() | ||
| assert [row["id"] for row in data] == [ | ||
| str(project_pages["high"].id), | ||
| str(project_pages["mid"].id), | ||
| str(project_pages["low"].id), | ||
| ] | ||
| assert all("sort_order" in row for row in data) | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_patch_sort_order_updates_page(self, session_client, workspace, project, project_pages): | ||
| page = project_pages["mid"] | ||
| url = self.get_page_url(workspace.slug, project.id, page.id) | ||
| response = session_client.patch(url, {"sort_order": 250}, format="json") | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| page.refresh_from_db() | ||
| assert page.sort_order == 250 | ||
| assert response.json()["sort_order"] == 250 | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_patch_sort_order_allowed_when_page_is_locked(self, session_client, workspace, project, project_pages): | ||
| page = project_pages["low"] | ||
| page.is_locked = True | ||
| page.save() | ||
|
|
||
| url = self.get_page_url(workspace.slug, project.id, page.id) | ||
| response = session_client.patch(url, {"sort_order": 175}, format="json") | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| page.refresh_from_db() | ||
| assert page.sort_order == 175 | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_patch_non_sort_field_blocked_when_page_is_locked(self, session_client, workspace, project, project_pages): | ||
| page = project_pages["high"] | ||
| page.is_locked = True | ||
| page.save() | ||
|
|
||
| url = self.get_page_url(workspace.slug, project.id, page.id) | ||
| response = session_client.patch(url, {"name": "Renamed while locked"}, format="json") | ||
|
|
||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
| assert response.json()["error"] == "Page is locked" | ||
|
|
||
| page.refresh_from_db() | ||
| assert page.name == "High" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, "")}/`; | ||||||||||||||||||||||
|
Comment on lines
+7
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Collapse repeated This only strips slashes at the ends, so Suggested fix const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "/";
- return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
+ const normalized = trimmed.replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
+ return normalized ? `/${normalized}/` : "/";
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
Comment on lines
+7
to
+11
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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, | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve explicit
order_byrequests here.Line 103 already applies the caller's
?order_by=..., but Line 105 replaces it unconditionally, so every list request now falls back to-is_favorite, -sort_order, -created_at. Ifsort_orderis only meant to be the default, gate this second ordering behind the absence oforder_byinstead of clobbering it.Suggested fix
🤖 Prompt for AI Agents