diff --git a/README.md b/README.md index 74688cd92c4b..c3e25c2fc1c6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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/) diff --git a/apps/api/plane/tests/unit/utils/test_issue_title_filters.py b/apps/api/plane/tests/unit/utils/test_issue_title_filters.py new file mode 100644 index 000000000000..e41324aed939 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_issue_title_filters.py @@ -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, "

login appears only in description

"), + ] + 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", + } diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py index f2a143b7faaf..dcefcba34f2c 100644 --- a/apps/api/plane/utils/filters/filterset.py +++ b/apps/api/plane/utils/filters/filterset.py @@ -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") diff --git a/apps/web/components/rich-filters/filter-value-input/root.tsx b/apps/web/components/rich-filters/filter-value-input/root.tsx index 9a87c0c4655e..29118ba6c83a 100644 --- a/apps/web/components/rich-filters/filter-value-input/root.tsx +++ b/apps/web/components/rich-filters/filter-value-input/root.tsx @@ -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

( props: TFilterValueInputProps ) { const { condition, filterFieldConfig, isDisabled = false, onChange } = props; + if (filterFieldConfig.type === FILTER_FIELD_TYPE.TEXT) { + return ( + onChange(value as SingleOrArray)} + /> + ); + } + // Single select input if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) { return ( diff --git a/apps/web/components/rich-filters/filter-value-input/text.test.tsx b/apps/web/components/rich-filters/filter-value-input/text.test.tsx new file mode 100644 index 000000000000..d19a7f498978 --- /dev/null +++ b/apps/web/components/rich-filters/filter-value-input/text.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + await user.type(screen.getByRole("textbox"), " draft"); + rerender(); + 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(); + 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( + 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(); + }); +}); diff --git a/apps/web/components/rich-filters/filter-value-input/text.tsx b/apps/web/components/rich-filters/filter-value-input/text.tsx new file mode 100644 index 000000000000..0d8e9ae8adee --- /dev/null +++ b/apps/web/components/rich-filters/filter-value-input/text.tsx @@ -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 ; +} + +/** + * 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(null); + + return ( + } + > + 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); + } + }} + /> + + ); +} diff --git a/apps/web/hooks/work-item-filters/use-work-item-filters-config.tsx b/apps/web/hooks/work-item-filters/use-work-item-filters-config.tsx index 256071b6e233..3d0e01fa1cf6 100644 --- a/apps/web/hooks/work-item-filters/use-work-item-filters-config.tsx +++ b/apps/web/hooks/work-item-filters/use-work-item-filters-config.tsx @@ -17,6 +17,7 @@ import { ProjectsOutline, StartDateOutline, StateOutline, + TextOutline, UserOutline, } from "@makeplane/propel/icons"; // plane imports @@ -50,6 +51,7 @@ import { getStateGroupFilterConfig, getSubscriberFilterConfig, getTargetDateFilterConfig, + getTitleFilterConfig, getUpdatedAtFilterConfig, isLoaderReady, } from "@plane/utils"; @@ -88,6 +90,10 @@ export type TWorkItemFiltersConfig = { members: IUserLite[]; }; +/** + * Build work-item filter configs with enablement based on allowed properties and entity data. + * Return both ordered configs and a property lookup alongside their initialization state. + */ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => { const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } = props; @@ -132,9 +138,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): ); const projects = useMemo( () => - projectIds - ? (projectIds.map((projectId) => getProjectById(projectId)).filter((project) => project) as IProject[]) - : [], + projectIds ? (projectIds.map((id) => getProjectById(id)).filter((candidate) => candidate) as IProject[]) : [], [projectIds, getProjectById] ); const areAllConfigsInitialized = useMemo(() => isLoaderReady(projectLoader), [projectLoader]); @@ -147,6 +151,16 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): */ const isFilterEnabled = useCallback((key: TWorkItemFilterProperty) => filtersToShow.has(key), [filtersToShow]); + const titleFilterConfig = useMemo( + () => + getTitleFilterConfig("name")({ + isEnabled: isFilterEnabled("name"), + filterIcon: TextOutline, + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + // state group filter config const stateGroupFilterConfig = useMemo( () => @@ -356,7 +370,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): isEnabled: isFilterEnabled("project_id") && projects !== undefined, filterIcon: ProjectsOutline, projects: projects, - getOptionIcon: (project) => , + getOptionIcon: (option) => , ...operatorConfigs, }), [isFilterEnabled, projects, operatorConfigs] @@ -365,6 +379,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): return { areAllConfigsInitialized, configs: [ + titleFilterConfig, stateFilterConfig, stateGroupFilterConfig, assigneeFilterConfig, @@ -382,6 +397,7 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): subscriberFilterConfig, ], configMap: { + name: titleFilterConfig, project_id: projectFilterConfig, state_group: stateGroupFilterConfig, state_id: stateFilterConfig, diff --git a/apps/web/package.json b/apps/web/package.json index 80ce9c9373a6..1416ad07a62f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "react-router dev --port 3000", "build": "react-router build", + "test": "vitest run", "preview": "react-router build && serve -s build/client -l 3000", "start": "serve -s build/client -l 3000", "clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build", @@ -75,13 +76,17 @@ "@react-router/dev": "catalog:", "@tailwindcss/postcss": "catalog:", "@tailwindcss/typography": "catalog:", + "@testing-library/react": "catalog:", + "@testing-library/user-event": "catalog:", "@types/lodash-es": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "dotenv": "catalog:", + "jsdom": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vite-tsconfig-paths": "catalog:" + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000000..8810ffa9372b --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { alias: { "@": fileURLToPath(new URL(".", import.meta.url)) } }, + test: { environment: "jsdom", include: ["components/**/*.test.tsx"] }, +}); diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 115b8856f419..7f4e4d2bde61 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -205,6 +205,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { }, issues: { filters: [ + "name", "priority", "state_group", "state_id", diff --git a/packages/constants/src/rich-filters/operator-labels/core.ts b/packages/constants/src/rich-filters/operator-labels/core.ts index a973c5f95d1b..ffa7f5f9dfc6 100644 --- a/packages/constants/src/rich-filters/operator-labels/core.ts +++ b/packages/constants/src/rich-filters/operator-labels/core.ts @@ -5,12 +5,18 @@ */ import type { TCoreSupportedOperators, TCoreSupportedDateFilterOperators } from "@plane/types"; -import { CORE_EQUALITY_OPERATOR, CORE_COLLECTION_OPERATOR, CORE_COMPARISON_OPERATOR } from "@plane/types"; +import { + CORE_EQUALITY_OPERATOR, + CORE_COLLECTION_OPERATOR, + CORE_COMPARISON_OPERATOR, + CORE_TEXT_OPERATOR, +} from "@plane/types"; /** * Core operator labels */ export const CORE_OPERATOR_LABELS_MAP: Record = { + [CORE_TEXT_OPERATOR.ICONTAINS]: "contains", [CORE_EQUALITY_OPERATOR.EXACT]: "is", [CORE_COLLECTION_OPERATOR.IN]: "is any of", [CORE_COMPARISON_OPERATOR.RANGE]: "between", diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 21cfedcedd02..9cb73801a60d 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -20,6 +20,7 @@ "check:format": "oxfmt --check .", "fix:lint": "oxlint --fix .", "fix:format": "oxfmt .", + "test": "vitest run", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" }, "dependencies": { @@ -37,6 +38,7 @@ "@types/lodash-es": "catalog:", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/shared-state/src/store/work-item-filters/title-filter.test.ts b/packages/shared-state/src/store/work-item-filters/title-filter.test.ts new file mode 100644 index 000000000000..9b80d5952b40 --- /dev/null +++ b/packages/shared-state/src/store/work-item-filters/title-filter.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, expect, it } from "vitest"; +import type { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types"; +import { CORE_OPERATORS, CORE_TEXT_OPERATOR, LOGICAL_OPERATOR } from "@plane/types"; +import { getTitleFilterConfig } from "@plane/utils"; +import { FilterInstance } from "../rich-filters/filter"; +import { workItemFiltersAdapter } from "./adapter"; + +/** + * Create a title-enabled filter instance and capture expression changes and saved-view payloads. + */ +function createFilter(initialExpression: TWorkItemFilterExpression = {}) { + const changes: TWorkItemFilterExpression[] = []; + const saved: TWorkItemFilterExpression[] = []; + const filter = new FilterInstance({ + adapter: workItemFiltersAdapter, + initialExpression, + onExpressionChange: (expression) => changes.push(expression), + options: { + expression: { + saveViewOptions: { + onViewSave: (expression) => { + saved.push(expression); + }, + }, + }, + }, + }); + filter.configManager.registerAll([ + getTitleFilterConfig("name")({ + isEnabled: true, + allowedOperators: new Set(Object.values(CORE_OPERATORS)), + }), + ]); + filter.configManager.setAreConfigsReady(true); + return { filter, changes, saved }; +} + +describe("Title rich filter", () => { + it("keeps punctuation in a saved scalar title and restores it alongside existing filters", async () => { + const { filter, changes, saved } = createFilter({ priority__in: "high,urgent" }); + const config = filter.configManager.getConfigByProperty("name"); + expect(config?.firstOperator).toBe(CORE_TEXT_OPERATOR.ICONTAINS); + filter.addCondition( + LOGICAL_OPERATOR.AND, + { property: "name", operator: config!.firstOperator!, value: undefined }, + false + ); + expect(changes).toEqual([]); + const title = filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)!; + filter.updateConditionValue(title.id, "Login, SSO & 50%_done"); + const expected = { and: [{ priority__in: "high,urgent" }, { name__icontains: "Login, SSO & 50%_done" }] }; + expect(changes).toEqual([expected]); + await filter.saveView(); + expect(saved).toEqual([expected]); + const restored = createFilter(JSON.parse(JSON.stringify(saved[0]))).filter; + expect(restored.hasChanges).toBe(false); + expect(restored.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)?.value).toBe( + "Login, SSO & 50%_done" + ); + expect(workItemFiltersAdapter.toExternal(restored.expression!)).toEqual(expected); + }); + + it("edits and removes the restored title without dropping the other AND conditions", () => { + const { filter, changes } = createFilter({ and: [{ name__icontains: "login" }, { state_group__in: "started" }] }); + const title = filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)!; + filter.updateConditionValue(title.id, "payment"); + expect(changes.at(-1)).toEqual({ and: [{ name__icontains: "payment" }, { state_group__in: "started" }] }); + filter.removeCondition(title.id); + expect(changes.at(-1)).toEqual({ state_group__in: "started" }); + expect(filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)).toBeUndefined(); + }); + + it("clears the final title condition when its committed value is empty", () => { + const { filter, changes } = createFilter({ name__icontains: "login" }); + const title = filter.findFirstConditionByPropertyAndOperator("name", CORE_TEXT_OPERATOR.ICONTAINS)!; + filter.updateConditionValue(title.id, null); + expect(changes).toEqual([{}]); + expect(filter.hasActiveFilters).toBe(false); + expect(filter.canSaveView).toBe(false); + }); +}); diff --git a/packages/types/src/rich-filters/field-types/core.ts b/packages/types/src/rich-filters/field-types/core.ts index 88bc53c6c91c..3a04d9fa8748 100644 --- a/packages/types/src/rich-filters/field-types/core.ts +++ b/packages/types/src/rich-filters/field-types/core.ts @@ -12,12 +12,20 @@ import type { TBaseFilterFieldConfig, IFilterOption } from "./shared"; * Core filter types */ export const CORE_FILTER_FIELD_TYPE = { + TEXT: "text", DATE: "date", DATE_RANGE: "date_range", SINGLE_SELECT: "single_select", MULTI_SELECT: "multi_select", } as const; +/** Free-text filter value, committed on Enter or blur. */ +export type TTextFilterFieldConfig = TBaseFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.TEXT; + defaultValue?: string; + placeholder: string; +}; + // -------- DATE FILTER CONFIGURATIONS -------- type TBaseDateFilterFieldConfig = TBaseFilterFieldConfig & { @@ -79,6 +87,7 @@ export type TMultiSelectFilterFieldConfig = TBaseFilterF * All core filter configurations */ export type TCoreFilterFieldConfigs = + | TTextFilterFieldConfig | TDateFilterFieldConfig | TDateRangeFilterFieldConfig | TSingleSelectFilterFieldConfig diff --git a/packages/types/src/rich-filters/operator-configs/core.ts b/packages/types/src/rich-filters/operator-configs/core.ts index b8eb9d3a11da..f303c048cb65 100644 --- a/packages/types/src/rich-filters/operator-configs/core.ts +++ b/packages/types/src/rich-filters/operator-configs/core.ts @@ -10,8 +10,14 @@ import type { TDateRangeFilterFieldConfig, TSingleSelectFilterFieldConfig, TMultiSelectFilterFieldConfig, + TTextFilterFieldConfig, } from "../field-types"; -import type { CORE_COLLECTION_OPERATOR, CORE_COMPARISON_OPERATOR, CORE_EQUALITY_OPERATOR } from "../operators"; +import type { + CORE_COLLECTION_OPERATOR, + CORE_COMPARISON_OPERATOR, + CORE_EQUALITY_OPERATOR, + CORE_TEXT_OPERATOR, +} from "../operators"; // ----------------------------- EXACT Operator ----------------------------- export type TCoreExactOperatorConfigs = @@ -26,6 +32,7 @@ export type TCoreRangeOperatorConfigs = TDateRangeFilterFieldConfig : T extends typeof FILTER_FIELD_TYPE.DATE_RANGE ? TDateRangeFilterFieldConfig - : never + : T extends typeof FILTER_FIELD_TYPE.TEXT + ? TTextFilterFieldConfig + : never ): TSupportedFilterFieldConfigs => config as TSupportedFilterFieldConfigs; diff --git a/packages/utils/src/work-item-filters/configs/filters/index.ts b/packages/utils/src/work-item-filters/configs/filters/index.ts index a436da8e31ab..f75a989107f5 100644 --- a/packages/utils/src/work-item-filters/configs/filters/index.ts +++ b/packages/utils/src/work-item-filters/configs/filters/index.ts @@ -12,3 +12,4 @@ export * from "./priority"; export * from "./project"; export * from "./state"; export * from "./user"; +export * from "./title"; diff --git a/packages/utils/src/work-item-filters/configs/filters/title.ts b/packages/utils/src/work-item-filters/configs/filters/title.ts new file mode 100644 index 000000000000..c5b7c48bf026 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/title.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TFilterProperty } from "@plane/types"; +import { CORE_TEXT_OPERATOR, FILTER_FIELD_TYPE } from "@plane/types"; +import type { TCreateFilterConfigParams, IFilterIconConfig, TCreateFilterConfig } from "../../../rich-filters"; +import { createFilterConfig, createFilterFieldConfig, createOperatorConfigEntry } from "../../../rich-filters"; + +type TCreateTitleFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +/** + * Create a Title filter factory with a scalar, case-insensitive substring operator. + * @param key - The work-item property to bind the filter to. + * @returns A factory accepting filter enablement, operator and icon options. + */ +export const getTitleFilterConfig = +

(key: P): TCreateFilterConfig => + (params) => + createFilterConfig

({ + id: key, + label: "Title", + ...params, + icon: params.filterIcon, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(CORE_TEXT_OPERATOR.ICONTAINS, params, (updatedParams) => + createFilterFieldConfig({ + ...updatedParams, + type: FILTER_FIELD_TYPE.TEXT, + placeholder: "Enter title", + }) + ), + ]), + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c7cd494021b..f259a62598c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1119,6 +1119,12 @@ importers: '@tailwindcss/typography': specifier: 'catalog:' version: 0.5.19 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.1(@testing-library/dom@10.4.2) '@types/lodash-es': specifier: 'catalog:' version: 4.17.12 @@ -1134,6 +1140,9 @@ importers: dotenv: specifier: 'catalog:' version: 16.4.7 + jsdom: + specifier: 'catalog:' + version: 23.2.0 typescript: specifier: 5.8.3 version: 5.8.3 @@ -1143,6 +1152,9 @@ importers: vite-tsconfig-paths: specifier: 'catalog:' version: 5.1.4(typescript@5.8.3)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.11)(jsdom@23.2.0)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) packages/blocks: dependencies: @@ -1640,6 +1652,9 @@ importers: typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.11)(jsdom@23.2.0)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) packages/tailwind-config: dependencies: