diff --git a/packages/calcite-components/src/components.d.ts b/packages/calcite-components/src/components.d.ts index 4c163bdf1c3..7d61f308a65 100644 --- a/packages/calcite-components/src/components.d.ts +++ b/packages/calcite-components/src/components.d.ts @@ -1805,6 +1805,10 @@ export namespace Components { * Defines the items to filter. The component uses the values as the starting point, and returns items that contain the string entered in the input, using a partial match and recursive search. This property is needed to conduct filtering. */ "items": object[]; + /** + * Specifies the fields to match against when filtering. + */ + "matchFields": string[]; /** * Use this property to override individual strings used by the component. */ @@ -9617,6 +9621,10 @@ declare namespace LocalJSX { * Defines the items to filter. The component uses the values as the starting point, and returns items that contain the string entered in the input, using a partial match and recursive search. This property is needed to conduct filtering. */ "items"?: object[]; + /** + * Specifies the fields to match against when filtering. + */ + "matchFields"?: string[]; /** * Use this property to override individual strings used by the component. */ diff --git a/packages/calcite-components/src/components/filter/filter.e2e.ts b/packages/calcite-components/src/components/filter/filter.e2e.ts index b38fce1cfda..b24f21cf53b 100644 --- a/packages/calcite-components/src/components/filter/filter.e2e.ts +++ b/packages/calcite-components/src/components/filter/filter.e2e.ts @@ -277,6 +277,14 @@ describe("calcite-filter", () => { await page.waitForTimeout(DEBOUNCE_TIMEOUT); assertMatchingItems(await filter.getProperty("filteredItems"), ["harry"]); }); + + it("should return no matching values", async () => { + const filter = await page.find("calcite-filter"); + filter.setProperty("matchFields", ["description"]); + await page.waitForChanges(); + await page.waitForTimeout(DEBOUNCE_TIMEOUT); + assertMatchingItems(await filter.getProperty("filteredItems"), []); + }); }); describe("filter method", () => { diff --git a/packages/calcite-components/src/components/filter/filter.tsx b/packages/calcite-components/src/components/filter/filter.tsx index 125c688c461..7f473ce7f30 100644 --- a/packages/calcite-components/src/components/filter/filter.tsx +++ b/packages/calcite-components/src/components/filter/filter.tsx @@ -81,6 +81,16 @@ export class Filter */ @Prop({ mutable: true }) filteredItems: object[] = []; + /** + * Specifies the fields to match against when filtering. This will only apply when `value` is an object. If not set, all fields will be matched. + */ + @Prop() matchFields: string[]; + + @Watch("matchFields") + matchFieldsHandler(): void { + this.filterDebounced(this.value); + } + /** * Specifies placeholder text for the input element. */ @@ -159,7 +169,7 @@ export class Filter async componentWillLoad(): Promise { setUpLoadableComponent(this); if (this.items.length) { - this.updateFiltered(filter(this.items, this.value)); + this.updateFiltered(filter(this.items, this.value, this.matchFields)); } await setUpMessages(this); } @@ -223,7 +233,8 @@ export class Filter private filterDebounced = debounce( (value: string, emit = false, onFilter?: () => void): void => - this.items.length && this.updateFiltered(filter(this.items, value), emit, onFilter), + this.items.length && + this.updateFiltered(filter(this.items, value, this.matchFields), emit, onFilter), DEBOUNCE_TIMEOUT, ); diff --git a/packages/calcite-components/src/utils/filter.spec.ts b/packages/calcite-components/src/utils/filter.spec.ts new file mode 100644 index 00000000000..ac5f45cc64e --- /dev/null +++ b/packages/calcite-components/src/utils/filter.spec.ts @@ -0,0 +1,71 @@ +import { filter } from "./filter"; + +describe("filter function", () => { + it("warns and returns empty array for empty data", () => { + const spy = jest.spyOn(console, "warn"); + const result = filter([], "test"); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + spy.mockRestore(); + }); + + it("returns empty array when no objects match", () => { + const data = [{ name: "John" }, { name: "Jane" }]; + const result = filter(data, "Doe"); + expect(result).toEqual([]); + }); + + it("returns matching objects", () => { + const data = [{ name: "John" }, { name: "Jane" }]; + const result = filter(data, "Jane"); + expect(result).toEqual([{ name: "Jane" }]); + }); + + it("considers only specified fields for matching", () => { + const data = [ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ]; + const result = filter(data, "25", ["age"]); + expect(result).toEqual([{ name: "Jane", age: 25 }]); + + const result2 = filter(data, "John", ["age"]); + expect(result2).toEqual([]); + }); + + it("ignores functions and null values in objects", () => { + const data = [{ name: "John", action: () => {}, value: null }]; + const result = filter(data, "John"); + expect(result).toEqual([{ name: "John", action: expect.any(Function), value: null }]); + }); + + it("returns empty array when searching with 'null'", () => { + const data = [{ name: "John", action: () => {}, value: null }]; + const result = filter(data, "null"); + expect(result).toEqual([]); + }); + + it("searches nested objects correctly", () => { + const data = [{ name: "John", details: { age: 30 } }]; + const result = filter(data, "30"); + expect(result).toEqual([{ name: "John", details: { age: 30 } }]); + }); + + it("searches arrays in objects correctly", () => { + const data = [{ name: "John", tags: ["developer", "tester"] }]; + const result = filter(data, "tester"); + expect(result).toEqual([{ name: "John", tags: ["developer", "tester"] }]); + }); + + it("always includes objects with filterDisabled set to true", () => { + const data = [{ name: "John" }, { name: "Jane", filterDisabled: true }]; + const result = filter(data, "Doe"); + expect(result).toEqual([{ name: "Jane", filterDisabled: true }]); + }); + + it("always includes objects with constant set to true", () => { + const data = [{ name: "John" }, { name: "Jane", constant: true }]; + const result = filter(data, "Doe"); + expect(result).toEqual([{ name: "Jane", constant: true }]); + }); +}); diff --git a/packages/calcite-components/src/utils/filter.ts b/packages/calcite-components/src/utils/filter.ts index d94fda4bce3..8ab36c624f4 100644 --- a/packages/calcite-components/src/utils/filter.ts +++ b/packages/calcite-components/src/utils/filter.ts @@ -1,6 +1,6 @@ import { escapeRegExp, forIn } from "lodash-es"; -export const filter = (data: Array, value: string): Array => { +export const filter = (data: Array, value: string, matchFields?: string[]): Array => { const escapedValue = escapeRegExp(value); const regex = new RegExp(escapedValue, "i"); @@ -9,16 +9,22 @@ export const filter = (data: Array, value: string): Array => { The data argument should be an array of objects`); } - const find = (input: object, RE: RegExp) => { + const find = (input: object, RE: RegExp, fields?: string[]) => { if ((input as any)?.constant || (input as any)?.filterDisabled) { return true; } + let found = false; - forIn(input, (val) => { + forIn(input, (val, key) => { if (typeof val === "function" || val == null /* intentional == to catch undefined */) { return; } + + if (fields && !fields.includes(key)) { + return; + } + if (Array.isArray(val) || (typeof val === "object" && val !== null)) { if (find(val, RE)) { found = true; @@ -27,12 +33,9 @@ export const filter = (data: Array, value: string): Array => { found = true; } }); + return found; }; - const result = data.filter((item) => { - return find(item, regex); - }); - - return result; + return data.filter((item) => find(item, regex, matchFields)); };