diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index 289155b87c6..1e2d00c7f29 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -41,6 +41,7 @@ ProjectPage, WorkspaceMember, ) +from plane.utils.issue_search import build_search_snippet, split_search_terms class GlobalSearchEndpoint(BaseAPIView): @@ -81,17 +82,28 @@ def filter_projects(self, query, slug, _project_id, _workspace_search): ) def filter_issues(self, query, slug, project_id, workspace_search): - fields = ["name", "sequence_id", "project__identifier"] + fields = ["name", "description_stripped", "sequence_id", "project__identifier"] q = Q() if query: - for field in fields: - if field == "sequence_id": - # Match whole integers only (exclude decimal numbers) - sequences = re.findall(r"\b\d+\b", query) - for sequence_id in sequences: - q |= Q(**{"sequence_id": sequence_id}) - else: - q |= Q(**{f"{field}__icontains": query}) + search_terms = split_search_terms(query) + if not search_terms: + q = Q(pk__in=[]) + elif len(search_terms) == 1: + search_term = search_terms[0] + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", search_term) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": search_term}) + else: + # For multi-keyword searches, every term must match either the + # work item title or its plain-text description. Sequence IDs + # and project identifiers intentionally retain no new behavior. + for search_term in search_terms: + q &= Q(name__icontains=search_term) | Q(description_stripped__icontains=search_term) issues = Issue.issue_objects.filter( q, @@ -104,14 +116,24 @@ def filter_issues(self, query, slug, project_id, workspace_search): if workspace_search == "false" and project_id: issues = issues.filter(project_id=project_id) - return issues.distinct().values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "workspace__slug", - )[:100] + issue_results = list( + issues.distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + "description_stripped", + )[:100] + ) + + for issue in issue_results: + description = issue.pop("description_stripped", None) + issue["description_snippet"] = build_search_snippet(description, query) + + return issue_results def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] diff --git a/apps/api/plane/tests/contract/app/test_global_search_app.py b/apps/api/plane/tests/contract/app/test_global_search_app.py new file mode 100644 index 00000000000..c6d6923dd83 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_global_search_app.py @@ -0,0 +1,109 @@ +"""Contract tests for global workspace search.""" + +# 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 django.urls import reverse + +from plane.db.models import Issue, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + project = Project.objects.create( + name="Search Project", + identifier="SP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + +@pytest.fixture +def search_issues(db, workspace, project): + title_match = Issue.objects.create( + name="Title needle", + description_html="
Body without the search term.
", + workspace=workspace, + project=project, + ) + description_match = Issue.objects.create( + name="Unrelated title", + description_html="This body contains the needle in the description.
", + workspace=workspace, + project=project, + ) + no_match = Issue.objects.create( + name="Unrelated issue", + description_html="Nothing relevant here.
", + workspace=workspace, + project=project, + ) + return title_match, description_match, no_match + + +@pytest.fixture +def multi_keyword_search_issues(db, workspace, project): + title_and_description_match = Issue.objects.create( + name="Alpha title", + description_html="Body contains beta.
", + workspace=workspace, + project=project, + ) + description_match = Issue.objects.create( + name="Unrelated title", + description_html="Alpha and beta are both in the body.
", + workspace=workspace, + project=project, + ) + partial_match = Issue.objects.create( + name="Alpha only", + description_html="Only alpha appears here.
", + workspace=workspace, + project=project, + ) + return title_and_description_match, description_match, partial_match + + +@pytest.mark.contract +class TestGlobalSearch: + @pytest.mark.django_db + def test_searches_issue_title_or_description(self, session_client, workspace, project, search_issues): + title_match, description_match, no_match = search_issues + response = session_client.get( + reverse("global-search", kwargs={"slug": workspace.slug}), + {"search": "needle", "project_id": str(project.id), "workspace_search": "false"}, + ) + + assert response.status_code == 200 + results = {str(result["id"]): result for result in response.data["results"]["issue"]} + + assert str(title_match.id) in results + assert str(description_match.id) in results + assert str(no_match.id) not in results + assert results[str(title_match.id)]["description_snippet"] is None + assert "needle" in results[str(description_match.id)]["description_snippet"].lower() + assert "" not in results[str(description_match.id)]["description_snippet"]
+
+ @pytest.mark.django_db
+ def test_requires_all_keywords_across_issue_title_and_description(
+ self, session_client, workspace, project, multi_keyword_search_issues
+ ):
+ title_and_description_match, description_match, partial_match = multi_keyword_search_issues
+ response = session_client.get(
+ reverse("global-search", kwargs={"slug": workspace.slug}),
+ {"search": " alpha beta ", "project_id": str(project.id), "workspace_search": "false"},
+ )
+
+ assert response.status_code == 200
+ results = {str(result["id"]): result for result in response.data["results"]["issue"]}
+
+ assert str(title_and_description_match.id) in results
+ assert str(description_match.id) in results
+ assert str(partial_match.id) not in results
+ assert "beta" in results[str(title_and_description_match.id)]["description_snippet"].lower()
+ assert "alpha" in results[str(description_match.id)]["description_snippet"].lower()
+ assert "beta" in results[str(description_match.id)]["description_snippet"].lower()
diff --git a/apps/api/plane/tests/unit/utils/test_issue_search.py b/apps/api/plane/tests/unit/utils/test_issue_search.py
new file mode 100644
index 00000000000..dd6a8101ced
--- /dev/null
+++ b/apps/api/plane/tests/unit/utils/test_issue_search.py
@@ -0,0 +1,41 @@
+"""Tests for issue search helpers."""
+
+# 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 plane.utils.issue_search import build_search_snippet
+
+
+@pytest.mark.unit
+class TestBuildSearchSnippet:
+ def test_returns_none_when_query_is_not_in_content(self):
+ assert build_search_snippet("A short description", "missing") is None
+
+ def test_returns_normalized_content_when_it_fits(self):
+ assert build_search_snippet(" Search result\ncontent ", "result") == "Search result content"
+
+ def test_returns_content_when_all_keywords_match_the_description(self):
+ content = "The alpha keyword and beta keyword are both present."
+
+ assert build_search_snippet(content, " alpha beta ") == content
+
+ def test_centers_on_the_first_matching_keyword(self):
+ content = "prefix " + ("x" * 140) + " beta " + ("y" * 140) + " alpha"
+
+ snippet = build_search_snippet(content, "alpha beta")
+
+ assert snippet is not None
+ assert "beta" in snippet.lower()
+
+ def test_centers_first_match_and_marks_truncated_edges(self):
+ content = "prefix " + ("x" * 140) + " keyword " + ("y" * 140)
+
+ snippet = build_search_snippet(content, "KEYWORD")
+
+ assert snippet is not None
+ assert "keyword" in snippet.lower()
+ assert snippet.startswith("…")
+ assert snippet.endswith("…")
diff --git a/apps/api/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py
index 7e5fab8fea3..45538b50837 100644
--- a/apps/api/plane/utils/issue_search.py
+++ b/apps/api/plane/utils/issue_search.py
@@ -11,6 +11,46 @@
# Module imports
+def split_search_terms(query):
+ """Split a search query into non-empty terms using any whitespace."""
+ return (query or "").split()
+
+
+def build_search_snippet(content, query, max_length=120):
+ """Return a plain-text snippet centered around the first matching term."""
+ normalized_content = " ".join((content or "").split())
+ search_terms = split_search_terms(query)
+
+ if not normalized_content or not search_terms:
+ return None
+
+ normalized_content_casefolded = normalized_content.casefold()
+ matching_terms = [
+ (normalized_content_casefolded.find(term.casefold()), term)
+ for term in search_terms
+ ]
+ matching_terms = [(index, term) for index, term in matching_terms if index >= 0]
+ if not matching_terms:
+ return None
+
+ match_index, matched_term = min(matching_terms, key=lambda match: match[0])
+ snippet_length = max(max_length, len(matched_term))
+ if len(normalized_content) <= snippet_length:
+ return normalized_content
+
+ context_before = max(0, (snippet_length - len(matched_term)) // 2)
+ start_index = max(0, match_index - context_before)
+ end_index = min(len(normalized_content), start_index + snippet_length)
+
+ # Keep the requested length when the match is close to the end of the text.
+ if end_index - start_index < snippet_length:
+ start_index = max(0, end_index - snippet_length)
+
+ prefix = "…" if start_index > 0 else ""
+ suffix = "…" if end_index < len(normalized_content) else ""
+ return f"{prefix}{normalized_content[start_index:end_index]}{suffix}"
+
+
def search_issues(query, queryset):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
diff --git a/apps/web/core/components/power-k/ui/modal/command-item.tsx b/apps/web/core/components/power-k/ui/modal/command-item.tsx
index 18fdbd5bc4a..f8ff8399eec 100644
--- a/apps/web/core/components/power-k/ui/modal/command-item.tsx
+++ b/apps/web/core/components/power-k/ui/modal/command-item.tsx
@@ -17,6 +17,7 @@ type Props = {
icon?: React.ComponentType<{ className?: string }>;
iconNode?: React.ReactNode;
isDisabled?: boolean;
+ isMultiline?: boolean;
isSelected?: boolean;
keySequence?: string;
label: string | React.ReactNode;
@@ -26,12 +27,25 @@ type Props = {
};
export function PowerKModalCommandItem(props: Props) {
- const { icon: Icon, iconNode, isDisabled, isSelected, keySequence, label, onSelect, shortcut, value } = props;
+ const {
+ icon: Icon,
+ iconNode,
+ isDisabled,
+ isMultiline = false,
+ isSelected,
+ keySequence,
+ label,
+ onSelect,
+ shortcut,
+ value,
+ } = props;
return (
- {cycle.project__identifier} {cycle.name}
+ {cycle.project__identifier}{" "}
+ {highlightSearchMatches(cycle.name, searchTerm)}
+ {highlightSearchKeywords(workItem.description_snippet, searchTerm)}
+
- {view.project__identifier} {view.name}
+ {view.project__identifier}{" "}
+ {highlightSearchMatches(view.name, searchTerm)}
- {module.project__identifier} {module.name}
+ {module.project__identifier}{" "}
+ {highlightSearchMatches(module.name, searchTerm)}
- {page.project__identifiers?.[0]} {page.name}
+ {page.project__identifiers?.[0]}{" "}
+ {highlightSearchMatches(page.name, searchTerm)}