diff --git a/integration-tests/create-state.test.ts b/integration-tests/create-state.test.ts index fe8c5d9..cf56fa1 100644 --- a/integration-tests/create-state.test.ts +++ b/integration-tests/create-state.test.ts @@ -455,6 +455,94 @@ describe("createState", () => { expect(unmountSpy).toHaveBeenCalledTimes(1); }); + test("useValueIterator does support selector option", async () => { + const user = userEvent.setup(); + type Item = { id: number; text: string }; + const item1: Item = { id: 1, text: "first item" }; + const item2: Item = { id: 2, text: "second item" }; + const item3: Item = { id: 3, text: "third item" }; + const item4: Item = { id: 4, text: "fourth item" }; + const item5: Item = { id: 5, text: "fifth item" }; + const unmountSpy = jest.fn(); + function IteratorComponent() { + const state = createState<{ value: Item[] }>({ + value: [item1, item3, item4], + }); + + return createElement("div", { + children: [ + createElement("button", { + "data-testid": "updateArrayButton", + onClick: () => { + state.setValue(() => ({ value: [item2, item1, item3, item5] })); + }, + children: ["update array values"], + }), + createElement("button", { + "data-testid": "updateFirstItem", + onClick: () => { + state.setValue((currentValues) => ({ + value: currentValues.value.map((value) => { + if (value.id === 1) { + return { + id: 1, + text: "updated first value", + }; + } else { + return value; + } + }), + })); + }, + }), + createElement("ul", { + "data-testid": "listComponent", + children: [ + state.useValueIterator( + { key: "id", selector: (state) => state.value }, + ({ elementState }) => + createElement(() => { + onUnmount(unmountSpy); + return createElement("li", { + children: [ + elementState.useValueSelector( + (element) => element.text, + (text) => createElement("span", { children: text }) + ), + ], + }); + }) + ), + ], + }), + ], + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(IteratorComponent), + }); + + const listElement = screen.getByTestId("listComponent"); + expect(listElement.childNodes.length).toBe(3); + const firstListElement = listElement.childNodes[0]; + const secondListElement = listElement.childNodes[1]; + const thirdListElement = listElement.childNodes[2]; + + expect(firstListElement.textContent).toBe(item1.text); + expect(secondListElement.textContent).toBe(item3.text); + expect(thirdListElement.textContent).toBe(item4.text); + + await user.click(screen.getByTestId("updateFirstItem")); + expect(unmountSpy).not.toHaveBeenCalled(); + expect(listElement.childNodes[0].textContent).toBe("updated first value"); + + await user.click(screen.getByTestId("updateArrayButton")); + expect(listElement.childNodes.length).toBe(4); + expect(unmountSpy).toHaveBeenCalledTimes(1); + }); + test("useAttribute does not re-mount the component", async () => { const user = userEvent.setup(); const spyFn = jest.fn(); diff --git a/src/hooks/create-state.ts b/src/hooks/create-state.ts index 2b14aac..72e5489 100644 --- a/src/hooks/create-state.ts +++ b/src/hooks/create-state.ts @@ -39,12 +39,13 @@ export type State = { useValueIterator( options: { key: string | ((options: { element: Element; index: number }) => string); + selector?: (value: ValueType) => Element[]; }, cb: (props: { elementState: State; index: number; }) => VelesElement | VelesComponent - ): VelesComponent | VelesElement; + ): VelesComponent | VelesElement | null; getValue(): ValueType; getPreviousValue(): undefined | ValueType; setValue(newValueCB: (currentValue: ValueType) => ValueType): void; @@ -59,6 +60,7 @@ type TrackingParams = { elementState: State; index: number; }) => VelesElement | VelesComponent; + selector?: (value: unknown) => any[]; renderedElements: [VelesElement | VelesComponent, string, State][]; key: string | ((options: { element: unknown; index: number }) => string); elementsByKey: { @@ -155,10 +157,10 @@ function createState( }); return node; }, - // TODO: add a version with a selector useValueIterator( options: { key: string | ((options: { element: any; index: number }) => string); + selector?: (value: T) => Element[]; }, cb: (props: { elementState: State; @@ -178,7 +180,14 @@ function createState( }; } = {}; - (value as Element[]).forEach((element, index) => { + const elements = options.selector ? options.selector(value) : value; + + if (!Array.isArray(elements)) { + console.error("useValueIterator received non-array value"); + return null; + } + + (elements as Element[]).forEach((element, index) => { // we calculate a key for each element. This key determines whether we render the element from scratch, or do nothing // when the element updates let calculatedKey: string = ""; @@ -233,6 +242,10 @@ function createState( trackingParams.renderedElements = children; trackingParams.wrapperComponent = wrapperComponent; + if (options.selector) { + trackingParams.selector = options.selector; + } + return wrapperComponent; // 1. build a lookup table with existing values @@ -374,8 +387,14 @@ function createState( }); trackingIterators.forEach((trackingIterator) => { - const { cb, key, renderedElements, elementsByKey, wrapperComponent } = - trackingIterator; + const { + cb, + key, + renderedElements, + elementsByKey, + wrapperComponent, + selector, + } = trackingIterator; if (!wrapperComponent) { console.error("there is no wrapper component for the iterator"); return; @@ -392,10 +411,12 @@ function createState( return; } + const elements = selector ? selector(value) : value; + // if we have any tracking iterators, it means the value is an array // but I don't know how to have correct type inferring here // so we check manually - if (Array.isArray(value)) { + if (Array.isArray(elements)) { const newRenderedElements: [ VelesElement | VelesComponent, string, @@ -413,7 +434,7 @@ function createState( [calculatedKey: string]: boolean; } = {}; - value.forEach((element, index) => { + elements.forEach((element, index) => { let calculatedKey: string = ""; if ( typeof key === "string" &&