diff --git a/readme.md b/readme.md index c52b2d7adc8dbb2f8b06030b1c9a7364c28affd9..a7fd56e5a5ed368d57745c4977579763e21da200 100644 --- a/readme.md +++ b/readme.md @@ -191,6 +191,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list - `arrayRemove` - remove items by a `filter` function - `arrayFilter` - preserve items by a `filter` function - `arrayUpdate` - update items by a `filter` + `update` function + - `arrayUpsert` - update items by a `filter`, or insert when there is no match - `arrayInsertIndex` - insert an item at an index - Queries that are complex objects. Each function takes a type-safe object path to evaluate. @@ -204,6 +205,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list - `objArrayRemove` - remove items from array by `filter` - `objArrayFilter` - preserve items from array by `filter` - `objArrayUpdate` - update items in array by `filter` + `update` + - `objArrayUpsert` - update items in array by `filter`, or insert when there is no match - `objArrayInsertIndex` - insert an item in an array at an index ## Debouncing diff --git a/src/tanstack-query.ts b/src/tanstack-query.ts index fd1f1a91a5845f211a9e8f2d62fa8e3351460fda..36200b4e7e9555ab4152354c7bb5f6de395b827b 100644 --- a/src/tanstack-query.ts +++ b/src/tanstack-query.ts @@ -7,6 +7,10 @@ export type QueryKeyAndFn = { queryFn?: QueryFunction | undefined; }; +type RequireAtLeastOneKey = { + [K in keyof T]-?: Required> & Partial>; +}[keyof T]; + export function boundQueryClientGet( queryClient: QueryClient, ): ({ queryKey }: QueryKeyAndFn) => T | undefined { @@ -50,7 +54,7 @@ class TanstackQueryOptimisticHelpers { set = ( queryKey: QueryKeyAndFn, value: Data | ((prev: Data | undefined) => Data | undefined), - ) => { + ): void => { const prev = this.#get(queryKey); const newValue = typeof value === "function" @@ -378,8 +382,11 @@ class TanstackQueryOptimisticHelpers { >( queryKey: QueryKeyAndFn, path: Path, - { filter, update }: { - filter: ( + { + filter, + update, + }: { + filter?: ( item: GetObjectPath extends Array ? T : never, index: number, ) => boolean; @@ -387,13 +394,62 @@ class TanstackQueryOptimisticHelpers { item: GetObjectPath extends Array ? T : never, ) => GetObjectPath extends Array ? T : never; }, - ) => { + ): { inserted: boolean } => { + return this.objArrayUpsert(queryKey, path, { + filter: filter ?? (() => true), + update, + }); + }; + + /** + * Update items in an array that match a `filter`, or insert a new one if there was no match. + * If the query or path doesn't exist, the updater is skipped. + */ + objArrayUpsert = < + Data extends object, + const Path extends AllObjectPaths, + >( + queryKey: QueryKeyAndFn, + path: Path, + { + filter, + update, + insert, + }: + & { + filter: ( + item: GetObjectPath extends Array ? T : never, + index: number, + ) => boolean; + } + & RequireAtLeastOneKey<{ + /** Defaults to the identity function */ + update: ( + item: GetObjectPath extends Array ? T : never, + ) => GetObjectPath extends Array ? T : never; + /** Defaults to not inserting */ + insert: () => GetObjectPath extends Array ? T : never; + }>, + ): { inserted: boolean } => { const prev = this.#get(queryKey); - if (!prev) return; + if (!prev) return { inserted: false }; const { value: original, exists } = getPath(prev, path); - if (!exists || !Array.isArray(original)) return; + if (!exists || !Array.isArray(original)) return { inserted: false }; - const newArray = original.map((item, index) => filter(item, index) ? update(item) : item); + let matched = false; + const newArray = original.map((item, index) => { + if (filter(item, index)) { + matched = true; + return update ? update(item) : item; + } else { + return item; + } + }); + let inserted = false; + if (!matched && insert) { + newArray.push(insert()); + inserted = true; + } this.#set( queryKey, (obj) => obj ? setPath(obj, path, newArray as any) : obj, @@ -402,6 +458,7 @@ class TanstackQueryOptimisticHelpers { // TODO: splice items back in case original changed this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj); }); + return { inserted }; }; /** @@ -439,7 +496,7 @@ class TanstackQueryOptimisticHelpers { }; /** - * Push item(s) to the end of an array at an object path. + * Push item(s) to the end of an array. * If the query or path doesn't exist, the updater is skipped. */ arrayPush = (queryKey: QueryKeyAndFn, ...items: Data[]) => { @@ -531,16 +588,54 @@ class TanstackQueryOptimisticHelpers { filter?: (item: Data, index: number) => boolean; update: (item: Data) => Data; }, - ) => { + ): { inserted: boolean } => { + return this.arrayUpsert(queryKey, { filter: filter ?? (() => true), update }); + }; + + /** + * Update items in an array that match a `filter`, or insert a new one if there was no match. + * If the query doesn't exist, the updater is skipped. + */ + arrayUpsert = ( + queryKey: QueryKeyAndFn, + { + filter, + update, + insert, + }: + & { + filter: (item: Data, index: number) => boolean; + } + & RequireAtLeastOneKey<{ + /** Defaults to the identity function */ + update: (item: Data) => Data; + /** Defaults to not inserting */ + insert: () => Data; + }>, + ): { inserted: boolean } => { const prev = this.#get(queryKey); - if (!prev || !Array.isArray(prev)) return; + if (!prev || !Array.isArray(prev)) return { inserted: false }; - const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item); + let matched = false; + const newArray = prev.map((item, index) => { + if (filter(item, index)) { + matched = true; + return update ? update(item) : item; + } else { + return item; + } + }); + let inserted = false; + if (!matched && insert) { + newArray.push(insert()); + inserted = true; + } this.#set(queryKey, newArray); this.#onRestore(() => { // TODO: splice items back in case original changed this.#set(queryKey, prev); }); + return { inserted }; }; /** diff --git a/test/tanstack-query-helpers.test.ts b/test/tanstack-query-helpers.test.ts index 0ea3486d5b520a7411144c409aefd6b0b47fce89..4f4b7b16c7f73dbdc5efee82ad5c281cc44d9270 100644 --- a/test/tanstack-query-helpers.test.ts +++ b/test/tanstack-query-helpers.test.ts @@ -15,6 +15,11 @@ interface TestData { tags: string[]; } +interface TestArrayItem { + id: number; + label: string; +} + const initialData: TestData = { name: "Test", count: 10, @@ -31,6 +36,12 @@ const initialData: TestData = { tags: ["alpha", "beta", "gamma"], }; +const initialArrayItems: TestArrayItem[] = [ + { id: 1, label: "first" }, + { id: 2, label: "second" }, + { id: 3, label: "third" }, +]; + function createTestQueryClient() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -46,6 +57,21 @@ function createTestQueryClient() { return { client, queryTest }; } +function createArrayQueryClient() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const queryArray = queryOptions({ + queryKey: ["test-array"], + queryFn: (): TestArrayItem[] => initialArrayItems, + }); + + client.setQueryData(queryArray.queryKey, structuredClone(initialArrayItems)); + + return { client, queryArray }; +} + test("helpers can be spread and retain bound this", () => { const { client, queryTest } = createTestQueryClient(); @@ -663,6 +689,172 @@ test("arrayUpdateItem - predicate receives index", () => { assertEquals(result?.items[0].label, "FIRST"); }); +test("arrayUpdateItem - should update all items when filter is omitted", () => { + const { client, queryTest } = createTestQueryClient(); + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: () => {}, + onRefetch: () => {}, + }); + + helpers.objArrayUpdate( + queryTest, + ["items"], + { + update: (item) => ({ ...item, label: item.label.toUpperCase() }), + }, + ); + + const result = client.getQueryData(queryTest.queryKey); + assertEquals(result?.items.map((item) => item.label), ["FIRST", "SECOND", "THIRD"]); +}); + +// ============================================================================ +// objArrayUpsert() tests +// ============================================================================ + +test("objArrayUpsert - should update matching items without inserting", () => { + const { client, queryTest } = createTestQueryClient(); + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: () => {}, + }); + + const upsertResult = helpers.objArrayUpsert( + queryTest, + ["items"], + { + filter: (item) => item.id === 2, + update: (item) => ({ ...item, label: "UPDATED" }), + insert: () => ({ id: 4, label: "fourth" }), + }, + ); + + const result = client.getQueryData(queryTest.queryKey); + assertEquals(upsertResult, { inserted: false }); + assertEquals(result?.items.length, 3); + assertEquals(result?.items[1], { id: 2, label: "UPDATED" }); + + restoreFns[0](); + const restored = client.getQueryData(queryTest.queryKey); + assertEquals(restored?.items, initialData.items); +}); + +test("objArrayUpsert - should insert a new item when nothing matches", () => { + const { client, queryTest } = createTestQueryClient(); + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: () => {}, + }); + + const upsertResult = helpers.objArrayUpsert( + queryTest, + ["items"], + { + filter: (item) => item.id === 4, + insert: () => ({ id: 4, label: "fourth" }), + }, + ); + + const result = client.getQueryData(queryTest.queryKey); + assertEquals(upsertResult, { inserted: true }); + assertEquals(result?.items.length, 4); + assertEquals(result?.items[3], { id: 4, label: "fourth" }); + + restoreFns[0](); + const restored = client.getQueryData(queryTest.queryKey); + assertEquals(restored?.items, initialData.items); +}); + +// ============================================================================ +// arrayUpsert() tests +// ============================================================================ + +test("arrayUpsert - should update matching items without inserting", () => { + const { client, queryArray } = createArrayQueryClient(); + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: () => {}, + }); + + const upsertResult = helpers.arrayUpsert( + queryArray, + { + filter: (item) => item.id === 2, + update: (item) => ({ ...item, label: "UPDATED" }), + insert: () => ({ id: 4, label: "fourth" }), + }, + ); + + const result = client.getQueryData(queryArray.queryKey); + assertEquals(upsertResult, { inserted: false }); + assertEquals(result?.length, 3); + assertEquals(result?.[1], { id: 2, label: "UPDATED" }); + + restoreFns[0](); + const restored = client.getQueryData(queryArray.queryKey); + assertEquals(restored, initialArrayItems); +}); + +test("arrayUpsert - should insert a new item when nothing matches", () => { + const { client, queryArray } = createArrayQueryClient(); + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: () => {}, + }); + + const upsertResult = helpers.arrayUpsert( + queryArray, + { + filter: (item) => item.id === 4, + insert: () => ({ id: 4, label: "fourth" }), + }, + ); + + const result = client.getQueryData(queryArray.queryKey); + assertEquals(upsertResult, { inserted: true }); + assertEquals(result?.length, 4); + assertEquals(result?.[3], { id: 4, label: "fourth" }); + + restoreFns[0](); + const restored = client.getQueryData(queryArray.queryKey); + assertEquals(restored, initialArrayItems); +}); + +test("arrayUpsert - should skip if query doesn't exist", () => { + const { client } = createTestQueryClient(); + const restoreFns: Array<() => void> = []; + + const helpers = queryClientOptimisticHelpers(client)({ + onRestore: (fn) => restoreFns.push(fn), + onRefetch: () => {}, + }); + + const queryNonexistent = queryOptions({ + queryKey: ["nonexistent-array"], + queryFn: (): TestArrayItem[] => initialArrayItems, + }); + + const upsertResult = helpers.arrayUpsert( + queryNonexistent, + { + filter: (item) => item.id === 4, + insert: () => ({ id: 4, label: "fourth" }), + }, + ); + + assertEquals(upsertResult, { inserted: false }); + assertEquals(restoreFns.length, 0); +}); + // ============================================================================ // arrayInsertIndex() tests // ============================================================================