diff --git a/package-lock.json b/package-lock.json index 30cfbb5..a20891a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", + "name": "@ciscode/query-kit", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", + "name": "@ciscode/query-kit", + "version": "0.0.0", "license": "MIT", "devDependencies": { "@changesets/cli": "^2.27.8", "@eslint/js": "^9.39.2", + "@tanstack/react-query": "^5.96.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -37,6 +38,7 @@ "node": ">=20" }, "peerDependencies": { + "@tanstack/react-query": ">=5", "react": ">=18", "react-dom": ">=18" } @@ -69,33 +71,6 @@ "node": ">=6.0.0" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/dom-selector": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", @@ -2214,6 +2189,34 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.96.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3482,32 +3485,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5873,6 +5850,46 @@ } } }, + "node_modules/jsdom/node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/jsdom/node_modules/cssstyle": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", + "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.2", + "@csstools/css-syntax-patches-for-csstree": "^1.0.26", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz", + "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", diff --git a/package.json b/package.json index 643db40..30533bd 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,14 @@ "access": "public" }, "peerDependencies": { + "@tanstack/react-query": ">=5", "react": ">=18", "react-dom": ">=18" }, "devDependencies": { "@changesets/cli": "^2.27.8", "@eslint/js": "^9.39.2", + "@tanstack/react-query": "^5.96.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -84,5 +86,8 @@ }, "engines": { "node": ">=20" + }, + "overrides": { + "cssstyle": "6.0.1" } } diff --git a/src/index.ts b/src/index.ts index c55977d..3609053 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './hooks'; export * from './utils'; +export * from './query'; diff --git a/src/query/createQuery.test.tsx b/src/query/createQuery.test.tsx new file mode 100644 index 0000000..4a862a8 --- /dev/null +++ b/src/query/createQuery.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { createQuery } from './createQuery'; + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('createQuery', () => { + it('returns a QueryDefinition with queryKey, queryFn, and useQuery', () => { + const def = createQuery( + (id: string) => ['user', id] as const, + async (id: string) => ({ id, name: 'Alice' }), + ); + + expect(typeof def.queryKey).toBe('function'); + expect(typeof def.queryFn).toBe('function'); + expect(typeof def.useQuery).toBe('function'); + }); + + it('queryKey returns the correct readonly tuple given params', () => { + const def = createQuery( + (id: string) => ['user', id] as const, + async (id: string) => ({ id }), + ); + + expect(def.queryKey('123')).toEqual(['user', '123']); + }); + + it('queryKey produces a stable reference for the same params', () => { + const def = createQuery( + (id: number) => ['item', id] as const, + async (id: number) => id, + ); + + const first = def.queryKey(1); + const second = def.queryKey(1); + expect(first).toEqual(second); + }); + + it('queryFn calls the fetcher with the given params', async () => { + const fetcher = vi.fn(async (id: string) => ({ id, name: 'Bob' })); + const def = createQuery((id: string) => ['user', id] as const, fetcher); + + const result = await def.queryFn('42'); + + expect(fetcher).toHaveBeenCalledWith('42'); + expect(result).toEqual({ id: '42', name: 'Bob' }); + }); + + it('queryFn return type is inferred from the fetcher (TData inference)', async () => { + const def = createQuery( + (n: number) => ['count', n] as const, + async (n: number) => ({ value: n * 2 }), + ); + + const result = await def.queryFn(5); + // TypeScript infers result as { value: number } + expect(result.value).toBe(10); + }); + + it('useQuery hook resolves data from the fetcher', async () => { + const fetcher = vi.fn(async (id: string) => ({ id, name: 'Carol' })); + const def = createQuery((id: string) => ['user', id] as const, fetcher); + + const { result } = renderHook(() => def.useQuery('1'), { + wrapper: makeWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ id: '1', name: 'Carol' }); + expect(fetcher).toHaveBeenCalledWith('1'); + }); + + it('useQuery hook shows loading state before data resolves', () => { + let resolve: (v: { value: number }) => void; + const fetcher = vi.fn( + () => + new Promise<{ value: number }>((res) => { + resolve = res; + }), + ); + const def = createQuery((n: number) => ['lazy', n] as const, fetcher); + + const { result } = renderHook(() => def.useQuery(99), { + wrapper: makeWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + // Resolve to avoid open handles + resolve!({ value: 99 }); + }); + + it('useQuery respects enabled: false and does not call fetcher', () => { + const fetcher = vi.fn(async (id: string) => ({ id })); + const def = createQuery((id: string) => ['item', id] as const, fetcher); + + const { result } = renderHook(() => def.useQuery('x', { enabled: false }), { + wrapper: makeWrapper(), + }); + + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe('idle'); + }); + + it('useQuery uses the key from keyFn — different params produce different keys', async () => { + const fetcher = vi.fn(async (id: string) => ({ id })); + const def = createQuery((id: string) => ['entity', id] as const, fetcher); + + const wrapper = makeWrapper(); + + const { result: r1 } = renderHook(() => def.useQuery('a'), { wrapper }); + const { result: r2 } = renderHook(() => def.useQuery('b'), { wrapper }); + + await waitFor(() => expect(r1.current.isSuccess).toBe(true)); + await waitFor(() => expect(r2.current.isSuccess).toBe(true)); + + expect(r1.current.data).toEqual({ id: 'a' }); + expect(r2.current.data).toEqual({ id: 'b' }); + }); +}); diff --git a/src/query/createQuery.ts b/src/query/createQuery.ts new file mode 100644 index 0000000..ec99881 --- /dev/null +++ b/src/query/createQuery.ts @@ -0,0 +1,33 @@ +import { useQuery as useTanstackQuery } from '@tanstack/react-query'; +import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; + +export type QueryKeyFn = (params: TParams) => readonly unknown[]; +export type QueryFetcher = (params: TParams) => Promise; + +type UseQueryShorthandOptions = Omit, 'queryKey' | 'queryFn'>; + +export interface QueryDefinition { + queryKey: QueryKeyFn; + queryFn: QueryFetcher; + useQuery: ( + params: TParams, + options?: UseQueryShorthandOptions, + ) => UseQueryResult; +} + +export function createQuery( + keyFn: QueryKeyFn, + fetcher: QueryFetcher, +): QueryDefinition { + return { + queryKey: keyFn, + queryFn: fetcher, + useQuery(params, options) { + return useTanstackQuery({ + queryKey: keyFn(params), + queryFn: () => fetcher(params), + ...options, + }); + }, + }; +} diff --git a/src/query/index.ts b/src/query/index.ts new file mode 100644 index 0000000..a4ed052 --- /dev/null +++ b/src/query/index.ts @@ -0,0 +1 @@ +export * from './createQuery'; diff --git a/vitest.config.ts b/vitest.config.ts index 2c7babe..4cc6302 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from 'vitest/config'; -import { defineConfig } from 'vitest/config'; export default defineConfig({ test: {