diff --git a/.gitignore b/.gitignore index b0a76c1..534545f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dist .pnp.* lib/ + +# AI Assistant Configuration +CLAUDE.md diff --git a/.prettierignore b/.prettierignore index e738f12..56eb95b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ coverage node_modules lib + +README.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..868a95a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **New query/mutation/infinite options exports** for React Query v5 compatibility + - `{methodName}QueryOptions` functions that return `queryOptions` for use with `useQuery`/`useSuspenseQuery` + - `{methodName}MutationOptions` functions that return `mutationOptions` for use with `useMutation` + - `{methodName}InfiniteQueryOptions` functions that return `infiniteQueryOptions` for use with `useInfiniteQuery`/`useSuspenseInfiniteQuery` + - These exports enable better tree-shaking and composability + - Full TypeScript support with proper generic types +- **Service getter functions** for non-React contexts + - `get{ServiceName}Service()` functions that return service instances without React hooks + - Enables use of API clients outside React components (e.g., in server-side code, scripts, tests) +- **Query key builder utility** (`query-key-builder.ts`) for type-safe cache operations + - `matchQueryKey()` function for building type-safe query keys + - `QueryKeyMap`, `ServiceKeys`, `OperationKeys`, and `OperationParams` types for full type safety + - Enables precise cache invalidation and query matching + +### Changed + +- **Query key structure completely redesigned** for simplicity and consistency + - Changed from complex URL-based patterns (e.g., `` `/widgets/${id}` ``) to simple arrays: `['serviceName', 'methodName', params || {}, metadata?]` + - Infinite queries now differentiated by metadata (`{infinite: true}`) instead of key structure + - All queries for an interface can now be invalidated with just `['interfaceName']` + - Removed `buildResourceKey()`, `isCacheParam()`, and complex path parsing logic +- **Mutations now invalidate at the interface level** instead of specific resource paths + - Simplified from invalidating multiple specific query keys to just `queryClient.invalidateQueries({ queryKey: ['interfaceName'] })` + - More predictable cache invalidation behavior +- **Refactored naming system** from class-based to function-based + - Replaced `NameFactory` class with standalone functions in `name-helpers.ts` + - Functions: `buildHookName()`, `buildQueryOptionsName()`, `buildMutationOptionsName()`, `buildInfiniteQueryOptionsName()`, `buildServiceName()`, `buildServiceHookName()`, `buildServiceGetterName()`, `buildContextName()`, `buildProviderName()` + - `buildHookName()` now requires `service` parameter for proper context +- **Context file enhanced** with new capabilities + - Added `currentContext` variable for non-hook access to context + - Service getter functions exported alongside hooks for flexibility + - Interfaces sorted alphabetically for consistent output + - Props interface now extends options type with optional fetch +- **Error handling improved** with `QueryError` type + - Changed from `CompositeError` throws to structured `QueryError` type + - Enables better error discrimination in error handlers + +### Fixed + +- **TypeScript compilation errors** in generated code + - Fixed `isRequired()` parameter access in `query-key-builder.ts` (accessing `p.value` instead of `p`) + - Removed unused `includeRelayParams` parameter that was being passed but ignored + - Fixed duplicate imports and missing function exports +- **Test and snapshot generation issues** + - Updated test utilities to use `@basketry/ir` parser instead of inline JSON parsing + - Fixed snapshot file generation that was silently failing + - Cleaned up debug `console.log` statements from test utilities + +### Deprecated + +- **All wrapped hook exports** are now marked as `@deprecated` + - `use{MethodName}()` - query hooks + - `useSuspense{MethodName}()` - suspense query hooks + - `useInfinite{MethodName}()` - infinite query hooks + - `useSuspenseInfinite{MethodName}()` - suspense infinite query hooks + - Hooks remain functional for backward compatibility but display deprecation warnings + - Each deprecation notice includes migration guidance to the new pattern + - Will be removed in the next major version (v1.0.0) + +### Internal + +- Added `xxxx()` method in `hook-file.ts` that needs renaming (analyzes return types for select function generation) +- Removed complex relay parameter handling from query key generation +- Simplified infinite query differentiation using metadata instead of key manipulation diff --git a/README.md b/README.md index d16892a..0a377d1 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,195 @@ # React Query -[Basketry generator](https://github.com/basketry/basketry) for generating React Query hooks. This parser can be coupled with any Basketry parser. +[Basketry generator](https://basketry.io) for generating React Query queryOptions and hooks. This generator can be coupled with any Basketry parser. ## Quick Start -// TODO +### Installation + +```bash +npm install @basketry/react-query +``` + +### Getting Started + +1. **Create a Basketry configuration file** (`basketry.config.json`): + + ```json + { + "source": "openapi.json", + "parser": "@basketry/openapi-3", + "generators": ["@basketry/react-query"], + "output": "./src/generated/react-query", + "options": { + "basketry": { + "command": "npx basketry" + }, + "typescript": { + "includeVersion": false + }, + "reactQuery": { + "typesModule": "@your-api/types", // Path to generated TypeScript types + "clientModule": "@your-api/http-client-sdk" // Path to generated HTTP client + } + } + } + ``` + +2. **Run Basketry** to generate the React Query hooks: + + ```bash + npx basketry + ``` + +3. **Set up your React Query provider** in your app: + + ```typescript + import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + // Name of provider will depend on the name of the API service in your OpenAPI spec. + import { BasketryExampleProvider } from './src/generated/context'; + + const queryClient = new QueryClient(); + const httpClient = fetch; // or your custom fetch implementation + + function App() { + return ( + + + {/* Your app components */} + + + ); + } + ``` + +4. **Use the generated hooks** in your components: + + ```typescript + import { useQuery } from '@tanstack/react-query'; + import { getWidgetsQueryOptions } from './src/generated'; + + function WidgetList() { + const { data, isLoading } = useQuery(getWidgetsQueryOptions()); + + if (isLoading) return
Loading...
; + return
{data?.map(widget =>
{widget.name}
)}
; + } + ``` + +### Basic Usage + +This generator produces React Query compatible code with queryOptions functions that provide maximum flexibility: + +```typescript +// Using query options with React Query hooks +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from './petstore'; // generated code + +function WidgetList() { + // Basic usage + const { data } = useQuery(getWidgetsQueryOptions()); + + // With parameters + const { data: filtered } = useQuery( + getWidgetsQueryOptions({ status: 'active' }) + ); + + // With custom options + const { data: cached } = useQuery({ + ...getWidgetsQueryOptions(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + return
{/* render widgets */}
; +} +``` + +### Mutations + +```typescript +import { useMutation } from '@tanstack/react-query'; +import { createWidgetMutationOptions } from './petstore'; // generated code + +function CreateWidget() { + const mutation = useMutation(createWidgetMutationOptions()); + + const handleSubmit = (data: CreateWidgetInput) => { + mutation.mutate(data, { + onSuccess: (widget) => { + console.log('Created widget:', widget); + }, + }); + }; + + return
{/* form fields */}
; +} +``` + +### Infinite Queries (Pagination) + +For services with Relay-style pagination: + +```typescript +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getWidgetsInfiniteQueryOptions } from './petstore'; // generated code + +function InfiniteWidgetList() { + const { + data, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery(getWidgetsInfiniteQueryOptions()); + + return ( +
+ {data?.pages.map(page => + page.edges.map(({ node }) => ( + + )) + )} + +
+ ); +} +``` + +## Configuration + +Add to your `basketry.config.json`: + +```json +``` + +## Features + +- **React Query Compatible**: Generates queryOptions and mutationOptions functions +- **Type-Safe**: Full TypeScript support with proper type inference +- **Flexible**: Use with any React Query hook (useQuery, useSuspenseQuery, etc.) +- **SSR Ready**: Service getters work outside React components +- **Backward Compatible**: Legacy hooks are deprecated but still available +- **Relay Pagination**: Built-in support for cursor-based pagination +- **Error Handling**: Automatic error aggregation with CompositeError --- -## For contributors: +## For contributors ### Run this project -1. Install packages: `npm ci` -1. Build the code: `npm run build` -1. Run it! `npm start` +1. Install packages: `npm ci` +1. Build the code: `npm run build` +1. Run it! `npm start` Note that the `lint` script is run prior to `build`. Auto-fixable linting or formatting errors may be fixed by running `npm run fix`. ### Create and run tests -1. Add tests by creating files with the `.test.ts` suffix -1. Run the tests: `npm t` -1. Test coverage can be viewed at `/coverage/lcov-report/index.html` +1. Add tests by creating files with the `.test.ts` suffix +1. Run the tests: `npm test` +1. Test coverage can be viewed at `/coverage/lcov-report/index.html` ### Publish a new package version diff --git a/package-lock.json b/package-lock.json index 5186821..52d25f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basketry/react-query", - "version": "0.2.1", + "version": "0.3.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@basketry/react-query", - "version": "0.2.1", + "version": "0.3.0-alpha.0", "license": "MIT", "dependencies": { "@basketry/typescript": "^0.2.3", @@ -15,6 +15,9 @@ "pluralize": "^8.0.0", "prettier": "^2.5.1" }, + "bin": { + "basketry-react-query": "lib/rpc.js" + }, "devDependencies": { "@basketry/dotfiles": "^1.1.0", "@types/jest": "^27.4.0", diff --git a/package.json b/package.json index 6d6b395..2afcd00 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@basketry/react-query", - "version": "0.2.1", - "description": "Basketry generator for generating Typescript interfaces", + "version": "0.3.0-alpha.0", + "description": "Basketry generator for generating React Query hooks", "main": "./lib/index.js", "bin": { "basketry-react-query": "./lib/rpc.js" diff --git a/src/context-file.ts b/src/context-file.ts index 523a723..cd9e429 100644 --- a/src/context-file.ts +++ b/src/context-file.ts @@ -1,14 +1,16 @@ -import { camel, pascal } from 'case'; +import { pascal } from 'case'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; -import { NameFactory } from './name-factory'; +import { + buildContextName, + buildProviderName, + buildServiceHookName, + buildServiceGetterName, + buildServiceName, +} from './name-helpers'; export class ContextFile extends ModuleBuilder { - private readonly nameFactory = new NameFactory(this.service, this.options); - private readonly react = new ImportBuilder( - 'react', - this.options?.reactQuery?.reactImport ? 'React' : undefined, - ); + private readonly react = new ImportBuilder('react'); private readonly client = new ImportBuilder( this.options?.reactQuery?.clientModule ?? '../http-client', ); @@ -28,25 +30,47 @@ export class ContextFile extends ModuleBuilder { const FetchLike = () => this.client.type('FetchLike'); const OptionsType = () => this.client.type(optionsName); - const contextName = this.nameFactory.buildContextName(); + const contextName = buildContextName(this.service); const contextPropsName = pascal(`${contextName}_props`); - const providerName = this.nameFactory.buildProviderName(); + const providerName = buildProviderName(this.service); yield `export interface ${contextPropsName} extends ${OptionsType()} { fetch?: ${FetchLike()}; }`; yield `const ${contextName} = ${createContext()}<${contextPropsName} | undefined>( undefined );`; yield ``; + + // Store context for non-hook access + yield `let currentContext: ${contextPropsName} | undefined;`; + yield ``; + yield `export const ${providerName}: ${FC()}<${PropsWithChildren()}<${contextPropsName}>> = ({ children, ...props }) => {`; yield ` const value = ${useMemo()}(() => ({ ...props }), [props.fetch, props.mapUnhandledException, props.mapValidationError, props.root]);`; + yield ` currentContext = value;`; yield ` return <${contextName}.Provider value={value}>{children};`; yield `};`; - for (const int of [...this.service.interfaces].sort((a, b) => + + const sortedInterfaces = [...this.service.interfaces].sort((a, b) => a.name.value.localeCompare(b.name.value), - )) { - const hookName = this.nameFactory.buildServiceHookName(int); - const localName = this.nameFactory.buildServiceName(int); - const interfaceName = pascal(localName); + ); + for (const int of sortedInterfaces) { + const hookName = buildServiceHookName(int); + const getterName = buildServiceGetterName(int); + const localName = buildServiceName(int); + const interfaceName = pascal(`${int.name.value}_service`); const className = pascal(`http_${int.name.value}_service`); + // Add service getter function (v0.3.0) + yield ``; + yield `export const ${getterName} = () => {`; + yield ` if (!currentContext) { throw new Error('${getterName} called outside of ${providerName}'); }`; + yield ` const ${localName}: ${this.types.type( + interfaceName, + )} = new ${this.client.fn( + className, + )}(currentContext.fetch ?? window.fetch.bind(window), currentContext);`; + yield ` return ${localName};`; + yield `};`; + + // Keep legacy hook for backward compatibility (v0.2.0) yield ``; yield `export const ${hookName} = () => {`; yield ` const context = ${useContext()}(${contextName});`; diff --git a/src/hook-file.ts b/src/hook-file.ts index dd50508..d965a9b 100644 --- a/src/hook-file.ts +++ b/src/hook-file.ts @@ -5,7 +5,6 @@ import { getTypeByName, getUnionByName, HttpMethod, - HttpParameter, HttpRoute, Interface, isRequired, @@ -27,7 +26,15 @@ import { camel } from 'case'; import { NamespacedReactQueryOptions } from './types'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; -import { NameFactory } from './name-factory'; +import { + buildServiceName, + buildServiceHookName, + buildHookName, + buildQueryOptionsName, + buildMutationOptionsName, + buildInfiniteQueryOptionsName, + buildServiceGetterName, +} from './name-helpers'; import { isRelayPaginaged } from './utils'; type Envelope = { @@ -45,7 +52,6 @@ export class HookFile extends ModuleBuilder { ) { super(service, options); } - private readonly nameFactory = new NameFactory(this.service, this.options); private readonly tanstack = new ImportBuilder('@tanstack/react-query'); private readonly runtime = new ImportBuilder('./runtime'); private readonly context = new ImportBuilder('./context'); @@ -61,6 +67,21 @@ export class HookFile extends ModuleBuilder { ]; *body(): Iterable { + // === QUERY/MUTATION OPTIONS EXPORTS (React Query v5) === + yield '// Query and mutation options exports for React Query v5'; + yield ''; + + for (const method of this.int.methods) { + const httpMethod = getHttpMethodByName(this.service, method.name.value); + const httpRoute = this.getHttpRoute(httpMethod); + yield* this.generateAllOptionsExports(method, httpMethod, httpRoute); + } + + // === LEGACY HOOKS (deprecated) === + yield ''; + yield '// Legacy hooks - deprecated, use query/mutation options exports instead'; + yield ''; + const useMutation = () => this.tanstack.fn('useMutation'); const useQuery = () => this.tanstack.fn('useQuery'); const useQueryClient = () => this.tanstack.fn('useQueryClient'); @@ -81,16 +102,16 @@ export class HookFile extends ModuleBuilder { const type = (t: string) => this.types.type(t); - const serviceName = this.nameFactory.buildServiceName(this.int); - const serviceHookName = this.nameFactory.buildServiceHookName(this.int); + const serviceName = buildServiceName(this.int); + const serviceHookName = buildServiceHookName(this.int); for (const method of [...this.int.methods].sort((a, b) => - this.nameFactory - .buildHookName(a) - .localeCompare(this.nameFactory.buildHookName(b)), + buildHookName(a, this.service).localeCompare( + buildHookName(b, this.service), + ), )) { - const name = this.nameFactory.buildHookName(method); - const suspenseName = this.nameFactory.buildHookName(method, { + const name = buildHookName(method, this.service); + const suspenseName = buildHookName(method, this.service, { suspense: true, }); const paramsType = from(buildParamsType(method)); @@ -107,18 +128,16 @@ export class HookFile extends ModuleBuilder { const isGet = httpMethod?.verb.value === 'get' && !!httpRoute; if (isGet) { - yield* this.generateQueryOptions(method, httpRoute); - } - - if (isGet) { - const queryOptionsName = this.nameFactory.buildQueryOptionsName(method); + const queryOptionsName = buildQueryOptionsName(method); const paramsCallsite = method.parameters.length ? 'params' : ''; const genericTypes = this.buildGenericTypes(method).join(','); const optionsExpression = `options?: Omit<${UndefinedInitialDataOptions()}<${genericTypes}>,'queryKey' | 'queryFn' | 'select'>`; - yield* buildDescription(method.description, method.deprecated?.value); + // NOTE: We are manually setting a deprecation message + yield* buildDescription(method.description, false); + yield `/** @deprecated Use ${queryOptionsName} with useQuery instead */`; yield `export function ${name}(${[ paramsExpression, optionsExpression, @@ -127,7 +146,9 @@ export class HookFile extends ModuleBuilder { yield ` return ${useQuery()}({...defaultOptions, ...options});`; yield `}`; yield ''; - yield* buildDescription(method.description, method.deprecated?.value); + // NOTE: We are manually setting a deprecation message + yield* buildDescription(method.description, false); + yield `/** @deprecated Use ${queryOptionsName} with useSuspenseQuery instead */`; yield `export function ${suspenseName}(${[ paramsExpression, optionsExpression, @@ -147,7 +168,11 @@ export class HookFile extends ModuleBuilder { const optionsExpression = `options?: Omit<${mutationOptions()}, 'mutationFn'>`; - yield* buildDescription(method.description, method.deprecated?.value); + // NOTE: We are manually setting a deprecation message + yield* buildDescription(method.description, false); + yield `/** @deprecated Use ${buildMutationOptionsName( + method, + )} with useMutation instead */`; yield `export function ${name}(${optionsExpression}) {`; yield ` const queryClient = ${useQueryClient()}();`; yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; @@ -163,17 +188,9 @@ export class HookFile extends ModuleBuilder { yield ` throw handled`; yield ` }`; - const queryKeys = new Set(); - queryKeys.add(this.buildResourceKey(httpRoute, method)); // Invalidate this resource - queryKeys.add( - this.buildResourceKey(httpRoute, method, { - skipTerminalParams: true, - }), // Invalidate the parent resource group - ); - - for (const queryKey of Array.from(queryKeys)) { - yield ` queryClient.invalidateQueries({ queryKey: [${queryKey}] });`; - } + // Invalidate all queries for this interface + const interfaceName = camel(this.int.name.value); + yield ` queryClient.invalidateQueries({ queryKey: ['${interfaceName}'] });`; if (dataProp && !isRequired(dataProp.value)) { yield ` ${assert()}(res.data);`; } @@ -191,7 +208,7 @@ export class HookFile extends ModuleBuilder { : ''; const infiniteOptionsHook = camel( - `${this.nameFactory.buildHookName(method, { + `${buildHookName(method, this.service, { infinite: true, })}_query_options`, ); @@ -201,8 +218,7 @@ export class HookFile extends ModuleBuilder { yield `function ${infiniteOptionsHook}(${paramsExpression}) {`; yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}();`; yield ` return {`; - yield ` queryKey: ${this.buildQueryKey(httpRoute, method, { - includeRelayParams: false, + yield ` queryKey: ${this.buildQueryKey(method, { infinite: true, })},`; yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`; @@ -224,8 +240,12 @@ export class HookFile extends ModuleBuilder { yield ` };`; yield `}`; - yield* buildDescription(method.description, method.deprecated?.value); - yield `export const ${this.nameFactory.buildHookName(method, { + // NOTE: We are manually setting a deprecation message + yield* buildDescription(method.description, false); + yield `/** @deprecated Use ${buildInfiniteQueryOptionsName( + method, + )} with useInfiniteQuery instead */`; + yield `export const ${buildHookName(method, this.service, { suspense: false, infinite: true, })} = (${paramsExpression}) => {`; @@ -233,8 +253,12 @@ export class HookFile extends ModuleBuilder { yield ` return ${useInfiniteQuery()}(options);`; yield `}`; - yield* buildDescription(method.description, method.deprecated?.value); - yield `export const ${this.nameFactory.buildHookName(method, { + // NOTE: We are manually setting a deprecation message + yield* buildDescription(method.description, false); + yield `/** @deprecated Use ${buildInfiniteQueryOptionsName( + method, + )} with useSuspenseInfiniteQuery instead */`; + yield `export const ${buildHookName(method, this.service, { suspense: true, infinite: true, })} = (${paramsExpression}) => {`; @@ -344,73 +368,16 @@ export class HookFile extends ModuleBuilder { const { returnTypeName, dataTypeName, array, skipSelect } = this.xxxx(method); - // This is the type returned by the queryFn genericTypes.push(type(returnTypeName)); - // This is the type of the error returned by the hook if the query fails genericTypes.push(`${QueryError()}<${type('Error')}[]>`); - // This is the type returned by the select function (if it exists) if (!skipSelect) { genericTypes.push(`${type(dataTypeName)}${array}`); } return genericTypes; } - private *generateQueryOptions( - method: Method, - httpRoute: HttpRoute, - ): Iterable { - const queryOptions = this.buildQueryOptions(method); - const QueryError = () => this.runtime.type('QueryError'); - const assert = () => this.runtime.fn('assert'); - const type = (t: string) => this.types.type(t); - - const serviceName = camel(`${this.int.name.value}_service`); - const serviceHookName = camel(`use_${this.int.name.value}_service`); - const name = this.nameFactory.buildQueryOptionsName(method); - const paramsType = from(buildParamsType(method)); - const q = method.parameters.every((param) => !isRequired(param.value)) - ? '?' - : ''; - const paramsExpression = method.parameters.length - ? `params${q}: ${type(paramsType)}` - : ''; - const paramsCallsite = method.parameters.length ? 'params' : ''; - - const { skipSelect, dataProp } = this.xxxx(method); - - const guard = () => this.runtime.fn('guard'); - - yield `const ${name} = (${paramsExpression}) => {`; - yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; - yield ` return ${queryOptions()}({`; - yield ` queryKey: ${this.buildQueryKey(httpRoute, method, { - includeRelayParams: true, - })},`; - yield ` queryFn: async () => {`; - yield ` const res = await ${guard()}(${serviceName}.${camel( - method.name.value, - )}(${paramsCallsite}));`; - yield ` if (res.errors.length) {`; - yield ` const handled: ${QueryError()}<${type( - 'Error', - )}[]> = { kind: 'handled', payload: res.errors };`; - yield ` throw handled`; - yield ` }`; - yield ` return res;`; - yield ` },`; - if (!skipSelect) { - if (dataProp && !isRequired(dataProp.value)) { - yield ` select: (data) => { ${assert()}(data.data); return data.data},`; - } else { - yield ` select: (data) => data.data,`; - } - } - yield ` });`; - yield `};`; - } - private getHttpRoute( httpMethod: HttpMethod | undefined, ): HttpRoute | undefined { @@ -429,81 +396,6 @@ export class HookFile extends ModuleBuilder { return undefined; } - private buildQueryKey( - httpRoute: HttpRoute, - method: Method, - options?: { includeRelayParams?: boolean; infinite?: boolean }, - ): string { - const compact = () => this.runtime.fn('compact'); - - const resourceKey = this.buildResourceKey(httpRoute, method); - const q = method.parameters.every((param) => !isRequired(param.value)) - ? '?' - : ''; - - const httpMethod = getHttpMethodByName(this.service, method.name.value); - const queryParams = httpMethod?.parameters.filter((p) => - isCacheParam(p, options?.includeRelayParams ?? false), - ); - - const queryKey = [resourceKey]; - - let couldHaveNullQueryParams = false; - if (queryParams?.length) { - couldHaveNullQueryParams = true; - queryKey.push( - `${compact()}({${queryParams - .map((p) => { - const param = method.parameters.find( - (mp) => camel(mp.name.value) === camel(p.name.value), - ); - const isArray = param?.value.isArray ?? false; - return `${p.name.value}: params${q}.${p.name.value}${ - isArray ? ".join(',')" : '' - }`; - }) - .join(',')}})`, - ); - } - - if (options?.infinite) { - queryKey.push('{inifinite: true}'); - } - - return `[${queryKey.join(', ')}]${ - couldHaveNullQueryParams ? '.filter(Boolean)' : '' - }`; - } - - private buildResourceKey( - httpRoute: HttpRoute, - method: Method, - options?: { skipTerminalParams: boolean }, - ): string { - const q = method.parameters.every((param) => !isRequired(param.value)) - ? '?' - : ''; - - const parts = httpRoute.pattern.value.split('/'); - - if (options?.skipTerminalParams) { - while (isPathParam(parts[parts.length - 1])) { - parts.pop(); - } - } - - const path = parts.filter(Boolean).map((part) => { - if (part.startsWith('{') && part.endsWith('}')) { - const param = part.slice(1, -1); - return `\${params${q}.${camel(param)}}`; - } - - return part; - }); - - return `\`/${path.join('/')}\``; - } - private isRelayPaginated(method: Method): boolean { return isRelayPaginaged(method, this.service); } @@ -556,30 +448,203 @@ export class HookFile extends ModuleBuilder { returnType, }; } -} -function brakets(member: { isArray: boolean }): '[]' | '' { - return member.isArray ? '[]' : ''; -} + private *generateAllOptionsExports( + method: Method, + httpMethod: HttpMethod | undefined, + httpRoute: HttpRoute | undefined, + ): Iterable { + if (!httpRoute) return; -function isPathParam(part: string): boolean { - return part.startsWith('{') && part.endsWith('}'); -} + const isGet = httpMethod?.verb.value === 'get'; -function isCacheParam( - param: HttpParameter, - includeRelayParams: boolean, -): boolean { - if (param.location.value !== 'query') return false; - - if (!includeRelayParams) { - return ( - camel(param.name.value.toLowerCase()) !== 'first' && - camel(param.name.value.toLowerCase()) !== 'after' && - camel(param.name.value.toLowerCase()) !== 'last' && - camel(param.name.value.toLowerCase()) !== 'before' - ); + if (isGet) { + yield* this.generateQueryOptionsExport(method, httpRoute); + + if (this.isRelayPaginated(method)) { + yield* this.generateInfiniteQueryOptionsExport(method, httpRoute); + } + } else { + yield* this.generateMutationOptionsExport(method); + } } - return true; + private *generateQueryOptionsExport( + method: Method, + httpRoute: HttpRoute, + ): Iterable { + const queryOptions = this.buildQueryOptions(method); + const QueryError = () => this.runtime.type('QueryError'); + const assert = () => this.runtime.fn('assert'); + const type = (t: string) => this.types.type(t); + const guard = () => this.runtime.fn('guard'); + + const serviceName = buildServiceName(this.int); + const serviceGetterName = buildServiceGetterName(this.int); + const exportedName = buildQueryOptionsName(method); + + const paramsType = from(buildParamsType(method)); + const q = method.parameters.every((param) => !isRequired(param.value)) + ? '?' + : ''; + const paramsExpression = method.parameters.length + ? `params${q}: ${type(paramsType)}` + : ''; + const paramsCallsite = method.parameters.length ? 'params' : ''; + + const { skipSelect, dataProp } = this.xxxx(method); + + yield ''; + yield* buildDescription(method.description, method.deprecated?.value); + yield `export const ${exportedName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`; + yield ` return ${queryOptions()}({`; + yield ` queryKey: ${this.buildQueryKey(method)},`; + yield ` queryFn: async () => {`; + yield ` const res = await ${guard()}(${serviceName}.${camel( + method.name.value, + )}(${paramsCallsite}));`; + yield ` if (res.errors.length) {`; + yield ` const handled: ${QueryError()}<${type( + 'Error', + )}[]> = { kind: 'handled', payload: res.errors };`; + yield ` throw handled`; + yield ` }`; + yield ` return res;`; + yield ` },`; + if (!skipSelect) { + if (dataProp && !isRequired(dataProp.value)) { + yield ` select: (data) => { ${assert()}(data.data); return data.data},`; + } else { + yield ` select: (data) => data.data,`; + } + } + yield ` });`; + yield `};`; + } + + private *generateMutationOptionsExport(method: Method): Iterable { + const mutationOptions = () => this.tanstack.fn('mutationOptions'); + const QueryError = () => this.runtime.type('QueryError'); + const type = (t: string) => this.types.type(t); + const guard = () => this.runtime.fn('guard'); + const assert = () => this.runtime.fn('assert'); + + const serviceName = buildServiceName(this.int); + const serviceGetterName = buildServiceGetterName(this.int); + const mutationOptionsName = buildMutationOptionsName(method); + + const paramsType = from(buildParamsType(method)); + const paramsExpression = method.parameters.length + ? `params: ${type(paramsType)}` + : ''; + const paramsCallsite = method.parameters.length ? 'params' : ''; + + const { envelope } = this.unwrapEnvelop(method); + const dataProp = envelope?.dataProp; + + yield ''; + yield* buildDescription(method.description, method.deprecated?.value); + yield `export const ${mutationOptionsName} = () => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`; + yield ` return ${mutationOptions()}({`; + yield ` mutationFn: async (${paramsExpression}) => {`; + yield ` const res = await ${guard()}(${serviceName}.${camel( + method.name.value, + )}(${paramsCallsite}));`; + yield ` if (res.errors.length) {`; + yield ` const handled: ${QueryError()}<${type( + 'Error', + )}[]> = { kind: 'handled', payload: res.errors };`; + yield ` throw handled`; + yield ` }`; + if (dataProp && !isRequired(dataProp.value)) { + yield ` ${assert()}(res.data);`; + } + yield ` return res.data;`; + yield ` },`; + yield ` });`; + yield `};`; + } + + private *generateInfiniteQueryOptionsExport( + method: Method, + httpRoute: HttpRoute, + ): Iterable { + const infiniteQueryOptions = () => this.tanstack.fn('infiniteQueryOptions'); + const QueryError = () => this.runtime.type('QueryError'); + const type = (t: string) => this.types.type(t); + const applyPageParam = () => this.runtime.fn('applyPageParam'); + const getInitialPageParam = () => this.runtime.fn('getInitialPageParam'); + const getNextPageParam = () => this.runtime.fn('getNextPageParam'); + const getPreviousPageParam = () => this.runtime.fn('getPreviousPageParam'); + const PageParam = () => this.runtime.type('PageParam'); + const guard = () => this.runtime.fn('guard'); + + const serviceName = buildServiceName(this.int); + const serviceGetterName = buildServiceGetterName(this.int); + const infiniteOptionsName = buildInfiniteQueryOptionsName(method); + + const paramsType = from(buildParamsType(method)); + const q = method.parameters.every((param) => !isRequired(param.value)) + ? '?' + : ''; + const paramsExpression = method.parameters.length + ? `params${q}: ${type(paramsType)}` + : ''; + + const methodExpression = `${serviceName}.${camel(method.name.value)}`; + const paramsCallsite = method.parameters.length + ? `${applyPageParam()}(params${q ? '?? {}' : ''}, pageParam)` + : ''; + + yield ''; + yield* buildDescription(method.description, method.deprecated?.value); + yield `export const ${infiniteOptionsName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}();`; + yield ` return ${infiniteQueryOptions()}({`; + yield ` queryKey: ${this.buildQueryKey(method, { + infinite: true, + })},`; + yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`; + yield ` const res = await ${guard()}(${methodExpression}(${paramsCallsite}));`; + yield ` if (res.errors.length) {`; + yield ` const handled: ${QueryError()}<${type( + 'Error', + )}[]> = { kind: 'handled', payload: res.errors };`; + yield ` throw handled`; + yield ` }`; + yield ` return res;`; + yield ` },`; + yield* this.buildInfiniteSelectFn(method); + yield ` initialPageParam: ${getInitialPageParam()}(params${ + q ? '?? {}' : '' + }),`; + yield ` ${getNextPageParam()},`; + yield ` ${getPreviousPageParam()},`; + yield ` });`; + yield `};`; + } + + private buildQueryKey( + method: Method, + options?: { infinite?: boolean }, + ): string { + const interfaceName = camel(this.int.name.value); + const methodName = camel(method.name.value); + + const queryKey = [`'${interfaceName}'`, `'${methodName}'`]; + + if (method.parameters.length) { + queryKey.push(`params || {}`); + } else { + queryKey.push('{}'); + } + + if (options?.infinite) { + queryKey.push('{infinite: true}'); + } + + return `[${queryKey.join(', ')}]`; + } } diff --git a/src/hook-generator.test.ts b/src/hook-generator.test.ts index 1c518bb..9e0bf31 100644 --- a/src/hook-generator.test.ts +++ b/src/hook-generator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { generateFiles } from './snapshot/test-utils'; -describe.skip('HookGenerator', () => { +describe('HookGenerator', () => { it('recreates a valid snapshot using the Engine', async () => { for await (const file of generateFiles()) { const snapshot = readFileSync(join(...file.path)).toString(); diff --git a/src/hook-generator.ts b/src/hook-generator.ts index c6f9bab..c536ea2 100644 --- a/src/hook-generator.ts +++ b/src/hook-generator.ts @@ -1,14 +1,13 @@ import { File, Generator, Service } from 'basketry'; import { plural } from 'pluralize'; - import { buildFilePath } from '@basketry/typescript'; import { format, from } from '@basketry/typescript/lib/utils'; - import { kebab } from 'case'; import { NamespacedReactQueryOptions } from './types'; import { HookFile } from './hook-file'; import { ContextFile } from './context-file'; import { RuntimeFile } from './runtime-file'; +import { QueryKeyBuilder } from './query-key-builder'; import { formatMarkdown, ReadmeFile } from './readme-file'; export const generateHooks: Generator = (service, options) => { @@ -40,12 +39,24 @@ class HookGenerator { ), }); + files.push({ + path: buildFilePath( + ['hooks', 'query-key-builder.ts'], + this.service, + this.options, + ), + contents: await format( + from(new QueryKeyBuilder(this.service, this.options).build()), + this.options, + ), + }); + files.push({ path: buildFilePath(['hooks', 'README.md'], this.service, this.options), - contents: formatMarkdown( + contents: await formatMarkdown( from(new ReadmeFile(this.service, this.options).build()), this.options, - ) as unknown as string, + ), }); for (const int of this.service.interfaces) { diff --git a/src/name-factory.ts b/src/name-factory.ts deleted file mode 100644 index 9d0132b..0000000 --- a/src/name-factory.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getHttpMethodByName, Interface, Method, Service } from 'basketry'; -import { camel, pascal } from 'case'; -import { NamespacedReactQueryOptions } from './types'; - -export class NameFactory { - constructor( - private readonly service: Service, - private readonly options?: NamespacedReactQueryOptions, - ) {} - - buildContextName(): string { - return pascal(`${this.service.title.value}_context`); - } - - buildProviderName(): string { - return pascal(`${this.service.title.value}_provider`); - } - - buildQueryOptionsName(method: Method): string { - return camel(`use_${method.name.value}_query_options`); - } - - buildServiceName(int: Interface): string { - return camel(`${int.name.value}_service`); - } - - buildServiceHookName(int: Interface): string { - return camel(`use_${this.buildServiceName(int)}`); - } - - buildHookName( - method: Method, - options?: { infinite?: boolean; suspense?: boolean }, - ): string { - const name = method.name.value; - const httpMethod = getHttpMethodByName(this.service, name); - - if ( - httpMethod?.verb.value === 'get' && - name.toLocaleLowerCase().startsWith('get') - ) { - // Query Hook - return camel( - `use_${options?.suspense ? 'suspense_' : ''}${ - options?.infinite ? 'infinite_' : '' - }${name.slice(3)}`, - ); - } - - // Mutation Hook - return camel(`use_${name}`); - } -} diff --git a/src/name-helpers.ts b/src/name-helpers.ts new file mode 100644 index 0000000..e04017b --- /dev/null +++ b/src/name-helpers.ts @@ -0,0 +1,64 @@ +import { getHttpMethodByName, Interface, Method, Service } from 'basketry'; +import { camel, pascal } from 'case'; + +export function getQueryOptionsName(method: Method): string { + return camel(`use_${method.name.value}_query_options`); +} + +export function buildServiceGetterName(int: Interface): string { + return camel(`get_${int.name.value}_service`); +} + +export function buildQueryOptionsName(method: Method): string { + return camel(`${method.name.value}_query_options`); +} + +export function buildMutationOptionsName(method: Method): string { + return camel(`${method.name.value}_mutation_options`); +} + +export function buildInfiniteQueryOptionsName(method: Method): string { + return camel(`${method.name.value}_infinite_query_options`); +} + +export function buildServiceHookName(int: Interface): string { + return camel(`use_${int.name.value}_service`); +} + +export function buildContextName(service: Service): string { + return pascal(`${service.title.value}_context`); +} + +export function buildProviderName(service: Service): string { + return pascal(`${service.title.value}_provider`); +} + +export function buildServiceName(int: Interface): string { + return camel(`${int.name.value}_service`); +} + +export function buildHookName( + method: Method, + service: Service, + options?: { infinite?: boolean; suspense?: boolean }, +): string { + const name = method.name.value; + const httpMethod = getHttpMethodByName(service, name); + + if (httpMethod?.verb.value === 'get') { + // Query Hook + // Remove 'get' prefix if present for cleaner hook names + const hookBaseName = name.toLocaleLowerCase().startsWith('get') + ? name.slice(3) + : name; + + return camel( + `use_${options?.suspense ? 'suspense_' : ''}${ + options?.infinite ? 'infinite_' : '' + }${hookBaseName}`, + ); + } + + // Mutation Hook + return camel(`use_${name}`); +} diff --git a/src/query-key-builder.ts b/src/query-key-builder.ts new file mode 100644 index 0000000..68e0104 --- /dev/null +++ b/src/query-key-builder.ts @@ -0,0 +1,159 @@ +import { isRequired, Method, Service } from 'basketry'; + +import { buildParamsType } from '@basketry/typescript'; +import { from } from '@basketry/typescript/lib/utils'; + +import { camel } from 'case'; +import { NamespacedReactQueryOptions } from './types'; +import { ModuleBuilder } from './module-builder'; +import { ImportBuilder } from './import-builder'; + +export class QueryKeyBuilder extends ModuleBuilder { + constructor( + service: Service, + options: NamespacedReactQueryOptions | undefined, + ) { + super(service, options); + } + + private readonly types = new ImportBuilder( + this.options?.reactQuery?.typesModule ?? '../types', + ); + + protected readonly importBuilders = [this.types]; + + *body(): Iterable { + yield* this.generateQueryKeyMap(); + yield ''; + + yield* this.generateTypeHelpers(); + yield ''; + + yield* this.generateMatchQueryKeyFunction(); + } + + private *generateQueryKeyMap(): Iterable { + yield '/**'; + yield ' * Type mapping for all available query keys in the service'; + yield ' */'; + yield 'export interface QueryKeyMap {'; + + for (const int of this.service.interfaces) { + const serviceName = camel(int.name.value); + yield ` ${serviceName}: {`; + + for (const method of int.methods) { + const methodName = camel(method.name.value); + const paramsType = this.buildMethodParamsType(method); + + yield ` ${methodName}: ${paramsType};`; + } + + yield ' };'; + } + + yield '}'; + } + + private *generateTypeHelpers(): Iterable { + yield '/**'; + yield ' * Extract all service names from QueryKeyMap'; + yield ' */'; + yield 'export type ServiceKeys = keyof QueryKeyMap;'; + yield ''; + + yield '/**'; + yield ' * Extract operation names for a given service'; + yield ' */'; + yield 'export type OperationKeys = keyof QueryKeyMap[S];'; + yield ''; + + yield '/**'; + yield ' * Extract parameter type for a given service and operation'; + yield ' */'; + yield 'export type OperationParams<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '> = QueryKeyMap[S][O];'; + } + + private *generateMatchQueryKeyFunction(): Iterable { + yield '/**'; + yield ' * Build type-safe query keys for React Query cache operations'; + yield ' * '; + yield ' * @example'; + yield ' * // Match all queries for a service'; + yield ' * matchQueryKey("widget")'; + yield ' * // Returns: ["widget"]'; + yield ' * '; + yield ' * @example'; + yield ' * // Match all queries for a specific operation'; + yield ' * matchQueryKey("widget", "getWidgets")'; + yield ' * // Returns: ["widget", "getWidgets"]'; + yield ' * '; + yield ' * @example'; + yield ' * // Match specific query with parameters'; + yield ' * matchQueryKey("widget", "getWidgets", { status: "active" })'; + yield ' * // Returns: ["widget", "getWidgets", { status: "active" }]'; + yield ' */'; + + // Function overloads + yield 'export function matchQueryKey('; + yield ' service: S'; + yield '): readonly [S];'; + yield ''; + + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation: O'; + yield '): readonly [S, O];'; + yield ''; + + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation: O,'; + yield ' params: OperationParams extends undefined ? undefined : OperationParams'; + yield '): readonly [S, O, OperationParams extends undefined ? {} : OperationParams];'; + yield ''; + + // Implementation + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation?: O,'; + yield ' params?: OperationParams'; + yield ') {'; + yield ' if (arguments.length === 3 && operation !== undefined) {'; + yield ' // When called with 3 arguments, always include params (use {} if undefined)'; + yield ' const finalParams = params === undefined ? {} : params;'; + yield ' return [service, operation, finalParams] as const;'; + yield ' }'; + yield ' if (operation !== undefined) {'; + yield ' return [service, operation] as const;'; + yield ' }'; + yield ' return [service] as const;'; + yield '}'; + } + + private buildMethodParamsType(method: Method): string { + const paramsType = from(buildParamsType(method)); + + if (!paramsType) { + return 'undefined'; + } + + // Register the type with the import builder + this.types.type(paramsType); + + const hasRequired = method.parameters.some((p) => isRequired(p.value)); + return hasRequired ? paramsType : `${paramsType} | undefined`; + } +} diff --git a/src/readme-file.ts b/src/readme-file.ts index 36f4471..cdb4d4b 100644 --- a/src/readme-file.ts +++ b/src/readme-file.ts @@ -9,7 +9,12 @@ import { plural } from 'pluralize'; import { format, Options } from 'prettier'; import { NamespacedReactQueryOptions } from './types'; -import { NameFactory } from './name-factory'; +import { + buildHookName, + buildProviderName, + buildServiceName, + buildServiceHookName, +} from './name-helpers'; import { isRelayPaginaged } from './utils'; type MethodInfo = { @@ -27,8 +32,6 @@ export class ReadmeFile { private readonly options: NamespacedReactQueryOptions, ) {} - private readonly nameFactory = new NameFactory(this.service, this.options); - private import(...path: string[]) { return `./${buildFilePath(path, this.service, this.options).join('/')}`; } @@ -46,8 +49,8 @@ export class ReadmeFile { return { method, interface: int, - hookName: this.nameFactory.buildHookName(method), - suspenseHookName: this.nameFactory.buildHookName(method, { + hookName: buildHookName(method, this.service), + suspenseHookName: buildHookName(method, this.service, { suspense: true, }), importPath: this.import('hooks', kebab(plural(int.name.value))), @@ -75,8 +78,8 @@ export class ReadmeFile { return { method, interface: int, - hookName: this.nameFactory.buildHookName(method, { infinite: true }), - suspenseHookName: this.nameFactory.buildHookName(method, { + hookName: buildHookName(method, this.service, { infinite: true }), + suspenseHookName: buildHookName(method, this.service, { suspense: true, infinite: true, }), @@ -102,8 +105,8 @@ export class ReadmeFile { return { method, interface: int, - hookName: this.nameFactory.buildHookName(method), - suspenseHookName: this.nameFactory.buildHookName(method, { + hookName: buildHookName(method, this.service), + suspenseHookName: buildHookName(method, this.service, { suspense: true, }), importPath: this.import('hooks', kebab(plural(int.name.value))), @@ -150,7 +153,7 @@ For more information about React Query, [read the official docs](https://tanstac private *buildSetup(): Iterable { const contextImportPath = this.import('hooks', 'context'); - const providerName = this.nameFactory.buildProviderName(); + const providerName = buildProviderName(this.service); yield ` ## Setup @@ -348,10 +351,10 @@ Handled errors will be of type \`T\` and are generally things like validation er const queryMethod = this.queryMethod(); const int = this.service.interfaces[0]; - const serviceName = this.nameFactory.buildServiceName(int); - const serviceHookName = this.nameFactory.buildServiceHookName(int); + const serviceName = buildServiceName(int); + const serviceHookName = buildServiceHookName(int); - const providerName = this.nameFactory.buildProviderName(); + const providerName = buildProviderName(this.service); yield ` ## Services diff --git a/src/snapshot/test-utils.ts b/src/snapshot/test-utils.ts index 6f9cace..a74cb75 100644 --- a/src/snapshot/test-utils.ts +++ b/src/snapshot/test-utils.ts @@ -20,8 +20,8 @@ export async function* generateFiles(): AsyncIterable { }); for (const engine of engines) { - engine.runParser(); - engine.runGenerators(); + await engine.runParser(); + await engine.runGenerators(); for (const file of engine.files) { if (file.path[0] !== '.gitattributes') { diff --git a/src/snapshot/v1/hooks/README.md b/src/snapshot/v1/hooks/README.md new file mode 100644 index 0000000..e57cae4 --- /dev/null +++ b/src/snapshot/v1/hooks/README.md @@ -0,0 +1,183 @@ + + +# React Query Hooks + +This directory contains the generated React Query hooks that provide access to the BasketryExample v1 API. + +For more information about React Query, [read the official docs](https://tanstack.com/query/latest/docs/framework/react/overview). + +## Setup + +Wrap your application in the `BasketryExampleProvider` exported from the `context` module. This provides implementations of the interfaces that empower the query and mutation hooks. + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BasketryExampleProvider } from './v1/hooks/context'; + +export const App = () => { + const queryClient = new QueryClient(); + + return ( + + +
Your app goes here
+
+
+ ); +}; +``` + +Note that the `BasketryExampleProvider` _DOES NOT_ automatically service as a Tanstack `QueryClientProvider`. You will also need to wrap your component tree in a `QueryClientProvider`. It doesn't matter which order you wrap the components, but both are required. + +## Queries + +See: [Tanstack Query docs for Queries](https://tanstack.com/query/latest/docs/framework/react/guides/queries) + +Each query hook is the equivalent of the general `useQuery` hook with the method-specific `queryFn`, `select`, and `queryKey` properties provided. + +```tsx +import { useGizmos } from './v1/hooks/gizmos'; + +export const Example = () => { + const { data, isLoading } = useGizmos({ + /* params */ + }); + + // Use `isLoading` value to display a loading indicator + if (isLoading) return
Loading ...
; + + // Use `data` value to display the response + return ( +
+

Here is your data:

+
{JSON.stringify(data, null, 2)}
+
+ ); +}; +``` + +### Suspense + +See: [Tanstack Query docs for Suspense](https://tanstack.com/query/latest/docs/framework/react/guides/suspense) + +React Query can also be used with React's Suspense for Data Fetching API's. Each generated query hook has a Suspense variant that can be used in place of the standard hook. + +```tsx +import { useSuspenseGizmos } from './v1/hooks/gizmos'; + +export const ExampleContainer = () => ( + // Use suspense to display a loading indicator + Loading...}> + + +); + +export const Example = () => { + const { data } = useSuspenseGizmos({ + /* params */ + }); + + // Use `data` value to display the response + return ( +
+

Here is your data:

+
{JSON.stringify(data, null, 2)}
+
+ ); +}; +``` + +### QueryClient Overrides + +Both the standard and suspense hooks can be called with optional client overrides. These options are only applied to the specific query and do not affect the global QueryClient. + +```tsx +const { data } = useGizmos( + { + /* params */ + }, + { retry: 5, retryDelay: 1000 }, +); + +const { data } = useSuspenseGizmos( + { + /* params */ + }, + { retry: 5, retryDelay: 1000 }, +); +``` + +## Mutations + +See: [Tanstack Query docs for Mutations](https://tanstack.com/query/latest/docs/framework/react/guides/mutations) + +```tsx +import { useUpdateGizmo } from './v1/hooks/gizmos'; + +export const Example = () => { + const { mutate } = useUpdateGizmo({ + onSuccess: (data, variables) => { + console.log('called with variables', variables); + console.log('returned data', data); + }, + onError: console.error, + }); + + const handleClick = useCallback(() => { + mutate({ + /* params */ + }); + }, [mutate]); + + return ( +
+ +
+ ); +}; +``` + +## Error Handling + +React Query returns an `error` property from the query and mutation hooks. This value is non-null when an error has been raised. + +The generated hooks return an error of type `QueryError` where `T` is the type of error returned from the API method. This error type is a discriminated union of either a handled or unhandled error. + +Handled errors will be of type `T` and are generally things like validation errors returned in a structurd format from the API. Unhandled errors are of type `unknown` generally represent exceptions thrown during the execution of the API or the processing of the response. + +## Services + +The generated hooks make use of the generated HTTP Client service implementations. While hooks provide a React-idiomatic mechanism for interacting with your API, the raw service implmentations provide more precise, fine-gained control. + +Using the generated React Query hooks will be sufficient for most use cases; however, the services can be access from within the `BasketryExampleProvider` tree by using the hooks exported from the `context` module. + +```tsx +import { useCallback } from 'react'; +import { useGizmoService } from './v1/hooks/context'; + +export const Example = () => { + const gizmoService = useGizmoService(); + + const handleClick = useCallback(() => { + // Do something directly with the gizmo service + }, [gizmoService]); + + return ( +
+ +
+ ); +}; +``` diff --git a/src/snapshot/v1/hooks/auth-permutations.ts b/src/snapshot/v1/hooks/auth-permutations.ts new file mode 100644 index 0000000..b9b8835 --- /dev/null +++ b/src/snapshot/v1/hooks/auth-permutations.ts @@ -0,0 +1,115 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { Error } from '../types'; +import { + getAuthPermutationService, + useAuthPermutationService, +} from './context'; +import { guard, type QueryError } from './runtime'; + +// Query and mutation options exports for React Query v5 + +export const allAuthSchemesQueryOptions = () => { + const authPermutationService = getAuthPermutationService(); + return queryOptions, void>({ + queryKey: ['authPermutation', 'allAuthSchemes', {}], + queryFn: async () => { + const res = await guard(authPermutationService.allAuthSchemes()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const comboAuthSchemesMutationOptions = () => { + const authPermutationService = getAuthPermutationService(); + return mutationOptions({ + mutationFn: async () => { + const res = await guard(authPermutationService.comboAuthSchemes()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +// Legacy hooks - deprecated, use query/mutation options exports instead + +/** @deprecated Use allAuthSchemesQueryOptions with useQuery instead */ +export function useAllAuthSchemes( + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = allAuthSchemesQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use allAuthSchemesQueryOptions with useSuspenseQuery instead */ +export function useSuspenseAllAuthSchemes( + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = allAuthSchemesQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use comboAuthSchemesMutationOptions with useMutation instead */ +export function useComboAuthSchemes( + options?: Omit>, 'mutationFn'>, +) { + const queryClient = useQueryClient(); + const authPermutationService = useAuthPermutationService(); + return useMutation({ + mutationFn: async () => { + const res = await guard(authPermutationService.comboAuthSchemes()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['authPermutation'] }); + return res.data; + }, + ...options, + }); +} diff --git a/src/snapshot/v1/hooks/context.tsx b/src/snapshot/v1/hooks/context.tsx new file mode 100644 index 0000000..fe628f2 --- /dev/null +++ b/src/snapshot/v1/hooks/context.tsx @@ -0,0 +1,174 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import { + createContext, + type FC, + type PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import { + type BasketryExampleOptions, + type FetchLike, + HttpAuthPermutationService, + HttpExhaustiveService, + HttpGizmoService, + HttpWidgetService, +} from '../http-client'; +import type { + AuthPermutationService, + ExhaustiveService, + GizmoService, + WidgetService, +} from '../types'; + +export interface BasketryExampleContextProps extends BasketryExampleOptions { + fetch?: FetchLike; +} +const BasketryExampleContext = createContext< + BasketryExampleContextProps | undefined +>(undefined); + +let currentContext: BasketryExampleContextProps | undefined; + +export const BasketryExampleProvider: FC< + PropsWithChildren +> = ({ children, ...props }) => { + const value = useMemo( + () => ({ ...props }), + [ + props.fetch, + props.mapUnhandledException, + props.mapValidationError, + props.root, + ], + ); + currentContext = value; + return ( + + {children} + + ); +}; + +export const getAuthPermutationService = () => { + if (!currentContext) { + throw new Error( + 'getAuthPermutationService called outside of BasketryExampleProvider', + ); + } + const authPermutationService: AuthPermutationService = + new HttpAuthPermutationService( + currentContext.fetch ?? window.fetch.bind(window), + currentContext, + ); + return authPermutationService; +}; + +export const useAuthPermutationService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useAuthPermutationService must be used within a BasketryExampleProvider', + ); + } + const authPermutationService: AuthPermutationService = + new HttpAuthPermutationService( + context.fetch ?? window.fetch.bind(window), + context, + ); + return authPermutationService; +}; + +export const getExhaustiveService = () => { + if (!currentContext) { + throw new Error( + 'getExhaustiveService called outside of BasketryExampleProvider', + ); + } + const exhaustiveService: ExhaustiveService = new HttpExhaustiveService( + currentContext.fetch ?? window.fetch.bind(window), + currentContext, + ); + return exhaustiveService; +}; + +export const useExhaustiveService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useExhaustiveService must be used within a BasketryExampleProvider', + ); + } + const exhaustiveService: ExhaustiveService = new HttpExhaustiveService( + context.fetch ?? window.fetch.bind(window), + context, + ); + return exhaustiveService; +}; + +export const getGizmoService = () => { + if (!currentContext) { + throw new Error( + 'getGizmoService called outside of BasketryExampleProvider', + ); + } + const gizmoService: GizmoService = new HttpGizmoService( + currentContext.fetch ?? window.fetch.bind(window), + currentContext, + ); + return gizmoService; +}; + +export const useGizmoService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useGizmoService must be used within a BasketryExampleProvider', + ); + } + const gizmoService: GizmoService = new HttpGizmoService( + context.fetch ?? window.fetch.bind(window), + context, + ); + return gizmoService; +}; + +export const getWidgetService = () => { + if (!currentContext) { + throw new Error( + 'getWidgetService called outside of BasketryExampleProvider', + ); + } + const widgetService: WidgetService = new HttpWidgetService( + currentContext.fetch ?? window.fetch.bind(window), + currentContext, + ); + return widgetService; +}; + +export const useWidgetService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useWidgetService must be used within a BasketryExampleProvider', + ); + } + const widgetService: WidgetService = new HttpWidgetService( + context.fetch ?? window.fetch.bind(window), + context, + ); + return widgetService; +}; diff --git a/src/snapshot/v1/hooks/exhaustives.ts b/src/snapshot/v1/hooks/exhaustives.ts new file mode 100644 index 0000000..06285c4 --- /dev/null +++ b/src/snapshot/v1/hooks/exhaustives.ts @@ -0,0 +1,121 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import { + queryOptions, + type UndefinedInitialDataOptions, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + Error, + ExhaustiveFormatsParams, + ExhaustiveParamsParams, +} from '../types'; +import { getExhaustiveService } from './context'; +import { guard, type QueryError } from './runtime'; + +// Query and mutation options exports for React Query v5 + +export const exhaustiveFormatsQueryOptions = ( + params?: ExhaustiveFormatsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions, void>({ + queryKey: ['exhaustive', 'exhaustiveFormats', params || {}], + queryFn: async () => { + const res = await guard(exhaustiveService.exhaustiveFormats(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const exhaustiveParamsQueryOptions = ( + params: ExhaustiveParamsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions, void>({ + queryKey: ['exhaustive', 'exhaustiveParams', params || {}], + queryFn: async () => { + const res = await guard(exhaustiveService.exhaustiveParams(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + select: (data) => data.data, + }); +}; + +// Legacy hooks - deprecated, use query/mutation options exports instead + +/** @deprecated Use exhaustiveFormatsQueryOptions with useQuery instead */ +export function useExhaustiveFormats( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = exhaustiveFormatsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use exhaustiveFormatsQueryOptions with useSuspenseQuery instead */ +export function useSuspenseExhaustiveFormats( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = exhaustiveFormatsQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use exhaustiveParamsQueryOptions with useQuery instead */ +export function useExhaustiveParams( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = exhaustiveParamsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use exhaustiveParamsQueryOptions with useSuspenseQuery instead */ +export function useSuspenseExhaustiveParams( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = exhaustiveParamsQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} diff --git a/src/snapshot/v1/hooks/gizmos.ts b/src/snapshot/v1/hooks/gizmos.ts new file mode 100644 index 0000000..db152f2 --- /dev/null +++ b/src/snapshot/v1/hooks/gizmos.ts @@ -0,0 +1,191 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateGizmoParams, + Error, + GetGizmosParams, + GetGizmosResponse, + UpdateGizmoParams, +} from '../types'; +import { getGizmoService, useGizmoService } from './context'; +import { guard, type QueryError } from './runtime'; + +// Query and mutation options exports for React Query v5 + +/** + * Only has a summary + * + * @deprecated + */ +export const getGizmosQueryOptions = (params?: GetGizmosParams) => { + const gizmoService = getGizmoService(); + return queryOptions, void>({ + queryKey: ['gizmo', 'getGizmos', params || {}], + queryFn: async () => { + const res = await guard(gizmoService.getGizmos(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const updateGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: UpdateGizmoParams) => { + const res = await guard(gizmoService.updateGizmo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +/** + * Has a summary in addition to a description + * + * Has a description in addition to a summary + */ +export const createGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: CreateGizmoParams) => { + const res = await guard(gizmoService.createGizmo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +// Legacy hooks - deprecated, use query/mutation options exports instead + +/** + * Has a summary in addition to a description + * + * Has a description in addition to a summary + */ +/** @deprecated Use createGizmoMutationOptions with useMutation instead */ +export function useCreateGizmo( + options?: Omit< + UseMutationOptions, CreateGizmoParams>, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const gizmoService = useGizmoService(); + return useMutation({ + mutationFn: async (params?: CreateGizmoParams) => { + const res = await guard(gizmoService.createGizmo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + return res.data; + }, + ...options, + }); +} + +/** + * Only has a summary + * + * @deprecated + */ +/** @deprecated Use getGizmosQueryOptions with useQuery instead */ +export function useGizmos( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getGizmosQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * Only has a summary + * + * @deprecated + */ +/** @deprecated Use getGizmosQueryOptions with useSuspenseQuery instead */ +export function useSuspenseGizmos( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions, void>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getGizmosQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use updateGizmoMutationOptions with useMutation instead */ +export function useUpdateGizmo( + options?: Omit< + UseMutationOptions, UpdateGizmoParams>, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const gizmoService = useGizmoService(); + return useMutation({ + mutationFn: async (params?: UpdateGizmoParams) => { + const res = await guard(gizmoService.updateGizmo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + return res.data; + }, + ...options, + }); +} diff --git a/src/snapshot/v1/hooks/query-key-builder.ts b/src/snapshot/v1/hooks/query-key-builder.ts new file mode 100644 index 0000000..8f2463a --- /dev/null +++ b/src/snapshot/v1/hooks/query-key-builder.ts @@ -0,0 +1,127 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import type { + AllAuthSchemesParams, + ComboAuthSchemesParams, + CreateGizmoParams, + CreateWidgetParams, + DeleteWidgetFooParams, + ExhaustiveFormatsParams, + ExhaustiveParamsParams, + GetGizmosParams, + GetWidgetFooParams, + GetWidgetsParams, + PutWidgetParams, + UpdateGizmoParams, +} from '../types'; + +/** + * Type mapping for all available query keys in the service + */ +export interface QueryKeyMap { + gizmo: { + getGizmos: GetGizmosParams | undefined; + updateGizmo: UpdateGizmoParams | undefined; + createGizmo: CreateGizmoParams | undefined; + }; + widget: { + getWidgets: GetWidgetsParams | undefined; + putWidget: PutWidgetParams | undefined; + createWidget: CreateWidgetParams | undefined; + getWidgetFoo: GetWidgetFooParams | undefined; + deleteWidgetFoo: DeleteWidgetFooParams | undefined; + }; + exhaustive: { + exhaustiveFormats: ExhaustiveFormatsParams | undefined; + exhaustiveParams: ExhaustiveParamsParams; + }; + authPermutation: { + allAuthSchemes: AllAuthSchemesParams | undefined; + comboAuthSchemes: ComboAuthSchemesParams | undefined; + }; +} + +/** + * Extract all service names from QueryKeyMap + */ +export type ServiceKeys = keyof QueryKeyMap; + +/** + * Extract operation names for a given service + */ +export type OperationKeys = keyof QueryKeyMap[S]; + +/** + * Extract parameter type for a given service and operation + */ +export type OperationParams< + S extends ServiceKeys, + O extends OperationKeys, +> = QueryKeyMap[S][O]; + +/** + * Build type-safe query keys for React Query cache operations + * + * @example + * // Match all queries for a service + * matchQueryKey("widget") + * // Returns: ["widget"] + * + * @example + * // Match all queries for a specific operation + * matchQueryKey("widget", "getWidgets") + * // Returns: ["widget", "getWidgets"] + * + * @example + * // Match specific query with parameters + * matchQueryKey("widget", "getWidgets", { status: "active" }) + * // Returns: ["widget", "getWidgets", { status: "active" }] + */ +export function matchQueryKey(service: S): readonly [S]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>(service: S, operation: O): readonly [S, O]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>( + service: S, + operation: O, + params: OperationParams extends undefined + ? undefined + : OperationParams, +): readonly [ + S, + O, + OperationParams extends undefined ? {} : OperationParams, +]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>(service: S, operation?: O, params?: OperationParams) { + if (arguments.length === 3 && operation !== undefined) { + // When called with 3 arguments, always include params (use {} if undefined) + const finalParams = params === undefined ? {} : params; + return [service, operation, finalParams] as const; + } + if (operation !== undefined) { + return [service, operation] as const; + } + return [service] as const; +} diff --git a/src/snapshot/v1/hooks/runtime.ts b/src/snapshot/v1/hooks/runtime.ts new file mode 100644 index 0000000..94298a7 --- /dev/null +++ b/src/snapshot/v1/hooks/runtime.ts @@ -0,0 +1,128 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import type { + GetNextPageParamFunction, + GetPreviousPageParamFunction, +} from '@tanstack/react-query'; + +export type PageParam = { pageParam?: string }; + +export type QueryError = + | { + kind: 'handled'; + payload: THandledError; + } + | { + kind: 'unhandled'; + payload: unknown; + }; + +export async function guard(fn: Promise): Promise { + try { + return await fn; + } catch (payload) { + console.error(payload); + const unhandled: QueryError = { kind: 'unhandled', payload }; + throw unhandled; + } +} + +export function assert(value: T | null | undefined): asserts value { + if (value === null || value === undefined) { + throw new Error('Expected value to be defined'); + } +} + +export type RelayParams = { + first?: number; + after?: string; + last?: number; + before?: string; +}; + +export type Response = { + pageInfo?: { + startCursor?: string; + hasPreviousPage: boolean; + hasNextPage: boolean; + endCursor?: string; + }; +}; + +export const getNextPageParam: GetNextPageParamFunction< + string | undefined, + Response +> = (lastPage) => { + return lastPage.pageInfo?.hasNextPage + ? `after:${lastPage.pageInfo.endCursor}` + : undefined; +}; + +export const getPreviousPageParam: GetPreviousPageParamFunction< + string | undefined, + Response +> = (lastPage) => { + return lastPage.pageInfo?.hasPreviousPage + ? `before:${lastPage.pageInfo.startCursor}` + : undefined; +}; + +export function applyPageParam( + params: T, + pageParam: string | undefined, +): T { + const { first, after, last, before, ...rest } = params; + const syntheticParams: T = rest as T; + + if (pageParam) { + const [key, value] = pageParam.split(':'); + + if (key === 'after') { + syntheticParams.first = first ?? last; + syntheticParams.after = value; + } else if (key === 'before') { + syntheticParams.last = last ?? first; + syntheticParams.before = value; + } + } else { + if (first) syntheticParams.first = first; + if (after) syntheticParams.after = after; + if (last) syntheticParams.last = last; + if (before) syntheticParams.before = before; + } + + return syntheticParams; +} + +export function getInitialPageParam(params: { + after?: string; + before?: string; +}): string | undefined { + if (params.after) return `after:${params.after}`; + if (params.before) return `before:${params.before}`; + return; +} + +export function compact( + params: Record, +): Record | undefined { + const result: Record = Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== null && value !== undefined, + ), + ) as any; + + return Object.keys(result).length ? result : undefined; +} diff --git a/src/snapshot/v1/hooks/widgets.ts b/src/snapshot/v1/hooks/widgets.ts new file mode 100644 index 0000000..8b63acd --- /dev/null +++ b/src/snapshot/v1/hooks/widgets.ts @@ -0,0 +1,245 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://basketry.io + * About @basketry/react-query: https://basketry.io/docs/components/@basketry/react-query + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateWidgetParams, + DeleteWidgetFooParams, + Error, + GetWidgetFooParams, + Widget, +} from '../types'; +import { getWidgetService, useWidgetService } from './context'; +import { guard, type QueryError } from './runtime'; + +// Query and mutation options exports for React Query v5 + +export const getWidgetsQueryOptions = () => { + const widgetService = getWidgetService(); + return queryOptions>({ + queryKey: ['widget', 'getWidgets', {}], + queryFn: async () => { + const res = await guard(widgetService.getWidgets()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + }); +}; + +export const putWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async () => { + const res = await guard(widgetService.putWidget()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +export const createWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params: CreateWidgetParams) => { + const res = await guard(widgetService.createWidget(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +export const getWidgetFooQueryOptions = (params?: GetWidgetFooParams) => { + const widgetService = getWidgetService(); + return queryOptions>({ + queryKey: ['widget', 'getWidgetFoo', params || {}], + queryFn: async () => { + const res = await guard(widgetService.getWidgetFoo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res; + }, + }); +}; + +export const deleteWidgetFooMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params: DeleteWidgetFooParams) => { + const res = await guard(widgetService.deleteWidgetFoo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + return res.data; + }, + }); +}; + +// Legacy hooks - deprecated, use query/mutation options exports instead + +/** @deprecated Use createWidgetMutationOptions with useMutation instead */ +export function useCreateWidget( + options?: Omit< + UseMutationOptions, CreateWidgetParams>, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async (params?: CreateWidgetParams) => { + const res = await guard(widgetService.createWidget(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** @deprecated Use deleteWidgetFooMutationOptions with useMutation instead */ +export function useDeleteWidgetFoo( + options?: Omit< + UseMutationOptions, DeleteWidgetFooParams>, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async (params?: DeleteWidgetFooParams) => { + const res = await guard(widgetService.deleteWidgetFoo(params)); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** @deprecated Use putWidgetMutationOptions with useMutation instead */ +export function usePutWidget( + options?: Omit>, 'mutationFn'>, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async () => { + const res = await guard(widgetService.putWidget()); + if (res.errors.length) { + const handled: QueryError = { + kind: 'handled', + payload: res.errors, + }; + throw handled; + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** @deprecated Use getWidgetFooQueryOptions with useQuery instead */ +export function useWidgetFoo( + params?: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getWidgetFooQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use getWidgetFooQueryOptions with useSuspenseQuery instead */ +export function useSuspenseWidgetFoo( + params?: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getWidgetFooQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use getWidgetsQueryOptions with useQuery instead */ +export function useWidgets( + options?: Omit< + UndefinedInitialDataOptions>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getWidgetsQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +} + +/** @deprecated Use getWidgetsQueryOptions with useSuspenseQuery instead */ +export function useSuspenseWidgets( + options?: Omit< + UndefinedInitialDataOptions>, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = getWidgetsQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} diff --git a/tsconfig.json b/tsconfig.json index d8f8bc7..b6462f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "strictNullChecks": true }, "include": ["src"], - "exclude": ["**/*.test?.*"] + "exclude": ["**/*.test?.*", "src/snapshot/**/*"] }