diff --git a/packages/modules/data-widgets/package.json b/packages/modules/data-widgets/package.json index c10677f00b..ed159c0573 100644 --- a/packages/modules/data-widgets/package.json +++ b/packages/modules/data-widgets/package.json @@ -1,7 +1,7 @@ { "name": "data-widgets", "moduleName": "Data Widgets", - "version": "2.7.0", + "version": "2.7.1", "license": "Apache-2.0", "copyright": "© Mendix Technology BV 2023. All rights reserved.", "private": true, diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-date-filter-web/CHANGELOG.md index 241d355a3b..d375bcc767 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-date-filter-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue with widget rendering and performance. + +### Breaking changes + +- We introduce a breaking change that affects how widget is reacting on default value changes. Starting with this version, widget use the default value attribute only as an initial value, and any further changes to the default value attribute will be ignored. + ## [2.4.2] - 2022-09-29 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/package.json b/packages/pluggableWidgets/datagrid-date-filter-web/package.json index 946d9c1079..f35edb2214 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-date-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "datagrid-date-filter-web", "widgetName": "DatagridDateFilter", - "version": "2.4.2", + "version": "2.5.0", "description": "", "copyright": "© Mendix Technology BV 2023. All rights reserved.", "private": true, diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/DatagridDateFilter.tsx b/packages/pluggableWidgets/datagrid-date-filter-web/src/DatagridDateFilter.tsx index cf1a841943..9fd87139d7 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/DatagridDateFilter.tsx +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/DatagridDateFilter.tsx @@ -97,6 +97,10 @@ export default function DatagridDateFilter(props: DatagridDateFilterContainerPro return {errorMessage}; } + if (isLoadingDefaultValues(props)) { + return null; + } + return ( status === "loading"); +} diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/FilterComponent.tsx b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/FilterComponent.tsx index 140986f512..0f31b62840 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/FilterComponent.tsx +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/FilterComponent.tsx @@ -33,22 +33,6 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { const [rangeValues, setRangeValues] = useState([props.defaultStartDate, props.defaultEndDate]); const pickerRef = useRef(null); - useEffect(() => { - setValue(prev => { - if (prev?.toISOString() === props.defaultValue?.toISOString()) { - return prev; - } - - return props.defaultValue; - }); - }, [props.defaultValue]); - - useEffect(() => { - if (props.defaultStartDate || props.defaultEndDate) { - setRangeValues([props.defaultStartDate, props.defaultEndDate]); - } - }, [props.defaultStartDate, props.defaultEndDate]); - useEffect(() => { props.updateFilters?.(value, rangeValues, type); }, [value, rangeValues, type]); diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx index 0854b61b31..edce9750f4 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/DatagridDateFilter.spec.tsx @@ -102,17 +102,17 @@ describe("Date Filter", () => { expect(screen.getByRole("textbox")).toHaveValue("01/01/2000"); }); - it("sync value when defaultValue changes from undefined to date", async () => { + it("don't sync value when defaultValue changes from undefined to date", async () => { // 946684800000 = 01.01.2000 const date = new Date(946684800000); const { rerender } = render(); expect(screen.getByRole("textbox")).toHaveValue(""); rerender((date)} />); - expect(screen.getByRole("textbox")).toHaveValue("01/01/2000"); + expect(screen.getByRole("textbox")).toHaveValue(""); }); - it("sync value when defaultValue changes from date to undefined", async () => { + it("don't sync value when defaultValue changes from date to undefined", async () => { // 946684800000 = 01.01.2000 const date = new Date(946684800000); const { rerender } = render( @@ -121,7 +121,7 @@ describe("Date Filter", () => { expect(screen.getByRole("textbox")).toHaveValue("01/01/2000"); rerender(); - expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.getByRole("textbox")).toHaveValue("01/01/2000"); }); }); diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/FilterComponent.spec.tsx b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/FilterComponent.spec.tsx index bcc4720191..01801fb67e 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/FilterComponent.spec.tsx +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/components/__tests__/FilterComponent.spec.tsx @@ -40,7 +40,7 @@ describe("Filter component", () => { }); describe("with defaultValue", () => { - it("call updateFilters when defaultValue get new value", () => { + it("don't call updateFilters when defaultValue get new value", () => { const date = new Date(946684800000); const updateFilters = jest.fn(); const { rerender } = render_fromTestingLibrary( @@ -49,7 +49,7 @@ describe("Filter component", () => { // First time updateFilters is called on initial mount expect(updateFilters).toBeCalledTimes(1); - expect(updateFilters.mock.calls[0][0]).toBe(date); + expect(updateFilters).toHaveBeenLastCalledWith(date, [undefined, undefined], "equal"); const nextValue = new Date(999999900000); @@ -62,8 +62,8 @@ describe("Filter component", () => { /> ); - expect(updateFilters).toBeCalledTimes(2); - expect(updateFilters.mock.calls[1][0]).toBe(nextValue); + expect(updateFilters).toBeCalledTimes(1); + expect(updateFilters).toHaveBeenLastCalledWith(date, [undefined, undefined], "equal"); }); it("don't call updateFilters when defaultValue get same value", () => { diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml index 051d75e6df..6cbad36fa9 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-dropdown-filter-web/CHANGELOG.md index 6f40443c53..313669a1b1 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue with widget rendering and performance. + +### Breaking changes + +- We introduce a breaking change that affects how widget is reacting on default value changes. Starting with this version, widget use the default value attribute only as an initial value, and any further changes to the default value attribute will be ignored. + ## [2.3.0] - 2023-02-17 ### Changed diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json index 7e3ac3c1f8..3a1a76d60b 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "datagrid-dropdown-filter-web", "widgetName": "DatagridDropdownFilter", - "version": "2.3.0", + "version": "2.4.0", "description": "", "copyright": "© Mendix Technology BV 2023. All rights reserved.", "private": true, diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/DatagridDropdownFilter.tsx b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/DatagridDropdownFilter.tsx index 7c3ff001a3..e064588db9 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/DatagridDropdownFilter.tsx +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/DatagridDropdownFilter.tsx @@ -1,5 +1,5 @@ import { useFilterContextValue } from "@mendix/pluggable-widgets-commons/components/web"; -import { createElement, ReactElement } from "react"; +import { createElement, Fragment, ReactElement } from "react"; import { DatagridDropdownFilterContainerProps } from "../typings/DatagridDropdownFilterProps"; import { EnumerationFilter } from "./components/EnumerationFilter"; import { ErrorBox } from "./components/ErrorBox"; @@ -13,6 +13,10 @@ export default function DatagridDropdownFilter(props: DatagridDropdownFilterCont return ; } + if (props.defaultValue?.status === "loading") { + return ; + } + const Filter = context.value.associationProperties ? AssociationFilter : EnumerationFilter; return ; diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/FilterComponent.tsx b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/FilterComponent.tsx index d4170dd423..43c0e752a0 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/FilterComponent.tsx +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/FilterComponent.tsx @@ -64,6 +64,7 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { const [selectedFilters, setSelectedFilters] = useState([]); const [show, setShow] = useState(false); const [dropdownWidth, setDropdownWidth] = useState(0); + const { current: initialFilterValue } = useRef(defaultValue); const defaultValuesLoaded = useRef(false); const componentRef = useRef(null); @@ -108,8 +109,8 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { useEffect(() => { if (!defaultValuesLoaded.current && options.length > 0) { if (multiSelect) { - if (defaultValue) { - const initialOptions = defaultValue + if (initialFilterValue) { + const initialOptions = initialFilterValue .split(",") .map(value => options.find(option => option.value === value)) .filter(Boolean) as FilterOption[]; @@ -121,7 +122,7 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { } } else { // We want to add empty option caption - const initialOption = options.find(option => option.value === defaultValue) ?? options[0]; + const initialOption = options.find(option => option.value === initialFilterValue) ?? options[0]; setValueInput(initialOption?.caption ?? ""); setSelectedFilters(prev => { @@ -134,7 +135,7 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { } defaultValuesLoaded.current = true; } - }, [defaultValue, emptyOptionCaption, multiSelect, options, setMultiSelectFilters]); + }, [initialFilterValue, emptyOptionCaption, multiSelect, options, setMultiSelectFilters]); useEffect(() => { const emptyOption = multiSelect @@ -155,7 +156,7 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { // Resets the option to reload default values defaultValuesLoaded.current = false; - }, [emptyOptionCaption, multiSelect, optionsProp, defaultValue]); + }, [emptyOptionCaption, multiSelect, optionsProp, initialFilterValue]); // This side effect meant to sync filter value with parents // But, because updateFilters is might be "unstable" function diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/__tests__/DataGridDropdownFilter.spec.tsx b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/__tests__/DataGridDropdownFilter.spec.tsx index 3842fecdf2..5657ff3d15 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/__tests__/DataGridDropdownFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/components/__tests__/DataGridDropdownFilter.spec.tsx @@ -109,7 +109,7 @@ describe("Dropdown Filter", () => { expect(screen.getByRole("textbox")).toHaveValue("enum_value_1"); }); - it("sync defaultValue with state when defaultValue changes from undefined to string", async () => { + it("don't sync defaultValue with state when defaultValue changes from undefined to string", async () => { const { rerender } = render( { ); await waitFor(() => { - expect(screen.getByRole("textbox")).toHaveValue("enum_value_1"); + expect(screen.getByRole("textbox")).toHaveValue(""); }); }); - it("sync defaultValue with state when defaultValue changes from string to undefined", async () => { + it("don't sync defaultValue with state when defaultValue changes from string to undefined", async () => { mockCtx(["xyz", "abc"]); const { rerender } = render( { ); await waitFor(() => { - expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.getByRole("textbox")).toHaveValue("xyz"); }); }); }); diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml index 6f14d7639d..868bb75d88 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-number-filter-web/CHANGELOG.md index 7c6bf44115..d1d70bda07 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-number-filter-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue with widget rendering and performance. + +### Breaking changes + +- We introduce a breaking change that affects how widget is reacting on default value changes. Starting with this version, widget use the default value attribute only as an initial value, and any further changes to the default value attribute will be ignored. + ## [2.3.1] - 2022-08-11 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/package.json b/packages/pluggableWidgets/datagrid-number-filter-web/package.json index d041b06257..8bc4c797b1 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-number-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "datagrid-number-filter-web", "widgetName": "DatagridNumberFilter", - "version": "2.3.1", + "version": "2.4.0", "description": "", "copyright": "© Mendix Technology BV 2023. All rights reserved.", "private": true, diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/DatagridNumberFilter.editorPreview.tsx b/packages/pluggableWidgets/datagrid-number-filter-web/src/DatagridNumberFilter.editorPreview.tsx index 86ab79fc27..47ab9a6c63 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/DatagridNumberFilter.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/DatagridNumberFilter.editorPreview.tsx @@ -8,8 +8,8 @@ export function preview(props: DatagridNumberFilterPreviewProps): ReactElement { {errorMessage}; } + if (props.defaultValue?.status === "loading") { + return null; + } + return ( { - if ( - (value && !props.valueAttribute?.value?.eq(value)) || - value !== props.valueAttribute?.value - ) { - props.valueAttribute?.setValue(value); - props.onChange?.execute(); - } + props.valueAttribute?.setValue(value); + props.onChange?.execute(); const conditions = attributes ?.map(attribute => getFilterCondition(attribute, value, type)) .filter((filter): filter is FilterCondition => filter !== undefined); @@ -104,7 +103,6 @@ export default function DatagridNumberFilter(props: DatagridNumberFilterContaine filterType: FilterType.NUMBER }); }} - value={defaultFilter?.value ?? props.defaultValue?.value} /> ); }} diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/FilterComponent.tsx b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/FilterComponent.tsx index c2a2490a20..4087f42f19 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/FilterComponent.tsx +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/FilterComponent.tsx @@ -1,51 +1,39 @@ -import { createElement, CSSProperties, ReactElement, useCallback, useEffect, useRef, useState } from "react"; +import { ChangeEventHandler, createElement, CSSProperties, ReactElement, useRef, memo } from "react"; import { FilterSelector } from "@mendix/pluggable-widgets-commons/components/web"; -import { debounce } from "@mendix/pluggable-widgets-commons"; - -import { DefaultFilterEnum } from "../../typings/DatagridNumberFilterProps"; +import { FilterType } from "../../typings/FilterType"; import { Big } from "big.js"; import classNames from "classnames"; +import { useFilterState, useStateChangeEffects } from "../features/filter-state"; +import { toInputValue } from "../utils/value"; -interface FilterComponentProps { +interface FilterProps { adjustable: boolean; + initialFilterType: FilterType; className?: string; - defaultFilter: DefaultFilterEnum; - delay: number; id?: string; placeholder?: string; screenReaderButtonCaption?: string; screenReaderInputCaption?: string; tabIndex?: number; styles?: CSSProperties; - updateFilters?: (value: Big | undefined, type: DefaultFilterEnum) => void; - value?: Big; } -export function FilterComponent(props: FilterComponentProps): ReactElement { - const [type, setType] = useState(props.defaultFilter); - const [value, setValue] = useState(undefined); - const [valueInput, setValueInput] = useState(undefined); - const inputRef = useRef(null); - - useEffect(() => { - setValueInput(props.value?.toString() ?? ""); - setValue(props.value); - }, [props.value]); - - useEffect(() => { - props.updateFilters?.(value, type); - }, [value, type]); +interface FilterComponentProps extends FilterProps { + inputChangeDelay: number; + initialFilterValue?: Big; + updateFilters?: (value: Big | undefined, type: FilterType) => void; +} - const onChange = useCallback( - debounce((value?: Big) => setValue(value), props.delay), - [props.delay] - ); +interface FilterInputProps extends FilterProps { + onFilterTypeClick: (type: FilterType) => void; + onInputChange: ChangeEventHandler; + inputValue: string; + inputRef?: React.ClassAttributes["ref"]; + inputDisabled?: boolean; +} - const focusInput = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); +function FilterInput(props: FilterInputProps): ReactElement { + const { current: initialFilterType } = useRef(props.initialFilterType); return (
{ - setType(prev => { - if (prev === type) { - return prev; - } - focusInput(); - return type; - }); - }, - [focusInput] - )} + defaultFilter={initialFilterType} + onChange={props.onFilterTypeClick} options={ [ { value: "greater", label: "Greater than" }, @@ -80,29 +57,49 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { { value: "smallerEqual", label: "Smaller than or equal" }, { value: "empty", label: "Empty" }, { value: "notEmpty", label: "Not empty" } - ] as Array<{ value: DefaultFilterEnum; label: string }> + ] as Array<{ value: FilterType; label: string }> } /> )} { - const value = e.target.value; - if (value && !isNaN(Number(value))) { - setValueInput(value); - onChange(new Big(Number(value))); - } else { - setValueInput(value); - onChange(undefined); - } - }} + disabled={props.inputDisabled} + onChange={props.onInputChange} placeholder={props.placeholder} - ref={inputRef} + ref={props.inputRef} type="number" - value={valueInput} + value={props.inputValue} />
); } + +const PureFilterInput = memo(FilterInput); + +export function FilterComponent(props: FilterComponentProps): ReactElement { + const [state, onInputChange, onFilterTypeClick] = useFilterState(() => ({ + inputValue: toInputValue(props.initialFilterValue), + type: props.initialFilterType + })); + const [inputRef] = useStateChangeEffects(state, (a, b) => props.updateFilters?.(a, b), props.inputChangeDelay); + + return ( + + ); +} diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx index 2a96f8487f..3c3ba2bbd8 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/DatagridNumberFilter.spec.tsx @@ -63,20 +63,20 @@ describe("Number Filter", () => { render((new Big(100))} />); expect(screen.getByRole("spinbutton")).toHaveValue(100); }); - it("sync value and defaultValue when defaultValue changes from undefined to number", () => { + it("do not sync value and defaultValue when defaultValue changes from undefined to number", () => { const { rerender } = render(); expect(screen.getByRole("spinbutton")).toHaveValue(null); rerender((new Big(100))} />); - expect(screen.getByRole("spinbutton")).toHaveValue(100); + expect(screen.getByRole("spinbutton")).toHaveValue(null); }); - it("sync value and defaultValue when defaultValue changes from number to undefined", async () => { + it("do not sync value and defaultValue when defaultValue changes from number to undefined", async () => { const { rerender } = render( (new Big(100))} /> ); expect(screen.getByRole("spinbutton")).toHaveValue(100); rerender(); await waitFor(() => { - expect(screen.getByRole("spinbutton")).toHaveValue(null); + expect(screen.getByRole("spinbutton")).toHaveValue(100); }); }); }); diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/FilterComponent.spec.tsx b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/FilterComponent.spec.tsx index d5eeb90d27..58b55ea58f 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/FilterComponent.spec.tsx +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/components/__tests__/FilterComponent.spec.tsx @@ -1,4 +1,4 @@ -import { render, shallow } from "enzyme"; +import { render, mount } from "enzyme"; import { createElement } from "react"; import { FilterComponent } from "../FilterComponent"; @@ -6,13 +6,15 @@ jest.useFakeTimers(); describe("Filter component", () => { it("renders correctly", () => { - const component = render(); + const component = render(); expect(component).toMatchSnapshot(); }); it("renders correctly when not adjustable by user", () => { - const component = render(); + const component = render( + + ); expect(component).toMatchSnapshot(); }); @@ -21,8 +23,8 @@ describe("Filter component", () => { const component = render( @@ -33,24 +35,35 @@ describe("Filter component", () => { it("calls updateFilters when value changes", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); const input = component.find("input"); input.simulate("change", { target: { value: "test" } }); + jest.advanceTimersByTime(500); + expect(updateFiltersHandler).toBeCalled(); }); it("debounces calls for updateFilters when value changes with numbers", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); - // Initial call with default filter - expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toBeCalledTimes(0); const input = component.find("input"); input.simulate("change", { target: { value: "0" } }); @@ -59,22 +72,26 @@ describe("Filter component", () => { input.simulate("change", { target: { value: "2" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(2); + expect(updateFiltersHandler).toBeCalledTimes(1); input.simulate("change", { target: { value: "3" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(3); + expect(updateFiltersHandler).toBeCalledTimes(2); }); it("debounces calls for updateFilters when value changes with decimals", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); - // Initial call with default filter - expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toBeCalledTimes(0); const input = component.find("input"); input.simulate("change", { target: { value: "0.0" } }); @@ -83,22 +100,26 @@ describe("Filter component", () => { input.simulate("change", { target: { value: "4" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(2); + expect(updateFiltersHandler).toBeCalledTimes(1); input.simulate("change", { target: { value: "6.8" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(3); + expect(updateFiltersHandler).toBeCalledTimes(2); }); it("debounces calls for updateFilters when value changes with invalid input", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); - // Initial call with default filter - expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toBeCalledTimes(0); const input = component.find("input"); input.simulate("change", { target: { value: "test1" } }); @@ -107,13 +128,13 @@ describe("Filter component", () => { input.simulate("change", { target: { value: "test3" } }); jest.advanceTimersByTime(500); - // Consecutive invalid numbers wont call useState with empty value twice - // this is why we expect func to be called 1 time expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toHaveBeenLastCalledWith(undefined, "equal"); input.simulate("change", { target: { value: "test4" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toBeCalledTimes(2); + expect(updateFiltersHandler).toHaveBeenLastCalledWith(undefined, "equal"); }); }); diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/features/filter-state.ts b/packages/pluggableWidgets/datagrid-number-filter-web/src/features/filter-state.ts new file mode 100644 index 0000000000..b805796935 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/features/filter-state.ts @@ -0,0 +1,57 @@ +import { ChangeEventHandler, useState, useMemo, useRef, useEffect, MutableRefObject } from "react"; +import { FilterType } from "../../typings/FilterType"; +import { Big } from "big.js"; +import { debounce, useEventCallback } from "@mendix/pluggable-widgets-commons"; +import { toBig } from "../utils/value"; + +type FilterState = { + type: FilterType; + inputValue: string; +}; + +type InputChangeHandler = ChangeEventHandler; + +type TypeClickHandler = (type: FilterType) => void; + +function updateState(key: K, value: V): (prev: S) => S { + return prev => (prev[key] !== value ? { ...prev, [key]: value } : prev); +} + +export function useFilterState(initialState: () => FilterState): [FilterState, InputChangeHandler, TypeClickHandler] { + const [state, setState] = useState(initialState); + const [onInputChange, onTypeClick] = useMemo(() => { + const inputHandler: InputChangeHandler = event => setState(updateState("inputValue", event.target.value)); + const clickHandler: TypeClickHandler = type => setState(updateState("type", type)); + + return [inputHandler, clickHandler]; + }, []); + + return [state, onInputChange, onTypeClick]; +} + +type ChangeDispatch = (value: Big | undefined, type: FilterType) => void; + +export function useStateChangeEffects( + state: FilterState, + dispatch: ChangeDispatch, + inputChangeDelay: number +): [MutableRefObject] { + const stableDispatch = useEventCallback(dispatch); + const [stableDispatchDelayed] = useState(() => debounce(stableDispatch, inputChangeDelay)); + const inputRef = useRef(null); + const prevStateRef = useRef(state); + + useEffect(() => { + const { current: prevState } = prevStateRef; + if (state.type !== prevState.type) { + stableDispatch(toBig(state.inputValue), state.type); + inputRef.current?.focus(); + } else if (state.inputValue !== prevState.inputValue) { + stableDispatchDelayed(toBig(state.inputValue), state.type); + } + prevStateRef.current = state; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return [inputRef]; +} diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml index 5d86993529..8dc3946227 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/utils/value.ts b/packages/pluggableWidgets/datagrid-number-filter-web/src/utils/value.ts new file mode 100644 index 0000000000..bf9a2bb1f2 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/utils/value.ts @@ -0,0 +1,13 @@ +import { Big } from "big.js"; + +export function toBig(value: any): Big | undefined { + try { + return new Big(value); + } catch { + return undefined; + } +} + +export function toInputValue(value: Big | undefined): string { + return value instanceof Big ? value.toString() : ""; +} diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/typings/FilterType.d.ts b/packages/pluggableWidgets/datagrid-number-filter-web/typings/FilterType.d.ts new file mode 100644 index 0000000000..eb9fcd0c59 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-number-filter-web/typings/FilterType.d.ts @@ -0,0 +1 @@ +export { DefaultFilterEnum as FilterType } from "./DatagridNumberFilterProps"; diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md index 0f2c3a386a..3ddcf4fe54 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-text-filter-web/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue with widget rendering and performance. + +### Breaking changes + +- We introduce a breaking change that affects how widget is reacting on default value changes. Starting with this version, widget use the default value attribute only as an initial value, and any further changes to the default value attribute will be ignored. + ## [2.3.2] - 2022-08-11 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/package.json b/packages/pluggableWidgets/datagrid-text-filter-web/package.json index 8fabb2faaf..30e999840c 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-text-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "datagrid-text-filter-web", "widgetName": "DatagridTextFilter", - "version": "2.3.2", + "version": "2.4.0", "description": "", "copyright": "© Mendix Technology BV 2023. All rights reserved.", "private": true, diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.editorPreview.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.editorPreview.tsx index 471fe5224b..af61bfa56d 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.editorPreview.tsx @@ -8,13 +8,13 @@ export function preview(props: DatagridTextFilterPreviewProps): ReactElement { ); } diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.tsx index 17db93ae59..133d354a46 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/DatagridTextFilter.tsx @@ -77,12 +77,17 @@ export default function DatagridTextFilter(props: DatagridTextFilterContainerPro return {errorMessage}; } + if (props.defaultValue?.status === "loading") { + return null; + } + return ( { - const attributeCurrentValue = props.valueAttribute?.value || ""; - if (value !== attributeCurrentValue) { - props.valueAttribute?.setValue(value); - props.onChange?.execute(); - } + props.valueAttribute?.setValue(value); + props.onChange?.execute(); const conditions = attributes ?.map(attribute => getFilterCondition(attribute, value, type)) .filter((filter): filter is FilterCondition => filter !== undefined); @@ -104,7 +106,6 @@ export default function DatagridTextFilter(props: DatagridTextFilterContainerPro filterType: FilterType.STRING }); }} - value={defaultFilter?.value ?? props.defaultValue?.value} /> ); }} diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/FilterComponent.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/FilterComponent.tsx index b4c4f28604..6f545a1eb0 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/FilterComponent.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/FilterComponent.tsx @@ -1,50 +1,37 @@ -import { createElement, CSSProperties, ReactElement, useCallback, useEffect, useRef, useState } from "react"; +import { createElement, CSSProperties, ReactElement, memo, useRef, ChangeEventHandler } from "react"; import { FilterSelector } from "@mendix/pluggable-widgets-commons/components/web"; -import { debounce } from "@mendix/pluggable-widgets-commons"; - -import { DefaultFilterEnum } from "../../typings/DatagridTextFilterProps"; +import { useFilterState, useStateChangeEffects } from "../features/filter-state"; +import { FilterType } from "../../typings/FilterType"; import classNames from "classnames"; -interface FilterComponentProps { +interface FilterProps { adjustable: boolean; + initialFilterType: FilterType; className?: string; - defaultFilter: DefaultFilterEnum; - delay: number; id?: string; placeholder?: string; - tabIndex?: number; screenReaderButtonCaption?: string; screenReaderInputCaption?: string; + tabIndex?: number; styles?: CSSProperties; - updateFilters?: (value: string, type: DefaultFilterEnum) => void; - value?: string; } -export function FilterComponent(props: FilterComponentProps): ReactElement { - const [type, setType] = useState(props.defaultFilter); - const [value, setValue] = useState(""); - const [valueInput, setValueInput] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - setValueInput(props.value ?? ""); - setValue(props.value ?? ""); - }, [props.value]); - - useEffect(() => { - props.updateFilters?.(value, type); - }, [value, type]); +interface FilterComponentProps extends FilterProps { + inputChangeDelay: number; + initialFilterValue?: string; + updateFilters?: (value: string | undefined, type: FilterType) => void; +} - const onChange = useCallback( - debounce((value: string) => setValue(value), props.delay), - [props.delay] - ); +interface FilterInputProps extends FilterProps { + onFilterTypeClick: (type: FilterType) => void; + onInputChange: ChangeEventHandler; + inputValue: string; + inputRef?: React.ClassAttributes["ref"]; + inputDisabled?: boolean; +} - const focusInput = useCallback(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, [inputRef]); +function FilterInput(props: FilterInputProps): ReactElement { + const { current: initialFilterType } = useRef(props.initialFilterType); return (
{ - setType(prev => { - if (prev === type) { - return prev; - } - focusInput(); - return type; - }); - }, - [focusInput] - )} + defaultFilter={initialFilterType} + onChange={props.onFilterTypeClick} options={ [ { value: "contains", label: "Contains" }, @@ -82,23 +58,49 @@ export function FilterComponent(props: FilterComponentProps): ReactElement { { value: "smallerEqual", label: "Smaller than or equal" }, { value: "empty", label: "Empty" }, { value: "notEmpty", label: "Not empty" } - ] as Array<{ value: DefaultFilterEnum; label: string }> + ] as Array<{ value: FilterType; label: string }> } /> )} { - setValueInput(e.target.value); - onChange(e.target.value); - }} + disabled={props.inputDisabled} + onChange={props.onInputChange} placeholder={props.placeholder} - ref={inputRef} + ref={props.inputRef} type="text" - value={valueInput} + value={props.inputValue} />
); } + +const PureFilterInput = memo(FilterInput); + +export function FilterComponent(props: FilterComponentProps): ReactElement { + const [state, onInputChange, onFilterTypeClick] = useFilterState(() => ({ + inputValue: props.initialFilterValue ?? "", + type: props.initialFilterType + })); + const [inputRef] = useStateChangeEffects(state, (a, b) => props.updateFilters?.(a, b), props.inputChangeDelay); + + return ( + + ); +} diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx index 6003901116..54ae5140e8 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/DatagridTextFilter.spec.tsx @@ -30,7 +30,7 @@ describe("Text Filter", () => { delete (global as any)["com.mendix.widgets.web.UUID"]; }); - describe("sync input value with defaultValue prop", () => { + describe("with defaultValue prop", () => { beforeAll(() => { (window as any)["com.mendix.widgets.web.filterable.filterContext"] = createContext({ filterDispatcher: jest.fn(), @@ -38,7 +38,7 @@ describe("Text Filter", () => { } as FilterContextValue); }); - it("sync value when defaultValue changes from undefined to string", async () => { + it("don't sync value when defaultValue changes from undefined to string", async () => { const { rerender } = render(); expect(screen.getByRole("textbox")).toHaveValue(""); @@ -46,10 +46,10 @@ describe("Text Filter", () => { // rerender component with new `defaultValue` const defaultValue = dynamicValue("xyz"); rerender(); - expect(screen.getByRole("textbox")).toHaveValue("xyz"); + expect(screen.getByRole("textbox")).toHaveValue(""); }); - it("sync value when defaultValue changes from string to string", async () => { + it("don't sync value when defaultValue changes from string to string", async () => { const { rerender } = render( ("abc")} /> ); @@ -59,10 +59,10 @@ describe("Text Filter", () => { // rerender component with new `defaultValue` const defaultValue = dynamicValue("xyz"); rerender(); - expect(screen.getByRole("textbox")).toHaveValue("xyz"); + expect(screen.getByRole("textbox")).toHaveValue("abc"); }); - it("sync value when defaultValue changes from string to undefined", async () => { + it("don't sync value when defaultValue changes from string to undefined", async () => { const { rerender } = render( ("abc")} /> ); @@ -71,7 +71,7 @@ describe("Text Filter", () => { // rerender component with new `defaultValue` rerender(); - expect(screen.getByRole("textbox")).toHaveValue(""); + expect(screen.getByRole("textbox")).toHaveValue("abc"); }); }); diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/FilterComponent.spec.tsx b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/FilterComponent.spec.tsx index b8d5a31fb6..a37bb67220 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/FilterComponent.spec.tsx +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/components/__tests__/FilterComponent.spec.tsx @@ -1,4 +1,4 @@ -import { render, shallow } from "enzyme"; +import { render, mount } from "enzyme"; import { createElement } from "react"; import { FilterComponent } from "../FilterComponent"; @@ -6,13 +6,15 @@ jest.useFakeTimers(); describe("Filter component", () => { it("renders correctly", () => { - const component = render(); + const component = render(); expect(component).toMatchSnapshot(); }); it("renders correctly when not adjustable by user", () => { - const component = render(); + const component = render( + + ); expect(component).toMatchSnapshot(); }); @@ -23,8 +25,8 @@ describe("Filter component", () => { adjustable screenReaderButtonCaption="my label" screenReaderInputCaption="my label" - defaultFilter="contains" - delay={500} + initialFilterType="contains" + inputChangeDelay={500} /> ); @@ -33,37 +35,50 @@ describe("Filter component", () => { it("calls updateFilters when value changes", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); const input = component.find("input"); input.simulate("change", { target: { value: "test" } }); - - expect(updateFiltersHandler).toBeCalled(); + expect(updateFiltersHandler).toBeCalledTimes(0); + jest.advanceTimersByTime(500); + expect(updateFiltersHandler).toBeCalledTimes(1); }); it("debounces calls for updateFilters when value changes", () => { const updateFiltersHandler = jest.fn(); - const component = shallow( - + const component = mount( + ); - // Initial call with default filter - expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toBeCalledTimes(0); const input = component.find("input"); input.simulate("change", { target: { value: "test" } }); jest.advanceTimersByTime(499); + expect(updateFiltersHandler).toBeCalledTimes(0); + input.simulate("change", { target: { value: "test2" } }); input.simulate("change", { target: { value: "test3" } }); jest.advanceTimersByTime(500); - - expect(updateFiltersHandler).toBeCalledTimes(2); + expect(updateFiltersHandler).toBeCalledTimes(1); + expect(updateFiltersHandler).toHaveBeenCalledWith("test3", "contains"); input.simulate("change", { target: { value: "test" } }); jest.advanceTimersByTime(500); - expect(updateFiltersHandler).toBeCalledTimes(3); + expect(updateFiltersHandler).toBeCalledTimes(2); + expect(updateFiltersHandler).toHaveBeenCalledWith("test", "contains"); }); }); diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/features/filter-state.ts b/packages/pluggableWidgets/datagrid-text-filter-web/src/features/filter-state.ts new file mode 100644 index 0000000000..288864f2d6 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/features/filter-state.ts @@ -0,0 +1,55 @@ +import { ChangeEventHandler, useState, useMemo, useRef, useEffect, MutableRefObject } from "react"; +import { FilterType } from "../../typings/FilterType"; +import { debounce, useEventCallback } from "@mendix/pluggable-widgets-commons"; + +type FilterState = { + type: FilterType; + inputValue: string; +}; + +type InputChangeHandler = ChangeEventHandler; + +type TypeClickHandler = (type: FilterType) => void; + +function updateState(key: K, value: V): (prev: S) => S { + return prev => (prev[key] !== value ? { ...prev, [key]: value } : prev); +} + +export function useFilterState(initialState: () => FilterState): [FilterState, InputChangeHandler, TypeClickHandler] { + const [state, setState] = useState(initialState); + const [onInputChange, onTypeClick] = useMemo(() => { + const inputHandler: InputChangeHandler = event => setState(updateState("inputValue", event.target.value)); + const clickHandler: TypeClickHandler = type => setState(updateState("type", type)); + + return [inputHandler, clickHandler]; + }, []); + + return [state, onInputChange, onTypeClick]; +} + +type ChangeDispatch = (value: string | undefined, type: FilterType) => void; + +export function useStateChangeEffects( + state: FilterState, + dispatch: ChangeDispatch, + inputChangeDelay: number +): [MutableRefObject] { + const stableDispatch = useEventCallback(dispatch); + const [stableDispatchDelayed] = useState(() => debounce(stableDispatch, inputChangeDelay)); + const inputRef = useRef(null); + const prevStateRef = useRef(state); + + useEffect(() => { + const { current: prevState } = prevStateRef; + if (state.type !== prevState.type) { + stableDispatch(state.inputValue, state.type); + inputRef.current?.focus(); + } else if (state.inputValue !== prevState.inputValue) { + stableDispatchDelayed(state.inputValue, state.type); + } + prevStateRef.current = state; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return [inputRef]; +} diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml index 85477b056b..f08bb6cc35 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/typings/FilterType.d.ts b/packages/pluggableWidgets/datagrid-text-filter-web/typings/FilterType.d.ts new file mode 100644 index 0000000000..4005087572 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-text-filter-web/typings/FilterType.d.ts @@ -0,0 +1 @@ +export { DefaultFilterEnum as FilterType } from "./DatagridTextFilterProps"; \ No newline at end of file diff --git a/packages/shared/pluggable-widgets-commons/src/functions.ts b/packages/shared/pluggable-widgets-commons/src/functions.ts index 3d68e89a1b..a5ad4d3e66 100644 --- a/packages/shared/pluggable-widgets-commons/src/functions.ts +++ b/packages/shared/pluggable-widgets-commons/src/functions.ts @@ -1,3 +1,4 @@ +import { useState } from "react"; import { ActionValue, DynamicValue, EditableValue, ValueStatus } from "mendix"; export const executeAction = (action?: ActionValue): void => { @@ -38,3 +39,55 @@ export const debounce = any>(func: F, waitFor: nu return debounced as F; }; + +export function useId(name?: string): string { + const [id] = useState(() => { + const num = Math.random().toFixed(9).slice(2); + return name ? `${name}-${num}` : num; + }); + + return id; +} + +const debugMsg = (...args: any[]): void => console.debug("[DEBUG]", ...args); + +function debugHeader(id: string): void { + debugMsg(); + debugMsg(`Component:`, id); +} + +function createInspect(id: string): (props: any) => void { + let prevProps: any = {}; + return (currentProps: any) => { + debugHeader(id); + const keys = new Set([...Object.keys(prevProps), ...Object.keys(currentProps)]); + let changed = false; + for (const k of keys) { + if (prevProps[k] !== currentProps[k]) { + debugMsg(` > prop [${k}] changed`); + changed = true; + } + } + if (!changed) { + debugMsg(" > No prop changes"); + } + prevProps = currentProps; + }; +} + +export function usePropInspect(id: string): (props: any) => void { + const [inspect] = useState(() => createInspect(id)); + return inspect; +} + +function createLog(id: string): (...args: string[]) => void { + return (...args: string[]) => { + debugHeader(id); + debugMsg(" >", ...args); + }; +} + +export function useLog(id: string): (...args: string[]) => void { + const [log] = useState(() => createLog(id)); + return log; +}