From 280ce2e847204cb259ee00149f2a31b75598de1d Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Sat, 22 Feb 2025 14:11:45 +0200 Subject: [PATCH] Add a dependency list to useGridDataProvider and useComboBoxDataProvider This is to support using state and signals in the fetch callback --- .../ComboBoxUseComboBoxDataProviderHook.tsx | 30 +++++- .../views/GridUseGridDataProviderHook.tsx | 39 ++++++-- .../java/tests/spring/react-grid-test/pom.xml | 6 ++ ...ComboBoxUseComboBoxDataProviderHookIT.java | 97 +++++++++++++++++++ .../GridUseGridDataProviderHookIT.java | 31 ++++++ packages/ts/react-crud/src/data-provider.ts | 46 ++++++--- .../ts/react-crud/test/dataprovider.spec.tsx | 60 +++++++++++- 7 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/ComboBoxUseComboBoxDataProviderHookIT.java diff --git a/packages/java/tests/spring/react-grid-test/frontend/views/ComboBoxUseComboBoxDataProviderHook.tsx b/packages/java/tests/spring/react-grid-test/frontend/views/ComboBoxUseComboBoxDataProviderHook.tsx index 96264bc574..08c420f2e3 100644 --- a/packages/java/tests/spring/react-grid-test/frontend/views/ComboBoxUseComboBoxDataProviderHook.tsx +++ b/packages/java/tests/spring/react-grid-test/frontend/views/ComboBoxUseComboBoxDataProviderHook.tsx @@ -1,4 +1,5 @@ import { useComboBoxDataProvider } from '@vaadin/hilla-react-crud'; +import { useSignal } from '@vaadin/hilla-react-signals'; import { Button, ComboBox } from '@vaadin/react-components'; import { useState } from 'react'; import { PersonCustomService } from 'Frontend/generated/endpoints'; @@ -12,10 +13,35 @@ export default function ComboBoxUseComboBoxDataProviderHook(): React.JSX.Element const dataProvider = useComboBoxDataProvider(PersonCustomService.listPersonsLazyWithFilter); const dataProviderLastName = useComboBoxDataProvider(PersonCustomService.listPersonsLazyWithFilter, { sort }); + const filterSignal = useSignal(''); + const dataProviderLastNameFiltered = useComboBoxDataProvider( + async (pageable, filter) => PersonCustomService.listPersonsLazyWithFilter(pageable, filterSignal.value + filter), + { sort }, + [filterSignal.value], + ); + return (
- - + + + { + filterSignal.value = (e.target as HTMLInputElement).value; + }} + /> + -
+ <> +
+
+ { + const filterString = (e.target as HTMLInputElement).value; + setState(state + 1); + filterSignal.value = filterString; + }} + /> +
Filter length useState is {state}
+
Filter value is {filterSignal.value}
+ + + + + + +
+ ); } diff --git a/packages/java/tests/spring/react-grid-test/pom.xml b/packages/java/tests/spring/react-grid-test/pom.xml index aa1b6a528a..7e14f277be 100644 --- a/packages/java/tests/spring/react-grid-test/pom.xml +++ b/packages/java/tests/spring/react-grid-test/pom.xml @@ -37,6 +37,12 @@ vaadin-time-picker-testbench ${vaadin.components.version} + + com.vaadin + vaadin-combo-box-testbench + ${vaadin.components.version} + test + com.h2database h2 diff --git a/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/ComboBoxUseComboBoxDataProviderHookIT.java b/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/ComboBoxUseComboBoxDataProviderHookIT.java new file mode 100644 index 0000000000..aeeaa807b8 --- /dev/null +++ b/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/ComboBoxUseComboBoxDataProviderHookIT.java @@ -0,0 +1,97 @@ +package com.vaadin.hilla.test.reactgrid; + +import java.util.List; +import java.util.Map; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.flow.component.button.testbench.ButtonElement; +import com.vaadin.flow.component.combobox.testbench.ComboBoxElement; +import com.vaadin.flow.testutil.ChromeBrowserTest; +import com.vaadin.testbench.TestBenchElement; + +public class ComboBoxUseComboBoxDataProviderHookIT extends ChromeBrowserTest { + + @Override + protected String getTestPath() { + return getRootURL() + "/" + + getClass().getSimpleName().replace("IT", ""); + + } + + @Override + @Before + public void setup() throws Exception { + super.setup(); + getDriver().get(getTestPath()); + } + + @After + public void checkBrowserLogs() { + checkLogsForErrors(); + } + + @Test + public void defaultSort_dataShown() { + ComboBoxElement comboBox = $(ComboBoxElement.class).id("defaultSort"); + List options = getOptions(comboBox); + Assert.assertEquals("Johnson", options.get(0)); + Assert.assertEquals("Lewis", options.get(9)); + + } + + @Test + public void sortUsingLastname_dataShown() { + ComboBoxElement comboBox = $(ComboBoxElement.class).id("sortLastName"); + List options = getOptions(comboBox); + Assert.assertEquals("Adams", options.get(0)); + Assert.assertEquals("Evans", options.get(9)); + } + + @Test + public void filteringUsingSignalWorks() { + ComboBoxElement comboBox = $(ComboBoxElement.class).id("prependFilter"); + List options = getOptions(comboBox); + Assert.assertEquals("Adams", options.get(0)); + Assert.assertEquals("Evans", options.get(9)); + comboBox.closePopup(); + + TestBenchElement filterInput = $("input").id("filter"); + filterInput.sendKeys("c"); + options = getOptions(comboBox); + Assert.assertEquals("Baker", options.get(0)); // Zack + Assert.assertEquals("Johnson", options.get(9)); // Alice + } + + private List getOptions(ComboBoxElement comboBox) { + comboBox.openPopup(); + return waitUntil(driver -> { + List opt = comboBox.getOptions(); + if (opt.isEmpty()) { + return null; + } + return opt; + }); + } + + private void setFilter(String string) { + TestBenchElement filterInput = $("input").first(); + filterInput.clear(); + filterInput.sendKeys(string); + filterInput.dispatchEvent("input", Map.of("bubbles", true)); + } + + private void refresh() { + var refreshButton = $(ButtonElement.class).all().stream() + .filter(button -> button.getText().equals("Refresh")) + .findFirst(); + if (refreshButton.isPresent()) { + refreshButton.get().click(); + } else { + Assert.fail("Refresh button not found"); + } + } +} diff --git a/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/GridUseGridDataProviderHookIT.java b/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/GridUseGridDataProviderHookIT.java index f595a49017..174e452188 100644 --- a/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/GridUseGridDataProviderHookIT.java +++ b/packages/java/tests/spring/react-grid-test/src/test/java/com/vaadin/hilla/test/reactgrid/GridUseGridDataProviderHookIT.java @@ -1,9 +1,13 @@ package com.vaadin.hilla.test.reactgrid; +import java.util.Map; + import org.junit.Assert; import org.junit.Test; +import com.vaadin.flow.component.Key; import com.vaadin.flow.component.button.testbench.ButtonElement; +import com.vaadin.testbench.TestBenchElement; public class GridUseGridDataProviderHookIT extends AbstractGridTest { @@ -34,6 +38,33 @@ public void sortingWorks_and_refreshingDataProvider_keepsTheAppliedSort() { assertFirstName(3, "Xander"); } + @Test + public void filteringUsingSignalWorks() { + assertFirstName(0, "Alice"); + assertFirstName(1, "Bob"); + assertFirstName(2, "Charlie"); + assertFirstName(3, "David"); + + setFilter("al"); + assertFirstName(0, "Alice"); + assertFirstName(1, "Edward"); // Gonazlez matches + assertFirstName(2, "Kathy"); // Walker matches + assertFirstName(3, "Laura"); // Hall matches + setFilter(""); + assertFirstName(0, "Alice"); + assertFirstName(1, "Bob"); + assertFirstName(2, "Charlie"); + assertFirstName(3, "David"); + + } + + private void setFilter(String string) { + TestBenchElement filterInput = $("input").first(); + filterInput.clear(); + filterInput.sendKeys(string); + filterInput.dispatchEvent("input", Map.of("bubbles", true)); + } + private void refresh() { var refreshButton = $(ButtonElement.class).all().stream() .filter(button -> button.getText().equals("Refresh")) diff --git a/packages/ts/react-crud/src/data-provider.ts b/packages/ts/react-crud/src/data-provider.ts index 9774f33b99..7f04b6a941 100644 --- a/packages/ts/react-crud/src/data-provider.ts +++ b/packages/ts/react-crud/src/data-provider.ts @@ -4,7 +4,7 @@ import type { ComboBoxDataProviderParams, } from '@vaadin/react-components'; import type { GridDataProvider, GridDataProviderCallback, GridDataProviderParams } from '@vaadin/react-components/Grid'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, type DependencyList } from 'react'; import type { CountService, ListService } from './crud'; import type FilterUnion from './types/com/vaadin/hilla/crud/filter/FilterUnion'; import type Pageable from './types/com/vaadin/hilla/mappedtypes/Pageable'; @@ -126,14 +126,14 @@ export abstract class DataProvider { } export abstract class AbstractComboBoxDataProvider { - protected readonly list: ComboBoxFetchCallback; + protected readonly list: ComboBoxFetchMethod; protected readonly loadTotalCount?: boolean; protected sort: Sort | undefined; protected totalCount: number | undefined; protected filteredCount: number | undefined; - constructor(list: ComboBoxFetchCallback, sort: Sort | undefined) { + constructor(list: ComboBoxFetchMethod, sort: Sort | undefined) { this.list = list; this.sort = sort; } @@ -289,15 +289,25 @@ export type UseGridDataProviderResult = GridDataProvider & { refresh(): void; }; -export type GridFetchCallback = (pageable: Pageable) => Promise; - -export function useGridDataProvider(list: GridFetchCallback): UseGridDataProviderResult { +export type GridFetchMethod = (pageable: Pageable) => Promise; + +/** + * Creates a data provider for a grid component that fetches data using the provided fetch callback. + * + * @param fetch - the callback that fetches the data for the grid. The callback should return a promise that resolves to an array of items. + * @param dependencies - A list of all reactive values referenced inside of the fetch callback. A change to any of the listed values will cause the grid to refresh its data. + * @returns a data provider that can be assigned to a component usings its dataProvider property and additionally contains a refresh method that can be called to force a reload of the grid data. + */ +export function useGridDataProvider( + fetch: GridFetchMethod, + dependencies?: DependencyList, +): UseGridDataProviderResult { const result = useDataProvider( useMemo( () => ({ - list: async (pageable: Pageable) => list(pageable), + list: async (pageable: Pageable) => fetch(pageable), }), - [], + dependencies ?? [], ), ); const dataProvider: UseGridDataProviderResult = result.dataProvider as UseGridDataProviderResult; @@ -309,10 +319,10 @@ export type UseComboBoxDataProviderResult = ComboBoxDataProvider & refresh(): void; }; -export type ComboBoxFetchCallback = (pageable: Pageable, filterString: string) => Promise; +export type ComboBoxFetchMethod = (pageable: Pageable, filterString: string) => Promise; function createComboBoxDataProvider( - list: ComboBoxFetchCallback, + list: ComboBoxFetchMethod, sort: Sort | undefined, ): AbstractComboBoxDataProvider { return new InfiniteComboBoxDataProvider(list, sort); @@ -322,12 +332,24 @@ type ComboboxDataProviderOptions = { sort?: Sort; }; +/** + * Creates a data provider for a combo box component that fetches data using the provided fetch callback. + * + * @param fetch - the method that fetches the data for the grid. The method should return a promise that resolves to an array of items. + * @param dependencies - A list of all reactive values referenced inside of the fetch callback. A change to any of the listed values will cause the combo box to refresh its data. + * @returns a data provider that can be assigned to a component usings its dataProvider property and additionally contains a refresh method that can be called to force a reload of the combo box data. + */ export function useComboBoxDataProvider( - list: ComboBoxFetchCallback, + fetch: ComboBoxFetchMethod, options?: ComboboxDataProviderOptions, + dependencies?: DependencyList, ): UseComboBoxDataProviderResult { const [refreshCounter, setRefreshCounter] = useState(0); - const dataProvider = useMemo(() => createComboBoxDataProvider(list, options?.sort), [list, options?.sort]); + + const dataProvider = useMemo( + () => createComboBoxDataProvider(fetch, options?.sort), + [options?.sort, ...(dependencies ?? [])], + ); // Create a new data provider function reference when the refresh counter is incremented. // This effectively forces the combo box to reload diff --git a/packages/ts/react-crud/test/dataprovider.spec.tsx b/packages/ts/react-crud/test/dataprovider.spec.tsx index 843c3405c3..bcb8d2060a 100644 --- a/packages/ts/react-crud/test/dataprovider.spec.tsx +++ b/packages/ts/react-crud/test/dataprovider.spec.tsx @@ -1,6 +1,7 @@ import { cleanup, renderHook } from '@testing-library/react'; import type { ComboBoxDataProvider } from '@vaadin/react-components'; import type { GridDataProvider, GridSorterDefinition } from '@vaadin/react-components/Grid.js'; +import type { DependencyList } from 'react'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { afterEach, beforeEach, chai, describe, expect, it } from 'vitest'; @@ -14,6 +15,8 @@ import { useDataProvider, useGridDataProvider, type GridFetchCallback, + type ComboBoxFetchMethod, + type GridFetchMethod, type ItemCounts, } from '../src/data-provider.js'; import type AndFilter from '../src/types/com/vaadin/hilla/crud/filter/AndFilter.js'; @@ -291,7 +294,7 @@ describe('@hilla/react-crud', () => { it('does not reassign data provider for an inline fetch function', () => { const method1 = async (_pageable: Pageable) => await Promise.resolve([{ id: 1, name: 'Product 1' }]); const method2 = async (_pageable: Pageable) => await Promise.resolve([{ id: 2, name: 'Product 2' }]); - type PropsType = { fetchCallback: GridFetchCallback }; + type PropsType = { fetchCallback: GridFetchMethod }; const hook = renderHook((props: PropsType) => useGridDataProvider(props.fetchCallback), { initialProps: { fetchCallback: method1 }, @@ -302,6 +305,23 @@ describe('@hilla/react-crud', () => { const dataprovider2 = hook.result.current; expect(dataprovider1).to.be.eq(dataprovider2); }); + it('reassigns data provider if dependencies change', () => { + const method1 = async (_pageable: Pageable) => await Promise.resolve([{ id: 1, name: 'Product 1' }]); + const method2 = async (_pageable: Pageable) => await Promise.resolve([{ id: 2, name: 'Product 2' }]); + type PropsType = { + dependencies: DependencyList | undefined; + fetchCallback: GridFetchMethod; + }; + + const hook = renderHook((props: PropsType) => useGridDataProvider(props.fetchCallback, props.dependencies), { + initialProps: { fetchCallback: method1, dependencies: ['first'] }, + }); + + const dataprovider1 = hook.result.current; + hook.rerender({ fetchCallback: method2, dependencies: ['second'] }); + const dataprovider2 = hook.result.current; + expect(dataprovider1).not.to.be.eq(dataprovider2); + }); }); describe('useComboBoxDataProvider', () => { @@ -362,6 +382,44 @@ describe('@hilla/react-crud', () => { expect(result1).to.equal(result2); expect(result2).not.to.equal(result3); }); + it('does not reassign data provider for an inline fetch function', () => { + const method1 = async (_pageable: Pageable, _filter: string) => + await Promise.resolve([{ id: 1, name: 'Product 1' }]); + const method2 = async (_pageable: Pageable, _filter: string) => + await Promise.resolve([{ id: 2, name: 'Product 2' }]); + type PropsType = { fetchCallback: ComboBoxFetchMethod }; + + const hook = renderHook((props: PropsType) => useComboBoxDataProvider(props.fetchCallback), { + initialProps: { fetchCallback: method1 }, + }); + + const dataprovider1 = hook.result.current; + hook.rerender({ fetchCallback: method2 }); + const dataprovider2 = hook.result.current; + expect(dataprovider1).to.be.eq(dataprovider2); + }); + it('reassigns data provider if dependencies change', () => { + const method1 = async (_pageable: Pageable, _filter: string) => + await Promise.resolve([{ id: 1, name: 'Product 1' }]); + const method2 = async (_pageable: Pageable, _filter: string) => + await Promise.resolve([{ id: 2, name: 'Product 2' }]); + type PropsType = { + dependencies: DependencyList | undefined; + fetchCallback: ComboBoxFetchMethod; + }; + + const hook = renderHook( + (props: PropsType) => useComboBoxDataProvider(props.fetchCallback, {}, props.dependencies), + { + initialProps: { fetchCallback: method1, dependencies: ['first'] }, + }, + ); + + const dataprovider1 = hook.result.current; + hook.rerender({ fetchCallback: method2, dependencies: ['second'] }); + const dataprovider2 = hook.result.current; + expect(dataprovider1).not.to.be.eq(dataprovider2); + }); }); describe('createDataProvider', () => {