Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Getting started with Plane is simple. Choose the setup that works best for you:

- **Work Items**
Efficiently create and manage tasks with a robust rich text editor that supports file uploads. Enhance organization and tracking by adding sub-properties and referencing related issues.
In a project's Work Items filter menu, choose **Title** to match a case-insensitive substring of the work item name (not its description or ID). Type a value and press Enter or leave the input to apply it; Escape discards an uncommitted edit. Surrounding whitespace is trimmed, and committing an empty value or using the chip's remove button removes the condition. Title combines with the existing filters using AND and is retained by project filter persistence and **Save view**.

- **Cycles**
Maintain your team’s momentum with Cycles. Track progress effortlessly using burn-down charts and other insightful tools.
Expand All @@ -69,6 +70,19 @@ Getting started with Plane is simple. Choose the setup that works best for you:

See [CONTRIBUTING](./CONTRIBUTING.md)

Title-filter regression checks:

```sh
pnpm turbo run build --filter=@plane/shared-state...
pnpm --filter @plane/shared-state test
pnpm --filter web test
docker compose -f docker-compose-test.yml run --rm api-tests \
pytest plane/tests/unit/utils/test_issue_title_filters.py \
plane/tests/unit/utils/test_issue_datetime_filters.py -q
```

The rich-filter payload uses `{"name__icontains":"login"}` or that condition inside the existing `and` group. Project lists refetch from the API; no separate client-side title matcher or search endpoint is used.

## ⚙️ Built with

[![React Router](https://img.shields.io/badge/-React%20Router-CA4245?logo=react-router&style=for-the-badge&logoColor=white)](https://reactrouter.com/)
Expand Down
90 changes: 90 additions & 0 deletions apps/api/plane/tests/unit/utils/test_issue_title_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

import json
from types import SimpleNamespace

import pytest

from plane.db.models import Issue, Project, State
from plane.utils.filters.filter_backend import ComplexFilterBackend
from plane.utils.filters.filterset import IssueFilterSet


@pytest.fixture
def title_issues(db, workspace, create_user):
"""Create project-scoped issues that distinguish title matches from description-only matches."""
project = Project.objects.create(name="Title Filters", identifier="TF", workspace=workspace)
started = State.objects.create(name="In progress", group="started", project=project, workspace=workspace)
backlog = State.objects.create(name="Backlog", group="backlog", project=project, workspace=workspace)
rows = [
("Fix LOGIN, SSO & 50%_done", "high", started, ""),
("login screen", "low", started, ""),
("Login backlog", "high", backlog, ""),
("Unrelated", "high", started, "<p>login appears only in description</p>"),
]
for name, priority, state, description in rows:
Issue.objects.create(
name=name,
priority=priority,
state=state,
description_html=description,
project=project,
workspace=workspace,
created_by=create_user,
)
return Issue.objects.filter(project=project)


def filtered_titles(queryset, expression):
"""Apply a serialized rich-filter expression through the API backend and return matching titles."""
request = SimpleNamespace(query_params={"filters": json.dumps(expression)})
view = SimpleNamespace(filterset_class=IssueFilterSet)
result = ComplexFilterBackend().filter_queryset(request, queryset, view)
return set(result.values_list("name", flat=True))


@pytest.mark.unit
@pytest.mark.django_db
class TestTitleRichFilter:
def test_matches_case_insensitive_substring_only_in_title(self, title_issues):
"""Verify mixed-case substrings match names without searching descriptions."""
assert filtered_titles(title_issues, {"name__icontains": "LoGiN"}) == {
"Fix LOGIN, SSO & 50%_done",
"login screen",
"Login backlog",
}

def test_combines_title_with_priority_and_state_group(self, title_issues):
"""Verify title, priority and state-group predicates intersect in an AND expression."""
assert filtered_titles(
title_issues,
{
"and": [
{"name__icontains": "login"},
{"priority__in": "high,urgent"},
{"state_group__in": "started"},
]
},
) == {"Fix LOGIN, SSO & 50%_done"}

def test_no_match_and_identifier_are_not_title_matches(self, title_issues):
"""Verify missing text, UUIDs and project identifiers do not match unrelated titles."""
assert filtered_titles(title_issues, {"name__icontains": "does not exist"}) == set()
issue = title_issues.get(name="Unrelated")
assert filtered_titles(title_issues, {"name__icontains": str(issue.id)}) == set()
assert filtered_titles(title_issues, {"name__icontains": f"TF-{issue.sequence_id}"}) == set()

@pytest.mark.parametrize("substring", ["LOGIN, SSO", "50%_", "&"])
def test_punctuation_is_literal_not_csv_or_sql_wildcards(self, title_issues, substring):
"""Verify commas, percent signs and underscores remain literal substring characters."""
assert filtered_titles(title_issues, {"name__icontains": substring}) == {"Fix LOGIN, SSO & 50%_done"}

def test_removing_title_preserves_other_filters(self, title_issues):
"""Verify a priority-only expression retains issues regardless of their title."""
assert filtered_titles(title_issues, {"priority__in": "high"}) == {
"Fix LOGIN, SSO & 50%_done",
"Login backlog",
"Unrelated",
}
1 change: 1 addition & 0 deletions apps/api/plane/utils/filters/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def filter_queryset(self, queryset):

class IssueFilterSet(BaseFilterSet):
# Custom filter methods to handle soft delete exclusion for relations
name__icontains = filters.CharFilter(field_name="name", lookup_expr="icontains")

assignee_id = filters.UUIDFilter(method="filter_assignee_id")
assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in")
Expand Down
15 changes: 15 additions & 0 deletions apps/web/components/rich-filters/filter-value-input/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,27 @@ import { DateRangeFilterValueInput } from "./date/range";
import { SingleDateFilterValueInput } from "./date/single";
import { MultiSelectFilterValueInput } from "./select/multi";
import { SingleSelectFilterValueInput } from "./select/single";
import { TextFilterValueInput } from "./text";

/**
* Render the value editor selected by the condition's filter field type.
*/
export const FilterValueInput = observer(function FilterValueInput<P extends TFilterProperty, V extends TFilterValue>(
props: TFilterValueInputProps<P, V>
) {
const { condition, filterFieldConfig, isDisabled = false, onChange } = props;

if (filterFieldConfig.type === FILTER_FIELD_TYPE.TEXT) {
return (
<TextFilterValueInput
value={typeof condition.value === "string" ? condition.value : ""}
placeholder={filterFieldConfig.placeholder}
isDisabled={isDisabled}
onChange={(value) => onChange(value as SingleOrArray<V>)}
/>
);
}

// Single select input
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) {
return (
Expand Down
102 changes: 102 additions & 0 deletions apps/web/components/rich-filters/filter-value-input/text.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FilterInstance, workItemFiltersAdapter } from "@plane/shared-state";
import type { TWorkItemFilterExpression } from "@plane/types";
import { CORE_TEXT_OPERATOR } from "@plane/types";
import { TextFilterValueInput } from "./text";

afterEach(cleanup);

describe("text filter editing", () => {
it("keeps typing local and commits a trimmed substring on Enter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TextFilterValueInput value="" placeholder="Enter title" onChange={onChange} />);
const input = screen.getByRole("textbox", { name: "Filter value" });
await user.type(input, " Login, SSO ");
expect(onChange).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledExactlyOnceWith("Login, SSO");
});
it("commits an edited value on blur without issuing requests while typing", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TextFilterValueInput value="login" placeholder="Enter title" onChange={onChange} />);
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), " payment ");
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledExactlyOnceWith("payment");
});

it("discards the draft on Escape, including after blur", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TextFilterValueInput value="login" placeholder="Enter title" onChange={onChange} />);
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), "payment{Escape}");
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("login");
await user.tab();
expect(onChange).not.toHaveBeenCalled();
});

it("commits the empty-value sentinel only after editing finishes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TextFilterValueInput value="login" placeholder="Enter title" onChange={onChange} />);
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), " ");
expect(onChange).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledExactlyOnceWith(null);
});

it("replaces an uncommitted draft when a saved expression is restored", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const { rerender } = render(<TextFilterValueInput value="login" placeholder="Enter title" onChange={onChange} />);
await user.type(screen.getByRole("textbox"), " draft");
rerender(<TextFilterValueInput value="payment" placeholder="Enter title" onChange={onChange} />);
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("payment");
expect(onChange).not.toHaveBeenCalled();
});

it("does not edit or commit a disabled filter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<TextFilterValueInput value="login" placeholder="Enter title" isDisabled onChange={onChange} />);
await user.type(screen.getByRole("textbox"), "payment{Enter}");
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("login");
expect(onChange).not.toHaveBeenCalled();
});

it("removes a cleared title from the persisted expression while preserving priority", async () => {
const user = userEvent.setup();
const persisted: TWorkItemFilterExpression[] = [];
const filter = new FilterInstance({
adapter: workItemFiltersAdapter,
initialExpression: { and: [{ name__icontains: "login" }, { priority__in: "high" }] },
onExpressionChange: (expression) => persisted.push(expression),
});
const title = filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)!;
render(
<TextFilterValueInput
value="login"
placeholder="Enter title"
onChange={(value) => filter.updateConditionValue(title.id, value)}
/>
);
await user.clear(screen.getByRole("textbox"));
await user.keyboard("{Enter}");
expect(persisted).toEqual([{ priority__in: "high" }]);
expect(filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)).toBeUndefined();
});
});
65 changes: 65 additions & 0 deletions apps/web/components/rich-filters/filter-value-input/text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import { useState } from "react";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../shared";

type TTextFilterValueInputProps = {
value: string;
placeholder: string;
isDisabled?: boolean;
onChange: (value: string | null) => void;
};

/**
* Render a text filter whose local draft resets when the committed value changes.
*/
export function TextFilterValueInput(props: TTextFilterValueInputProps) {
return <TextFilterInput key={props.value} {...props} />;
}

/**
* Keep edits local until blur or Enter, and discard them on Escape.
* Trim committed text and emit null to clear an empty condition.
*/
function TextFilterInput({ value, placeholder, isDisabled, onChange }: TTextFilterValueInputProps) {
// Store only an edit; the committed value remains owned by the filter expression.
const [draft, setDraft] = useState<string | null>(null);

return (
<InputGroup
size="md"
render={<div className={`h-full w-48 rounded-none border-0 ${COMMON_FILTER_ITEM_BORDER_CLASSNAME}`} />}
>
<Input
aria-label="Filter value"
placeholder={placeholder}
value={draft ?? value}
disabled={isDisabled}
size="md"
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
if (isDisabled) return;
const nextValue = (draft ?? value).trim();
setDraft(nextValue);
if (nextValue !== value || nextValue === "") onChange(nextValue || null);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setDraft(null);
}
}}
/>
</InputGroup>
);
}
Loading