diff --git a/.gitignore b/.gitignore index b0a76c1..adad40b 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,7 @@ dist .pnp.* lib/ + +# Project-specific files +plan/ +CLAUDE.md diff --git a/.npmignore b/.npmignore index 824b66b..7d9551c 100644 --- a/.npmignore +++ b/.npmignore @@ -4,3 +4,6 @@ src **/snapshot /*.* +!codemod +codemod/__tests__ +codemod/__testfixtures__ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..153ea84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# 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.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0-alpha.1] - 2025-07-17 + +### Added + +- Backwards compatibility layer with deprecated hook wrappers + - All existing `useXxx()` hooks continue to work but are marked as deprecated + - Hooks include migration instructions in JSDoc comments + - Mutation hooks maintain automatic query invalidation behavior +- jscodeshift codemod for automated migration from v0.1.x to v0.2.x + - Automatically transforms deprecated hooks to new queryOptions pattern + - Preserves all parameters, options, and TypeScript types + - Includes dry-run mode for safe preview of changes + +### Changed + +- Re-added deprecated hooks alongside new queryOptions exports for smoother migration path + +## [0.2.0-alpha.0] - 2025-07-17 + +### Changed + +- **BREAKING**: Migrated from wrapper hooks to queryOptions/mutationOptions export pattern + - Changed from `useWidgets()` to `getWidgetsQueryOptions()` + - Changed from `useCreateWidget()` to `createWidgetMutationOptions()` + - Changed from `useInfiniteWidgets()` to `getWidgetsInfiniteQueryOptions()` +- **BREAKING**: Updated query key structure for better cache management + - From: `['/widgets', compact({ status: params?.status })].filter(Boolean)` + - To: `['widget', 'getWidgets', params || {}] as const` +- Added non-hook service getters in context for use in queryOptions + +### Added + +- Type-safe `matchQueryKey` function for building query keys with IntelliSense support + - Supports partial query matching at service, operation, or full parameter levels + - Provides compile-time type safety and autocomplete for all query operations + - Enables flexible cache invalidation patterns +- Test coverage for infinite query options generation +- Support for direct composition with React Query hooks +- Better TypeScript inference with queryOptions pattern + +### Removed + +- Wrapper hook functions (use queryOptions with React Query hooks directly) +- Complex query key filtering logic + +## [0.1.x] - Previous versions + +Initial implementation with wrapper hooks pattern. diff --git a/README.md b/README.md index d16892a..c8aac68 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,106 @@ # 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](https://tanstack.com/query) (TanStack Query) hooks and query/mutation options. This generator can be coupled with any Basketry parser to automatically generate type-safe React Query integration from your API definitions. -## Quick Start +## Features -// TODO +- Generates type-safe query and mutation options following React Query v5 patterns +- Type-safe query key builder for cache operations with IntelliSense support +- Support for infinite queries with Relay-style pagination +- Full TypeScript support with proper type inference +- Backwards compatibility with deprecated hook wrappers for smooth migration ---- +## Migration Guide (v0.1.x to v0.2.x) + +Starting with v0.2.0, this generator adopts the React Query v5 queryOptions pattern. The old hook wrappers are deprecated but still available for backwards compatibility. + +### Query Hooks + +```typescript +// Old pattern (deprecated) +import { useGetWidgets } from './hooks/widgets'; +const result = useGetWidgets(params); + +// New pattern +import { useQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from './hooks/widgets'; +const result = useQuery(getWidgetsQueryOptions(params)); +``` + +### Mutation Hooks + +```typescript +// Old pattern (deprecated) +import { useCreateWidget } from './hooks/widgets'; +const mutation = useCreateWidget(); + +// New pattern +import { useMutation } from '@tanstack/react-query'; +import { createWidgetMutationOptions } from './hooks/widgets'; +const mutation = useMutation(createWidgetMutationOptions()); +``` + +### Infinite Query Hooks + +```typescript +// Old pattern (deprecated) +import { useGetWidgetsInfinite } from './hooks/widgets'; +const result = useGetWidgetsInfinite(params); + +// New pattern +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getWidgetsInfiniteQueryOptions } from './hooks/widgets'; +const result = useInfiniteQuery(getWidgetsInfiniteQueryOptions(params)); +``` + +### Query Key Builder + +The new version includes a type-safe query key builder for cache operations: + +```typescript +import { matchQueryKey } from './hooks/query-key-builder'; + +// Invalidate all queries for a service +queryClient.invalidateQueries({ queryKey: matchQueryKey('widgets') }); + +// Invalidate specific operation +queryClient.invalidateQueries({ + queryKey: matchQueryKey('widgets', 'getWidgets'), +}); + +// Invalidate with specific parameters +queryClient.invalidateQueries({ + queryKey: matchQueryKey('widgets', 'getWidgets', { status: 'active' }), +}); +``` + +### Benefits of the New Pattern + +- Better tree-shaking - only import what you use +- More flexible - compose with any React Query hook +- Better TypeScript inference +- Easier testing - options can be tested without React context +- Consistent with React Query v5 best practices + +### Automated Migration + +We provide a jscodeshift codemod to automatically migrate your codebase: + +```bash +# Using the provided script +./node_modules/@basketry/react-query/codemod/run-migration.sh # Preview (dry run) +./node_modules/@basketry/react-query/codemod/run-migration.sh --apply # Apply changes + +# Or using jscodeshift directly +npx jscodeshift -t ./node_modules/@basketry/react-query/codemod/react-query-v0.2-migration.js \ + src/ --extensions=ts,tsx --parser=tsx --dry # Preview (dry run) + +npx jscodeshift -t ./node_modules/@basketry/react-query/codemod/react-query-v0.2-migration.js \ + src/ --extensions=ts,tsx --parser=tsx # Apply changes +``` + +See [codemod documentation](./codemod/README.md) for more details. ## For contributors: diff --git a/codemod/README.md b/codemod/README.md new file mode 100644 index 0000000..6e8daf2 --- /dev/null +++ b/codemod/README.md @@ -0,0 +1,203 @@ +# React Query v0.2 Migration Codemod + +This codemod helps automatically migrate your codebase from `@basketry/react-query` v0.1.x to v0.2.x by transforming deprecated hook patterns to the new queryOptions pattern. + +## What it does + +The codemod will transform: + +### Query Hooks + +```typescript +// Before +import { useGetWidgets } from '../hooks/widgets'; +const { data } = useGetWidgets({ status: 'active' }); + +// After +import { useQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from '../hooks/widgets'; +const { data } = useQuery(getWidgetsQueryOptions({ status: 'active' })); +``` + +### Mutation Hooks + +```typescript +// Before +import { useCreateWidget } from '../hooks/widgets'; +const mutation = useCreateWidget({ onSuccess: handleSuccess }); + +// After +import { useMutation } from '@tanstack/react-query'; +import { createWidgetMutationOptions } from '../hooks/widgets'; +const mutation = useMutation( + createWidgetMutationOptions({ onSuccess: handleSuccess }), +); +``` + +### Infinite Query Hooks + +```typescript +// Before +import { useGetWidgetsInfinite } from '../hooks/widgets'; +const { data, fetchNextPage } = useGetWidgetsInfinite({ limit: 20 }); + +// After +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getWidgetsInfiniteQueryOptions } from '../hooks/widgets'; +const { data, fetchNextPage } = useInfiniteQuery( + getWidgetsInfiniteQueryOptions({ limit: 20 }), +); +``` + +### Suspense Hooks + +```typescript +// Before +import { useSuspenseGetWidgets } from '../hooks/widgets'; +const { data } = useSuspenseGetWidgets(); + +// After +import { useSuspenseQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from '../hooks/widgets'; +const { data } = useSuspenseQuery(getWidgetsQueryOptions()); +``` + +## Installation + +```bash +# Install jscodeshift globally +npm install -g jscodeshift + +# Or use npx (no installation needed) +npx jscodeshift ... +``` + +## Usage + +### Basic Usage + +```bash +# Dry run (preview changes without modifying files) +jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx --dry + +# Run the transformation +jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx +``` + +### Specific Files or Directories + +```bash +# Transform a single file +jscodeshift -t codemod/react-query-v0.2-migration.js src/components/WidgetList.tsx --parser=tsx + +# Transform a specific directory +jscodeshift -t codemod/react-query-v0.2-migration.js src/features/widgets/ --extensions=ts,tsx --parser=tsx +``` + +### With Git + +```bash +# See what would change +jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx --dry + +# Run and see the diff +jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx +git diff + +# If something went wrong, revert +git checkout -- . +``` + +## Features + +- ✅ Transforms all deprecated hook types (query, mutation, infinite, suspense) +- ✅ Preserves TypeScript type parameters +- ✅ Updates imports correctly +- ✅ Handles multiple hooks from the same module +- ✅ Adds React Query imports only when needed +- ✅ Preserves existing React Query imports +- ✅ Maintains code formatting +- ✅ Only transforms hooks from generated `hooks/` modules + +## Limitations + +1. **Hooks in Dynamic Contexts**: The codemod may not handle hooks called in complex dynamic contexts (e.g., inside conditional logic or loops). + +2. **Custom Wrappers**: If you've created custom wrappers around the generated hooks, those won't be automatically migrated. + +3. **Import Aliases**: If you're using import aliases or renamed imports, you may need to update those manually: + + ```typescript + // This won't be transformed automatically + import { useGetWidgets as useWidgets } from '../hooks/widgets'; + ``` + +4. **Side Effects**: The old mutation hooks automatically invalidated queries on success. The new pattern requires you to handle this in your mutationOptions if needed. + +## Testing the Codemod + +### Run Tests + +```bash +# Install dependencies +npm install + +# Run the test suite +npm test codemod/__tests__/react-query-v0.2-migration.test.js +``` + +### Test on a Single File + +```bash +# Create a test file +echo "import { useGetWidgets } from './hooks/widgets'; +const Component = () => { + const { data } = useGetWidgets(); + return
{data?.length}
; +};" > test-migration.tsx + +# Run the codemod +jscodeshift -t codemod/react-query-v0.2-migration.js test-migration.tsx --parser=tsx --print +``` + +## Manual Review Checklist + +After running the codemod, review: + +1. **Build**: Run `npm run build` to ensure no TypeScript errors +2. **Tests**: Run your test suite to ensure functionality is preserved +3. **Mutations**: Check that mutation success handlers still invalidate queries if needed +4. **Imports**: Verify all imports are correct and no duplicates exist +5. **Runtime**: Test your application to ensure everything works as expected + +## Troubleshooting + +### "Cannot find module" errors + +Make sure you're running the codemod from your project root where `node_modules` is located. + +### Parser errors + +Ensure you're using the `--parser=tsx` flag for TypeScript files. + +### Nothing is transformed + +Check that your imports match the expected pattern (from `'../hooks/[service]'` modules). + +### Formatting issues + +The codemod tries to preserve formatting, but you may want to run your formatter after: + +```bash +npm run prettier -- --write src/ +# or +npm run eslint -- --fix src/ +``` + +## Need Help? + +If you encounter issues: + +1. Check the [migration guide](../README.md#migration-guide-v01x-to-v02x) in the main README +2. Look at the generated hooks to understand the new pattern +3. Open an issue with a code sample that isn't working correctly diff --git a/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx b/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx new file mode 100644 index 0000000..264582e --- /dev/null +++ b/codemod/__testfixtures__/react-query-v0.2-migration.input.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { + useGetWidgets, + useGetWidget, + useCreateWidget, + useUpdateWidget, + useDeleteWidget, + useGetWidgetsInfinite, + useSuspenseGetWidgets, + useSuspenseGetWidgetsInfinite, +} from '../hooks/widgets'; +import { useGetGizmos, useCreateGizmo } from '../hooks/gizmos'; +import { SomeOtherExport } from '../hooks/widgets'; + +// Simple query hook usage +export function WidgetList() { + const { data, isLoading } = useGetWidgets({ status: 'active' }); + + if (isLoading) return
Loading...
; + + return ( + + ); +} + +// Query with type parameters +export function TypedWidgetDetail({ id }: { id: string }) { + const { data } = useGetWidget({ id }); + return
{data?.customField}
; +} + +// Mutation hook usage +export function CreateWidgetForm() { + const createWidget = useCreateWidget({ + onSuccess: (data) => { + console.log('Created widget:', data); + }, + onError: (error) => { + console.error('Failed to create widget:', error); + }, + }); + + const updateWidget = useUpdateWidget(); + const deleteWidget = useDeleteWidget(); + + return ( +
{ + e.preventDefault(); + createWidget.mutate({ name: 'New Widget' }); + }} + > + +
+ ); +} + +// Infinite query usage +export function InfiniteWidgetList() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useGetWidgetsInfinite({ limit: 20 }); + + return ( +
+ {data?.pages.map((page, i) => ( +
+ {page.items.map((widget) => ( +
{widget.name}
+ ))} +
+ ))} + +
+ ); +} + +// Suspense query usage +export function SuspenseWidgetList() { + const { data } = useSuspenseGetWidgets({ status: 'active' }); + + return ( + + ); +} + +// Suspense infinite query usage +export function SuspenseInfiniteWidgets() { + const { data, fetchNextPage } = useSuspenseGetWidgetsInfinite({ + limit: 10, + sort: 'name', + }); + + return ( +
+ {data.pages.map((page, i) => ( + + {page.items.map((widget) => ( +
{widget.name}
+ ))} +
+ ))} + +
+ ); +} + +// Multiple hooks from different services +export function MultiServiceComponent() { + const widgets = useGetWidgets(); + const gizmos = useGetGizmos({ type: 'advanced' }); + const createGizmo = useCreateGizmo(); + + return ( +
+

Widgets: {widgets.data?.items.length || 0}

+

Gizmos: {gizmos.data?.items.length || 0}

+ +
+ ); +} + +// Edge case: hook in a callback +export function CallbackComponent() { + const fetchData = React.useCallback(() => { + const result = useGetWidgets({ limit: 5 }); + return result; + }, []); + + return
Callback component
; +} + +// Custom type definitions for testing +interface CustomWidget extends Widget { + customField: string; +} diff --git a/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx b/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx new file mode 100644 index 0000000..c8d3c7b --- /dev/null +++ b/codemod/__testfixtures__/react-query-v0.2-migration.output.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { + useQuery, + useMutation, + useInfiniteQuery, + useSuspenseQuery, + useSuspenseInfiniteQuery, +} from '@tanstack/react-query'; +import { + getWidgetsQueryOptions, + getWidgetQueryOptions, + createWidgetMutationOptions, + updateWidgetMutationOptions, + deleteWidgetMutationOptions, + getWidgetsInfiniteQueryOptions, + SomeOtherExport, +} from '../hooks/widgets'; +import { + getGizmosQueryOptions, + createGizmoMutationOptions, +} from '../hooks/gizmos'; + +// Simple query hook usage +export function WidgetList() { + const { data, isLoading } = useQuery( + getWidgetsQueryOptions({ status: 'active' }), + ); + + if (isLoading) return
Loading...
; + + return ( + + ); +} + +// Query with type parameters +export function TypedWidgetDetail({ id }: { id: string }) { + const { data } = useQuery(getWidgetQueryOptions({ id })); + return
{data?.customField}
; +} + +// Mutation hook usage +export function CreateWidgetForm() { + const createWidget = useMutation( + createWidgetMutationOptions({ + onSuccess: (data) => { + console.log('Created widget:', data); + }, + onError: (error) => { + console.error('Failed to create widget:', error); + }, + }), + ); + + const updateWidget = useMutation(updateWidgetMutationOptions()); + const deleteWidget = useMutation(deleteWidgetMutationOptions()); + + return ( +
{ + e.preventDefault(); + createWidget.mutate({ name: 'New Widget' }); + }} + > + +
+ ); +} + +// Infinite query usage +export function InfiniteWidgetList() { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery(getWidgetsInfiniteQueryOptions({ limit: 20 })); + + return ( +
+ {data?.pages.map((page, i) => ( +
+ {page.items.map((widget) => ( +
{widget.name}
+ ))} +
+ ))} + +
+ ); +} + +// Suspense query usage +export function SuspenseWidgetList() { + const { data } = useSuspenseQuery( + getWidgetsQueryOptions({ status: 'active' }), + ); + + return ( + + ); +} + +// Suspense infinite query usage +export function SuspenseInfiniteWidgets() { + const { data, fetchNextPage } = useSuspenseInfiniteQuery( + getWidgetsInfiniteQueryOptions({ + limit: 10, + sort: 'name', + }), + ); + + return ( +
+ {data.pages.map((page, i) => ( + + {page.items.map((widget) => ( +
{widget.name}
+ ))} +
+ ))} + +
+ ); +} + +// Multiple hooks from different services +export function MultiServiceComponent() { + const widgets = useQuery(getWidgetsQueryOptions()); + const gizmos = useQuery(getGizmosQueryOptions({ type: 'advanced' })); + const createGizmo = useMutation(createGizmoMutationOptions()); + + return ( +
+

Widgets: {widgets.data?.items.length || 0}

+

Gizmos: {gizmos.data?.items.length || 0}

+ +
+ ); +} + +// Edge case: hook in a callback +export function CallbackComponent() { + const fetchData = React.useCallback(() => { + const result = useQuery(getWidgetsQueryOptions({ limit: 5 })); + return result; + }, []); + + return
Callback component
; +} + +// Custom type definitions for testing +interface CustomWidget extends Widget { + customField: string; +} diff --git a/codemod/__tests__/react-query-v0.2-migration.test.js b/codemod/__tests__/react-query-v0.2-migration.test.js new file mode 100644 index 0000000..8959ecf --- /dev/null +++ b/codemod/__tests__/react-query-v0.2-migration.test.js @@ -0,0 +1,169 @@ +const { defineTest } = require('jscodeshift/dist/testUtils'); + +// Basic transformation test +defineTest( + __dirname, + '../react-query-v0.2-migration', + {}, + 'react-query-v0.2-migration', + { parser: 'tsx' }, +); + +// You can also add more specific tests +describe('react-query-v0.2-migration codemod', () => { + const jscodeshift = require('jscodeshift'); + const transform = require('../react-query-v0.2-migration'); + + const transformOptions = { + jscodeshift, + stats: () => {}, + report: () => {}, + }; + + it('should transform simple query hooks', () => { + const input = ` +import { useGetWidgets } from '../hooks/widgets'; + +function Component() { + const { data } = useGetWidgets({ limit: 10 }); + return
{data?.length}
; +} +`; + + const expected = ` +import { useQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from '../hooks/widgets'; + +function Component() { + const { data } = useQuery(getWidgetsQueryOptions({ limit: 10 })); + return
{data?.length}
; +} +`; + + const result = transform( + { path: 'test.tsx', source: input }, + transformOptions, + ); + + expect(result).toBe(expected.trim()); + }); + + it('should preserve type parameters', () => { + const input = ` +import { useGetWidget } from '../hooks/widgets'; + +function Component() { + const { data } = useGetWidget({ id: '123' }); + return
{data?.name}
; +} +`; + + const expected = ` +import { useQuery } from '@tanstack/react-query'; +import { getWidgetQueryOptions } from '../hooks/widgets'; + +function Component() { + const { data } = useQuery(getWidgetQueryOptions({ id: '123' })); + return
{data?.name}
; +} +`; + + const result = transform( + { path: 'test.tsx', source: input }, + transformOptions, + ); + + expect(result).toBe(expected.trim()); + }); + + it('should handle multiple hooks from same module', () => { + const input = ` +import { useGetWidgets, useCreateWidget, useUpdateWidget } from '../hooks/widgets'; + +function Component() { + const widgets = useGetWidgets(); + const create = useCreateWidget(); + const update = useUpdateWidget(); + + return
Test
; +} +`; + + const expected = ` +import { + useQuery, + useMutation, +} from '@tanstack/react-query'; +import { getWidgetsQueryOptions, createWidgetMutationOptions, updateWidgetMutationOptions } from '../hooks/widgets'; + +function Component() { + const widgets = useQuery(getWidgetsQueryOptions()); + const create = useMutation(createWidgetMutationOptions()); + const update = useMutation(updateWidgetMutationOptions()); + + return
Test
; +} +`; + + const result = transform( + { path: 'test.tsx', source: input }, + transformOptions, + ); + + expect(result).toBe(expected.trim()); + }); + + it('should not transform non-generated hooks', () => { + const input = ` +import { useCustomHook } from './custom-hooks'; +import { useState } from 'react'; + +function Component() { + const custom = useCustomHook(); + const [state, setState] = useState(); + + return
Test
; +} +`; + + const result = transform( + { path: 'test.tsx', source: input }, + transformOptions, + ); + + expect(result).toBe(input); + }); + + it('should handle existing React Query imports', () => { + const input = ` +import { useQueryClient } from '@tanstack/react-query'; +import { useGetWidgets } from '../hooks/widgets'; + +function Component() { + const queryClient = useQueryClient(); + const { data } = useGetWidgets(); + + return
{data?.length}
; +} +`; + + const expected = ` +import { useQueryClient, useQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from '../hooks/widgets'; + +function Component() { + const queryClient = useQueryClient(); + const { data } = useQuery(getWidgetsQueryOptions()); + + return
{data?.length}
; +} +`; + + const result = transform( + { path: 'test.tsx', source: input }, + transformOptions, + ); + + expect(result).toBe(expected.trim()); + }); +}); diff --git a/codemod/react-query-v0.2-migration.js b/codemod/react-query-v0.2-migration.js new file mode 100644 index 0000000..aaf10c9 --- /dev/null +++ b/codemod/react-query-v0.2-migration.js @@ -0,0 +1,258 @@ +/** + * jscodeshift codemod for migrating @basketry/react-query from v0.1.x to v0.2.x + * + * This transform will: + * 1. Replace deprecated hook calls with new queryOptions pattern + * 2. Update imports to include necessary React Query hooks + * 3. Preserve all arguments and type parameters + * 4. Handle query, mutation, and infinite query patterns + * + * Usage: + * jscodeshift -t codemod/react-query-v0.2-migration.js src/ --extensions=ts,tsx --parser=tsx + */ + +module.exports = function transformer(fileInfo, api) { + const j = api.jscodeshift; + const root = j(fileInfo.source); + + let hasModifications = false; + const reactQueryImportsToAdd = new Set(); + const hookImportsToRemove = new Set(); + const optionsImportsToAdd = new Map(); // Map> + + // Helper to convert hook name to options name + function getOptionsName(hookName, type) { + // Remove 'use' prefix and convert to camelCase + let baseName = hookName.substring(3); + + // Handle suspense prefix + if (baseName.startsWith('Suspense')) { + baseName = baseName.substring(8); // Remove 'Suspense' + } + + // For query hooks, check if we need to add back "get" prefix + // If the hook was "useWidgets" it came from "getWidgets", so options should be "getWidgetsQueryOptions" + if ( + (type === 'query' || + type === 'suspense' || + type === 'infinite' || + type === 'suspenseInfinite') && + !baseName.toLowerCase().startsWith('get') && + !hookName.match( + /use(Create|Update|Delete|Add|Remove|Set|Save|Post|Put|Patch)/, + ) + ) { + baseName = 'get' + baseName; + } + + const camelCaseName = baseName.charAt(0).toLowerCase() + baseName.slice(1); + + switch (type) { + case 'infinite': + // useWidgetsInfinite -> getWidgetsInfiniteQueryOptions + return camelCaseName.replace(/Infinite$/, '') + 'InfiniteQueryOptions'; + case 'suspenseInfinite': + // useSuspenseWidgetsInfinite -> getWidgetsInfiniteQueryOptions + return camelCaseName.replace(/Infinite$/, '') + 'InfiniteQueryOptions'; + case 'suspense': + // useSuspenseWidgets -> getWidgetsQueryOptions + return camelCaseName + 'QueryOptions'; + case 'mutation': + // useCreateWidget -> createWidgetMutationOptions + return camelCaseName + 'MutationOptions'; + default: + // useWidgets -> getWidgetsQueryOptions + return camelCaseName + 'QueryOptions'; + } + } + + // Helper to determine hook type + function getHookType(hookName) { + if (hookName.includes('useSuspense') && hookName.endsWith('Infinite')) { + return 'suspenseInfinite'; + } + if (hookName.endsWith('Infinite')) { + return 'infinite'; + } + if (hookName.startsWith('useSuspense')) { + return 'suspense'; + } + // Check if it's likely a mutation (contains Create, Update, Delete, etc.) + if ( + hookName.match( + /use(Create|Update|Delete|Add|Remove|Set|Save|Post|Put|Patch)/, + ) + ) { + return 'mutation'; + } + return 'query'; + } + + // Helper to get the React Query hook name for a given type + function getReactQueryHook(type) { + switch (type) { + case 'infinite': + return 'useInfiniteQuery'; + case 'suspenseInfinite': + return 'useSuspenseInfiniteQuery'; + case 'suspense': + return 'useSuspenseQuery'; + case 'mutation': + return 'useMutation'; + default: + return 'useQuery'; + } + } + + // Find all imports from hooks modules + const hookImports = new Map(); // Map + + root + .find(j.ImportDeclaration) + .filter((path) => { + const source = path.node.source.value; + return source.includes('/hooks/') && !source.includes('/hooks/runtime'); + }) + .forEach((path) => { + const modulePath = path.node.source.value; + path.node.specifiers.forEach((spec) => { + if ( + j.ImportSpecifier.check(spec) && + spec.imported.name.startsWith('use') + ) { + hookImports.set(spec.imported.name, modulePath); + } + }); + }); + + // Transform hook calls + root + .find(j.CallExpression) + .filter((path) => { + const callee = path.node.callee; + if (j.Identifier.check(callee)) { + return hookImports.has(callee.name); + } + return false; + }) + .forEach((path) => { + const hookName = path.node.callee.name; + const modulePath = hookImports.get(hookName); + const hookType = getHookType(hookName); + const optionsName = getOptionsName(hookName, hookType); + const reactQueryHook = getReactQueryHook(hookType); + + hasModifications = true; + hookImportsToRemove.add(hookName); + reactQueryImportsToAdd.add(reactQueryHook); + + // Track options import to add + if (!optionsImportsToAdd.has(modulePath)) { + optionsImportsToAdd.set(modulePath, new Set()); + } + optionsImportsToAdd.get(modulePath).add(optionsName); + + // Get the type parameters if any + const typeParams = path.node.typeParameters; + + // Create the options call + const optionsCall = j.callExpression( + j.identifier(optionsName), + path.node.arguments, + ); + + // Preserve type parameters on the options call + if (typeParams) { + optionsCall.typeParameters = typeParams; + } + + // Replace the hook call + j(path).replaceWith( + j.callExpression(j.identifier(reactQueryHook), [optionsCall]), + ); + }); + + // Update imports if we made modifications + if (hasModifications) { + // Remove old hook imports and add new options imports + root + .find(j.ImportDeclaration) + .filter((path) => { + const source = path.node.source.value; + return source.includes('/hooks/') && !source.includes('/hooks/runtime'); + }) + .forEach((path) => { + const modulePath = path.node.source.value; + const optionsToAdd = optionsImportsToAdd.get(modulePath); + + if (optionsToAdd) { + // Filter out removed hooks and add new options + const newSpecifiers = path.node.specifiers.filter((spec) => { + if (j.ImportSpecifier.check(spec)) { + return !hookImportsToRemove.has(spec.imported.name); + } + return true; + }); + + // Add new options imports + optionsToAdd.forEach((optionName) => { + newSpecifiers.push(j.importSpecifier(j.identifier(optionName))); + }); + + path.node.specifiers = newSpecifiers; + } + }); + + // Add or update React Query imports + const existingReactQueryImport = root.find(j.ImportDeclaration, { + source: { value: '@tanstack/react-query' }, + }); + + if (existingReactQueryImport.length > 0) { + const importDecl = existingReactQueryImport.at(0).get(); + const existingImports = new Set( + importDecl.node.specifiers + .filter((spec) => j.ImportSpecifier.check(spec)) + .map((spec) => spec.imported.name), + ); + + // Add missing imports + reactQueryImportsToAdd.forEach((hookName) => { + if (!existingImports.has(hookName)) { + importDecl.node.specifiers.push( + j.importSpecifier(j.identifier(hookName)), + ); + } + }); + } else { + // Create new React Query import + const imports = Array.from(reactQueryImportsToAdd).map((name) => + j.importSpecifier(j.identifier(name)), + ); + + const newImport = j.importDeclaration( + imports, + j.literal('@tanstack/react-query'), + ); + + // Add after the last import + const lastImport = root.find(j.ImportDeclaration).at(-1); + if (lastImport.length > 0) { + lastImport.insertAfter(newImport); + } else { + // If no imports, add at the beginning + root.get().node.program.body.unshift(newImport); + } + } + } + + return hasModifications + ? root.toSource({ + quote: 'single', + trailingComma: true, + }) + : fileInfo.source; +}; + +// Export helper for testing +module.exports.parser = 'tsx'; diff --git a/codemod/run-migration.sh b/codemod/run-migration.sh new file mode 100755 index 0000000..320b359 --- /dev/null +++ b/codemod/run-migration.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# React Query v0.2 Migration Script +# This script helps run the jscodeshift codemod with the correct settings + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Default values +DRY_RUN=true +TARGET_PATH="src/" +EXTENSIONS="ts,tsx" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --apply) + DRY_RUN=false + shift + ;; + --path) + TARGET_PATH="$2" + shift 2 + ;; + --help) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --apply Apply the transformation (default is dry-run)" + echo " --path PATH Target path to transform (default: src/)" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Dry run on src/ directory" + echo " $0 --apply # Apply transformation to src/" + echo " $0 --path lib/ # Dry run on lib/ directory" + echo " $0 --apply --path components/ # Apply to components/" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Check if jscodeshift is available +if ! command -v jscodeshift &> /dev/null && ! command -v npx &> /dev/null; then + echo -e "${RED}Error: jscodeshift is not installed and npx is not available${NC}" + echo "Please install jscodeshift globally: npm install -g jscodeshift" + echo "Or ensure npx is available" + exit 1 +fi + +# Use jscodeshift if available, otherwise use npx +JSCODESHIFT_CMD="jscodeshift" +if ! command -v jscodeshift &> /dev/null; then + JSCODESHIFT_CMD="npx jscodeshift" +fi + +# Get the directory of this script +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +TRANSFORM_PATH="$SCRIPT_DIR/react-query-v0.2-migration.js" + +# Check if transform file exists +if [ ! -f "$TRANSFORM_PATH" ]; then + echo -e "${RED}Error: Transform file not found at $TRANSFORM_PATH${NC}" + exit 1 +fi + +# Check if target path exists +if [ ! -e "$TARGET_PATH" ]; then + echo -e "${RED}Error: Target path does not exist: $TARGET_PATH${NC}" + exit 1 +fi + +echo -e "${GREEN}React Query v0.2 Migration Codemod${NC}" +echo "=====================================" +echo "" + +if [ "$DRY_RUN" = true ]; then + echo -e "${YELLOW}Running in DRY RUN mode${NC}" + echo "No files will be modified. Use --apply to apply changes." +else + echo -e "${RED}Running in APPLY mode${NC}" + echo "Files will be modified. Make sure you have committed your changes!" + echo "" + read -p "Are you sure you want to continue? (y/N) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 + fi +fi + +echo "" +echo "Target path: $TARGET_PATH" +echo "Extensions: $EXTENSIONS" +echo "Transform: $TRANSFORM_PATH" +echo "" + +# Build the command +CMD="$JSCODESHIFT_CMD -t $TRANSFORM_PATH $TARGET_PATH --extensions=$EXTENSIONS --parser=tsx" + +if [ "$DRY_RUN" = true ]; then + CMD="$CMD --dry" +fi + +# Show the command +echo "Running command:" +echo " $CMD" +echo "" + +# Execute the transformation +$CMD + +# Check the exit code +if [ $? -eq 0 ]; then + echo "" + echo -e "${GREEN}✓ Codemod completed successfully${NC}" + + if [ "$DRY_RUN" = true ]; then + echo "" + echo "This was a dry run. To apply the changes, run:" + echo " $0 --apply" + else + echo "" + echo "Changes have been applied. Next steps:" + echo "1. Review the changes: git diff" + echo "2. Run your build: npm run build" + echo "3. Run your tests: npm test" + echo "4. Test your application" + fi +else + echo "" + echo -e "${RED}✗ Codemod failed${NC}" + echo "Please check the errors above and try again." + exit 1 +fi \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a6e008a..882052d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basketry/react-query", - "version": "0.0.0", + "version": "0.2.0-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@basketry/react-query", - "version": "0.0.0", + "version": "0.2.0-alpha.2", "license": "MIT", "dependencies": { "@basketry/typescript": "^0.1.2", diff --git a/package.json b/package.json index c8e904c..5108b61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@basketry/react-query", - "version": "0.0.0", + "version": "0.2.0-alpha.2", "description": "Basketry generator for generating Typescript interfaces", "main": "./lib/index.js", "scripts": { diff --git a/src/context-file.ts b/src/context-file.ts index 6fb1b8a..6280150 100644 --- a/src/context-file.ts +++ b/src/context-file.ts @@ -1,8 +1,10 @@ import { camel, pascal } from 'case'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; +import { NameFactory } from './name-factory'; export class ContextFile extends ModuleBuilder { + private readonly nameFactory = new NameFactory(this.service, this.options); private readonly react = new ImportBuilder('react'); private readonly client = new ImportBuilder( this.options?.reactQuery?.clientModule ?? '../http-client', @@ -23,23 +25,43 @@ export class ContextFile extends ModuleBuilder { const FetchLike = () => this.client.type('FetchLike'); const OptionsType = () => this.client.type(optionsName); - yield `export interface ClientContextProps { fetch: ${FetchLike()}; options: ${OptionsType()}; }`; - yield `const ClientContext = ${createContext()}( undefined );`; + const contextName = this.nameFactory.buildContextName(); + const contextPropsName = pascal(`${contextName}_props`); + const providerName = this.nameFactory.buildProviderName(); + + yield `export interface ${contextPropsName} { fetch: ${FetchLike()}; options: ${OptionsType()}; }`; + yield `const ${contextName} = ${createContext()}<${contextPropsName} | undefined>( undefined );`; + yield ``; + // Store context for non-hook access + yield `let currentContext: ${contextPropsName} | undefined;`; yield ``; - yield `export const ClientProvider: ${FC()}<${PropsWithChildren()}> = ({ children, fetch, options }) => {`; + yield `export const ${providerName}: ${FC()}<${PropsWithChildren()}<${contextPropsName}>> = ({ children, fetch, options }) => {`; yield ` const value = ${useMemo()}(() => ({ fetch, options }), [fetch, options.mapUnhandledException, options.mapValidationError, options.root]);`; - yield ` return {children};`; + yield ` currentContext = value;`; + yield ` return <${contextName}.Provider value={value}>{children};`; yield `};`; for (const int of this.service.interfaces) { - const hookName = camel(`use_${int.name.value}_service`); - const localName = camel(`${int.name.value}_service`); + const hookName = this.nameFactory.buildServiceHookName(int); + const localName = this.nameFactory.buildServiceName(int); const interfaceName = pascal(`${int.name.value}_service`); const className = pascal(`http_${int.name.value}_service`); + const getterName = camel(`get_${int.name.value}_service`); + + 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, currentContext.options);`; + yield ` return ${localName};`; + yield `}`; yield ``; yield `export const ${hookName} = () => {`; - yield ` const context = ${useContext()}(ClientContext);`; - yield ` if (!context) { throw new Error('${hookName} must be used within a ClientProvider'); }`; + yield ` const context = ${useContext()}(${contextName});`; + yield ` if (!context) { throw new Error('${hookName} must be used within a ${providerName}'); }`; yield ` const ${localName}: ${this.types.type( interfaceName, )} = new ${this.client.fn(className)}(context.fetch, context.options);`; diff --git a/src/hook-file.test.ts b/src/hook-file.test.ts new file mode 100644 index 0000000..0a33167 --- /dev/null +++ b/src/hook-file.test.ts @@ -0,0 +1,854 @@ +import { File, Service } from 'basketry'; +import { generateHooks } from './hook-generator'; +import { NamespacedReactQueryOptions } from './types'; + +describe('HookFile', () => { + describe('Infinite Query Options', () => { + it('generates infinite query options for relay-paginated methods', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'getWidgets' }, + security: [], + parameters: [ + { + kind: 'Parameter', + name: { value: 'first' }, + typeName: { value: 'integer' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'after' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'last' }, + typeName: { value: 'integer' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'before' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + returnType: { + kind: 'ReturnType', + typeName: { value: 'WidgetConnection' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'getWidgets' }, + verb: { value: 'get' }, + parameters: [], + successCode: { value: 200 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'WidgetConnection' }, + properties: [ + { + kind: 'Property', + name: { value: 'pageInfo' }, + typeName: { value: 'PageInfo' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'data' }, + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: true, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'PageInfo' }, + properties: [ + { + kind: 'Property', + name: { value: 'startCursor' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'endCursor' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'hasNextPage' }, + typeName: { value: 'boolean' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'hasPreviousPage' }, + typeName: { value: 'boolean' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [ + { + kind: 'Property', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'name' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = { + reactQuery: { + typesModule: '../types', + clientModule: '../http-client', + }, + }; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + expect(widgetsFile).toBeDefined(); + + const content = widgetsFile!.contents; + + // Check that infinite query options are generated with full method names + expect(content).toContain('export const getWidgetsInfiniteQueryOptions'); + + // Verify the query key includes the infinite flag + expect(content).toMatch( + /queryKey:\s*\['widget',\s*'getWidgets',[^,]+,\s*\{\s*infinite:\s*true\s*\}/, + ); + + // Check that regular query options are also generated + expect(content).toContain('export const getWidgetsQueryOptions'); + + // Verify relay pagination utilities are used + expect(content).toContain('getNextPageParam'); + expect(content).toContain('getPreviousPageParam'); + expect(content).toContain('getInitialPageParam'); + expect(content).toContain('applyPageParam'); + }); + + it('does not generate infinite query options for non-relay-paginated methods', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'getWidget' }, + security: [], + parameters: [ + { + kind: 'Parameter', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + returnType: { + kind: 'ReturnType', + typeName: { value: 'WidgetResponse' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets/{id}' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'getWidget' }, + verb: { value: 'get' }, + parameters: [], + successCode: { value: 200 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'WidgetResponse' }, + properties: [ + { + kind: 'Property', + name: { value: 'data' }, + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [ + { + kind: 'Property', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = {}; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + expect(widgetsFile).toBeDefined(); + + const content = widgetsFile!.contents; + + expect(content).toContain('export const getWidgetQueryOptions'); + expect(content).not.toContain('InfiniteQueryOptions'); + expect(content).not.toContain('infiniteQueryOptions'); + }); + }); + + describe('Deprecated Hook Generation', () => { + it('generates deprecated query hooks with proper deprecation messages', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'getWidget' }, + security: [], + parameters: [ + { + kind: 'Parameter', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + returnType: { + kind: 'ReturnType', + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets/{id}' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'getWidget' }, + verb: { value: 'get' }, + parameters: [], + successCode: { value: 200 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [ + { + kind: 'Property', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = {}; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + expect(widgetsFile).toBeDefined(); + + const content = widgetsFile!.contents; + + // Check that deprecated hooks are generated + expect(content).toContain('export const useWidget'); + expect(content).toContain('export const useSuspenseWidget'); + + // Check for deprecation messages + expect(content).toContain('@deprecated'); + expect(content).toContain( + 'This hook is deprecated and will be removed in a future version', + ); + expect(content).toContain('// Old pattern (deprecated)'); + expect(content).toContain('// New pattern'); + expect(content).toContain('const result = useWidget'); + expect(content).toContain( + 'const result = useQuery(getWidgetQueryOptions', + ); + + // Check that hooks use the query options + expect(content).toMatch(/useWidget[^}]+useQuery\(getWidgetQueryOptions/s); + expect(content).toMatch( + /useSuspenseWidget[^}]+useSuspenseQuery\(getWidgetQueryOptions/s, + ); + }); + + it('generates deprecated mutation hooks with query invalidation', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'createWidget' }, + security: [], + parameters: [ + { + kind: 'Parameter', + name: { value: 'widget' }, + typeName: { value: 'CreateWidgetInput' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + ], + returnType: { + kind: 'ReturnType', + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'createWidget' }, + verb: { value: 'post' }, + parameters: [], + successCode: { value: 201 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'CreateWidgetInput' }, + properties: [ + { + kind: 'Property', + name: { value: 'name' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [ + { + kind: 'Property', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = {}; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + expect(widgetsFile).toBeDefined(); + + const content = widgetsFile!.contents; + + // Check that deprecated mutation hook is generated + expect(content).toContain('export const useCreateWidget'); + + // Check for deprecation message + expect(content).toContain('@deprecated'); + expect(content).toContain('mutation hook is deprecated'); + + // Check that hook uses useQueryClient for invalidation + expect(content).toContain('const queryClient = useQueryClient()'); + expect(content).toContain('useMutation({'); + expect(content).toContain('...mutationOptions'); + expect(content).toContain('onSuccess:'); + expect(content).toContain( + "queryClient.invalidateQueries({ queryKey: ['widget'] })", + ); + + // Check that it preserves existing onSuccess + expect(content).toContain( + 'mutationOptions.onSuccess?.(data, variables, context)', + ); + }); + + it('generates deprecated infinite query hooks for paginated endpoints', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'getWidgets' }, + security: [], + parameters: [ + { + kind: 'Parameter', + name: { value: 'first' }, + typeName: { value: 'integer' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'after' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'last' }, + typeName: { value: 'integer' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Parameter', + name: { value: 'before' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + returnType: { + kind: 'ReturnType', + typeName: { value: 'WidgetConnection' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'getWidgets' }, + verb: { value: 'get' }, + parameters: [], + successCode: { value: 200 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'WidgetConnection' }, + properties: [ + { + kind: 'Property', + name: { value: 'pageInfo' }, + typeName: { value: 'PageInfo' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'data' }, + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: true, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'PageInfo' }, + properties: [ + { + kind: 'Property', + name: { value: 'endCursor' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + { + kind: 'Property', + name: { value: 'hasNextPage' }, + typeName: { value: 'boolean' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [ + { + kind: 'Property', + name: { value: 'id' }, + typeName: { value: 'string' }, + isPrimitive: true, + isArray: false, + rules: [], + }, + ], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = {}; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + expect(widgetsFile).toBeDefined(); + + const content = widgetsFile!.contents; + + // Check that deprecated infinite hooks are generated + expect(content).toContain('export const useWidgetsInfinite'); + expect(content).toContain('export const useSuspenseWidgetsInfinite'); + + // Check for deprecation messages + expect(content).toContain('@deprecated'); + expect(content).toContain('infinite query hook is deprecated'); + + // Check that hooks use the infinite query options + expect(content).toMatch( + /useWidgetsInfinite[^}]+useInfiniteQuery\(getWidgetsInfiniteQueryOptions/s, + ); + expect(content).toMatch( + /useSuspenseWidgetsInfinite[^}]+useSuspenseInfiniteQuery\(getWidgetsInfiniteQueryOptions/s, + ); + }); + + it('verifies deprecation message format is consistent', async () => { + const service: Service = { + basketry: '1.1-rc', + kind: 'Service', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: 'test.json', + loc: 'test.json', + interfaces: [ + { + kind: 'Interface', + name: { value: 'widget' }, + methods: [ + { + kind: 'Method', + name: { value: 'getWidget' }, + security: [], + parameters: [], + returnType: { + kind: 'ReturnType', + typeName: { value: 'Widget' }, + isPrimitive: false, + isArray: false, + rules: [], + }, + }, + ], + protocols: { + http: [ + { + kind: 'HttpPath', + path: { value: '/widgets/{id}' }, + methods: [ + { + kind: 'HttpMethod', + name: { value: 'getWidget' }, + verb: { value: 'get' }, + parameters: [], + successCode: { value: 200 }, + requestMediaTypes: [], + responseMediaTypes: [], + }, + ], + }, + ], + }, + }, + ], + types: [ + { + kind: 'Type', + name: { value: 'Widget' }, + properties: [], + rules: [], + }, + ], + enums: [], + unions: [], + meta: [], + }; + + const options: NamespacedReactQueryOptions = {}; + + const files: File[] = []; + for await (const file of generateHooks(service, options)) { + files.push(file); + } + + const widgetsFile = files.find( + (f) => f.path[f.path.length - 1] === 'widgets.ts', + ); + const content = widgetsFile!.contents; + + // Check deprecation message includes proper imports + expect(content).toMatch( + /import \{ useQuery \} from '@tanstack\/react-query'/, + ); + expect(content).toMatch( + /import \{ getWidgetQueryOptions \} from '\.\/hooks\/widgets'/, + ); + + // Check code blocks are properly formatted + expect(content).toContain('```typescript'); + expect(content).toContain('```'); + + // Verify migration example structure + const deprecationBlocks = content.match(/\/\*\*[\s\S]*?\*\//g) || []; + const queryDeprecation = deprecationBlocks.find( + (block) => block.includes('useWidget') && !block.includes('Suspense'), + ); + + expect(queryDeprecation).toBeDefined(); + expect(queryDeprecation).toContain('Old pattern (deprecated)'); + expect(queryDeprecation).toContain('New pattern'); + }); + }); +}); diff --git a/src/hook-file.ts b/src/hook-file.ts index ad199a3..4f00468 100644 --- a/src/hook-file.ts +++ b/src/hook-file.ts @@ -21,7 +21,7 @@ import { camel } from 'case'; import { NamespacedReactQueryOptions } from './types'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; -import { getQueryOptionsName } from './name-factory'; +import { NameFactory } from './name-factory'; export class HookFile extends ModuleBuilder { constructor( @@ -31,6 +31,7 @@ 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'); @@ -72,9 +73,6 @@ export class HookFile extends ModuleBuilder { for (const method of [...this.int.methods].sort((a, b) => this.getHookName(a).localeCompare(this.getHookName(b)), )) { - const name = this.getHookName(method); - const suspenseName = this.getHookName(method, { suspense: true }); - const infiniteName = this.getHookName(method, { infinite: true }); const paramsType = from(buildParamsType(method)); const httpMethod = getHttpMethodByName(this.service, method.name.value); const httpPath = this.getHttpPath(httpMethod); @@ -88,12 +86,16 @@ export class HookFile extends ModuleBuilder { const isGet = httpMethod?.verb.value === 'get' && !!httpPath; + // Generate new query options exports (v0.2.0 feature) if (isGet) { yield* this.generateQueryOptions(method, httpPath); } + // Generate original hooks with options parameters (v0.1.0 compatibility) if (isGet) { - const queryOptionsName = getQueryOptionsName(method); + const name = this.getHookName(method); + const suspenseName = this.getHookName(method, { suspense: true }); + const queryOptionsName = this.nameFactory.buildQueryOptionsName(method); const paramsCallsite = method.parameters.length ? 'params' : ''; const returnType = getTypeByName( @@ -134,34 +136,68 @@ export class HookFile extends ModuleBuilder { dataTypeName, )} | undefined, (${queryParamsType})[]>,'queryKey' | 'queryFn' | 'select'>`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, + // Generate the regular hook + yield ''; + yield* this.buildDeprecationMessage( + 'query', + method.name.value, + name, + camel(this.int.name.value), ); - yield `export function ${name}(${[ + yield `export const ${name} = (${[ paramsExpression, optionsExpression, - ].filter(Boolean)}) {`; + ].filter(Boolean).join(', ')}) => {`; yield ` const defaultOptions = ${queryOptionsName}(${paramsCallsite});`; yield ` return ${useQuery()}({...defaultOptions, ...options});`; - yield `}`; + yield `};`; + + // Generate the suspense hook yield ''; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, + yield* this.buildDeprecationMessage( + 'suspenseQuery', + method.name.value, + suspenseName, + camel(this.int.name.value), ); - yield `export function ${suspenseName}(${[ + yield `export const ${suspenseName} = (${[ paramsExpression, optionsExpression, - ].filter(Boolean)}) {`; + ].filter(Boolean).join(', ')}) => {`; yield ` const defaultOptions = ${queryOptionsName}(${paramsCallsite});`; yield ` return ${useSuspenseQuery()}({...defaultOptions, ...options});`; - yield `}`; + yield `};`; } else if (httpPath) { + const mutationOptions = () => this.tanstack.fn('mutationOptions'); const paramsCallsite = method.parameters.length ? 'params' : ''; + const serviceGetterName = camel(`get_${this.int.name.value}_service`); + const mutationOptionsName = camel( + `${method.name.value}_mutation_options`, + ); + + yield* buildDescription( + method.description, + undefined, + 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 ${serviceName}.${camel( + method.name.value, + )}(${paramsCallsite});`; + yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; + yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`; + yield ` return res.data;`; + yield ` },`; + yield ` });`; + yield `}`; + // Generate original mutation hook with options parameter + const hookName = this.getHookName(method); const returnType = getTypeByName( this.service, method.returnType?.typeName.value, @@ -178,55 +214,81 @@ export class HookFile extends ModuleBuilder { typeName, )}, Error, ${type(paramsType)}, unknown>, 'mutationFn'>`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, + yield ''; + yield* this.buildDeprecationMessage( + 'mutation', + method.name.value, + hookName, + camel(this.int.name.value), ); - yield `export function ${name}(${optionsExpression}) {`; + yield `export const ${hookName} = (${optionsExpression}) => {`; yield ` const queryClient = ${useQueryClient()}();`; - yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; + yield ` const mutationOptions = ${mutationOptionsName}();`; yield ` return ${useMutation()}({`; - yield ` mutationFn: async (${paramsExpression}) => {`; - yield ` const res = await ${serviceName}.${camel( - method.name.value, - )}(${paramsCallsite});`; - yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; - yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`; - - const queryKeys = new Set(); - queryKeys.add(this.buildResourceKey(httpPath, method)); // Invalidate this resource - queryKeys.add( - this.buildResourceKey(httpPath, method, { skipTerminalParams: true }), // Invalidate the parent resource group - ); - - for (const queryKey of Array.from(queryKeys)) { - yield ` queryClient.invalidateQueries({ queryKey: [${queryKey}] });`; - } - yield ` return res.data;`; + yield ` ...mutationOptions,`; + yield ` onSuccess: (data, variables, context) => {`; + yield ` queryClient.invalidateQueries({ queryKey: ['${this.int.name.value}'] });`; + yield ` mutationOptions.onSuccess?.(data, variables, context);`; yield ` },`; yield ` ...options,`; yield ` });`; - yield `}`; + yield `};`; } if (isGet && this.isRelayPaginated(method)) { + const infiniteQueryOptions = () => + this.tanstack.fn('infiniteQueryOptions'); const methodExpression = `${serviceName}.${camel(method.name.value)}`; const paramsCallsite = method.parameters.length ? `${applyPageParam()}(params${q ? '?? {}' : ''}, pageParam)` : ''; + const infiniteName = this.getHookName(method, { infinite: true }); + const serviceGetterName = camel(`get_${this.int.name.value}_service`); + + // Export the infinite query options (v0.2.0 feature) + const infiniteOptionsName = camel( + `${method.name.value}_infinite_query_options`, + ); + + yield* buildDescription( + method.description, + undefined, + method.deprecated?.value, + ); + yield `export const ${infiniteOptionsName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn( + serviceGetterName, + )}();`; + yield ` return ${infiniteQueryOptions()}({`; + yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${ + method.parameters.length ? 'params || {}' : '{}' + }, {infinite: true}] as const,`; + yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`; + yield ` const res = await ${methodExpression}(${paramsCallsite});`; + yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; + yield ` return res;`; + yield ` },`; + yield* this.buildInfiniteSelectFn(method); + yield ` initialPageParam: ${getInitialPageParam()}(params${ + q ? '?? {}' : '' + }),`; + yield ` ${getNextPageParam()},`; + yield ` ${getPreviousPageParam()},`; + yield ` });`; + yield `}`; + // Generate private infinite options hook for backward compatibility const infiniteOptionsHook = camel( `${this.getHookName(method, { infinite: true })}_query_options`, ); + yield ''; yield `function ${infiniteOptionsHook}(${paramsExpression}) {`; yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}();`; yield ` return {`; - yield ` queryKey: ${this.buildQueryKey(httpPath, method, { - includeRelayParams: false, - infinite: true, - })},`; + yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${ + method.parameters.length ? 'params || {}' : '{}' + }, {infinite: true}] as const,`; yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`; yield ` const res = await ${methodExpression}(${paramsCallsite});`; yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; @@ -241,28 +303,33 @@ export class HookFile extends ModuleBuilder { yield ` };`; yield `}`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, + // Generate deprecated infinite query hook + yield ''; + yield* this.buildDeprecationMessage( + 'infinite', + method.name.value, + infiniteName, + camel(this.int.name.value), ); - yield `export const ${this.getHookName(method, { - suspense: false, - infinite: true, - })} = (${paramsExpression}) => {`; + yield `export const ${infiniteName} = (${paramsExpression}) => {`; yield ` const options = ${infiniteOptionsHook}(params);`; yield ` return ${useInfiniteQuery()}(options);`; yield `}`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); - yield `export const ${this.getHookName(method, { + // Generate deprecated suspense infinite query hook + const suspenseInfiniteName = this.getHookName(method, { suspense: true, infinite: true, - })} = (${paramsExpression}) => {`; + }); + + yield ''; + yield* this.buildDeprecationMessage( + 'suspenseInfinite', + method.name.value, + suspenseInfiniteName, + camel(this.int.name.value), + ); + yield `export const ${suspenseInfiniteName} = (${paramsExpression}) => {`; yield ` const options = ${infiniteOptionsHook}(params);`; yield ` return ${useSuspenseInfiniteQuery()}(options);`; yield `}`; @@ -301,10 +368,11 @@ export class HookFile extends ModuleBuilder { const queryOptions = () => this.tanstack.fn('queryOptions'); const CompositeError = () => this.runtime.fn('CompositeError'); const type = (t: string) => this.types.type(t); + const httpMethod = getHttpMethodByName(this.service, method.name.value); const serviceName = camel(`${this.int.name.value}_service`); - const serviceHookName = camel(`use_${this.int.name.value}_service`); - const name = getQueryOptionsName(method); + const serviceGetterName = camel(`get_${this.int.name.value}_service`); + const name = this.nameFactory.buildQueryOptionsName(method); const paramsType = from(buildParamsType(method)); const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; const paramsExpression = method.parameters.length @@ -323,12 +391,17 @@ export class HookFile extends ModuleBuilder { (prop) => prop.name.value !== 'data' && prop.name.value !== 'errors', ); - yield `const ${name} = (${paramsExpression}) => {`; - yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; + yield* buildDescription( + method.description, + undefined, + method.deprecated?.value, + ); + yield `export const ${name} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`; yield ` return ${queryOptions()}({`; - yield ` queryKey: ${this.buildQueryKey(httpPath, method, { - includeRelayParams: true, - })},`; + yield ` queryKey: ['${this.int.name.value}', '${method.name.value}', ${ + method.parameters.length ? 'params || {}' : '{}' + }] as const,`; yield ` queryFn: async () => {`; yield ` const res = await ${serviceName}.${camel( method.name.value, @@ -344,27 +417,6 @@ export class HookFile extends ModuleBuilder { yield `};`; } - private getHookName( - 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') - ) { - return camel( - `use_${options?.suspense ? 'suspense_' : ''}${ - options?.infinite ? 'infinite_' : '' - }${name.slice(3)}`, - ); - } - - return camel(`use_${name}`); - } - private getHttpPath( httpMethod: HttpMethod | undefined, ): HttpPath | undefined { @@ -389,7 +441,6 @@ export class HookFile extends ModuleBuilder { options?: { includeRelayParams?: boolean; infinite?: boolean }, ): string { const compact = () => this.runtime.fn('compact'); - const resourceKey = this.buildResourceKey(httpPath, method); const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; @@ -416,7 +467,7 @@ export class HookFile extends ModuleBuilder { } if (options?.infinite) { - queryKey.push('{inifinite: true}'); + queryKey.push('{infinite: true}'); } return `[${queryKey.join(', ')}]${ @@ -460,7 +511,6 @@ export class HookFile extends ModuleBuilder { ); if (!returnType) return false; - // TODO: Check if the return type has a `pageInfo` property if ( !returnType.properties.some( (prop) => camel(prop.name.value) === 'pageInfo', @@ -515,6 +565,147 @@ export class HookFile extends ModuleBuilder { return true; } + + // Private method to generate hook names with same logic as v0.1.0 + private getHookName( + 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') + ) { + return camel( + `use_${options?.suspense ? 'suspense_' : ''}${name.slice(3)}${ + options?.infinite ? '_infinite' : '' + }`, + ); + } + + return camel(`use_${name}`); + } + + private buildDeprecationMessage( + hookType: + | 'query' + | 'suspenseQuery' + | 'mutation' + | 'infinite' + | 'suspenseInfinite', + methodName: string, + hookName: string, + fileName: string, + ): string[] { + const pluralize = require('pluralize'); + const pluralFileName = pluralize(fileName); + const lines: string[] = []; + lines.push('/**'); + + // Use appropriate deprecation message based on hook type + if (hookType === 'mutation') { + lines.push( + ' * @deprecated This mutation hook is deprecated and will be removed in a future version.', + ); + } else if (hookType === 'infinite' || hookType === 'suspenseInfinite') { + lines.push( + ' * @deprecated This infinite query hook is deprecated and will be removed in a future version.', + ); + } else { + lines.push( + ' * @deprecated This hook is deprecated and will be removed in a future version.', + ); + } + + lines.push(' * Please use the new query options pattern instead:'); + lines.push(' * '); + lines.push(' * ```typescript'); + + switch (hookType) { + case 'query': + lines.push(" * import { useQuery } from '@tanstack/react-query';"); + lines.push( + ` * import { ${methodName}QueryOptions } from './hooks/${pluralFileName}';`, + ); + lines.push(' * '); + lines.push(' * // Old pattern (deprecated)'); + lines.push(` * const result = ${hookName}(params);`); + lines.push(' * '); + lines.push(' * // New pattern'); + lines.push( + ` * const result = useQuery(${methodName}QueryOptions(params));`, + ); + break; + case 'suspenseQuery': + lines.push( + " * import { useSuspenseQuery } from '@tanstack/react-query';", + ); + lines.push( + ` * import { ${methodName}QueryOptions } from './hooks/${pluralFileName}';`, + ); + lines.push(' * '); + lines.push(' * // Old pattern (deprecated)'); + lines.push(` * const result = ${hookName}(params);`); + lines.push(' * '); + lines.push(' * // New pattern'); + lines.push( + ` * const result = useSuspenseQuery(${methodName}QueryOptions(params));`, + ); + break; + case 'mutation': + lines.push(" * import { useMutation } from '@tanstack/react-query';"); + lines.push( + ` * import { ${methodName}MutationOptions } from './hooks/${pluralFileName}';`, + ); + lines.push(' * '); + lines.push(' * // Old pattern (deprecated)'); + lines.push(` * const mutation = ${hookName}();`); + lines.push(' * '); + lines.push(' * // New pattern'); + lines.push( + ` * const mutation = useMutation(${methodName}MutationOptions());`, + ); + break; + case 'infinite': + lines.push( + " * import { useInfiniteQuery } from '@tanstack/react-query';", + ); + lines.push( + ` * import { ${methodName}InfiniteQueryOptions } from './hooks/${pluralFileName}';`, + ); + lines.push(' * '); + lines.push(' * // Old pattern (deprecated)'); + lines.push(` * const result = ${hookName}(params);`); + lines.push(' * '); + lines.push(' * // New pattern'); + lines.push( + ` * const result = useInfiniteQuery(${methodName}InfiniteQueryOptions(params));`, + ); + break; + case 'suspenseInfinite': + lines.push( + " * import { useSuspenseInfiniteQuery } from '@tanstack/react-query';", + ); + lines.push( + ` * import { ${methodName}InfiniteQueryOptions } from './hooks/${pluralFileName}';`, + ); + lines.push(' * '); + lines.push(' * // Old pattern (deprecated)'); + lines.push(` * const result = ${hookName}(params);`); + lines.push(' * '); + lines.push(' * // New pattern'); + lines.push( + ` * const result = useSuspenseInfiniteQuery(${methodName}InfiniteQueryOptions(params));`, + ); + break; + } + + lines.push(' * ```'); + lines.push(' */'); + return lines; + } } function isPathParam(part: string): boolean { 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 189ba58..e74e81a 100644 --- a/src/hook-generator.ts +++ b/src/hook-generator.ts @@ -10,6 +10,7 @@ import { NamespacedReactQueryOptions } from './types'; import { HookFile } from './hook-file'; import { ContextFile } from './context-file'; import { RuntimeFile } from './runtime-file'; +import { QueryKeyBuilderFile } from './query-key-builder'; export const generateHooks: Generator = (service, options) => { return new HookGenerator(service, options).generate(); @@ -40,6 +41,18 @@ class HookGenerator { ), }); + files.push({ + path: buildFilePath( + ['hooks', 'query-key-builder.ts'], + this.service, + this.options, + ), + contents: format( + from(new QueryKeyBuilderFile(this.service, this.options).build()), + this.options, + ), + }); + for (const int of this.service.interfaces) { const contents = format( from(new HookFile(this.service, this.options, int).build()), diff --git a/src/name-factory.ts b/src/name-factory.ts index 465525d..653226a 100644 --- a/src/name-factory.ts +++ b/src/name-factory.ts @@ -1,6 +1,74 @@ -import { Method } from 'basketry'; -import { camel } from 'case'; +import { Interface, Method, Service } from 'basketry'; +import { camel, pascal } from 'case'; +import { NamespacedReactQueryOptions } from './types'; -export function getQueryOptionsName(method: Method): string { - return camel(`use_${method.name.value}_query_options`); +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(`${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)}`); + } + + getHookName(method: Method, httpVerb?: string): string { + const name = method.name.value; + + // If it's a GET method and the name starts with "get", remove the "Get" prefix + if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) { + return camel(`use_${name.slice(3)}`); + } + + return camel(`use_${name}`); + } + + getSuspenseHookName(method: Method, httpVerb?: string): string { + const name = method.name.value; + + // If it's a GET method and the name starts with "get", remove the "Get" prefix + if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) { + return camel(`use_suspense_${name.slice(3)}`); + } + + return camel(`use_suspense_${name}`); + } + + getInfiniteHookName(method: Method, httpVerb?: string): string { + const name = method.name.value; + + // If it's a GET method and the name starts with "get", remove the "Get" prefix + if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) { + return camel(`use_${name.slice(3)}_infinite`); + } + + return camel(`use_${name}_infinite`); + } + + getSuspenseInfiniteHookName(method: Method, httpVerb?: string): string { + const name = method.name.value; + + // If it's a GET method and the name starts with "get", remove the "Get" prefix + if (httpVerb === 'get' && name.toLowerCase().startsWith('get')) { + return camel(`use_suspense_${name.slice(3)}_infinite`); + } + + return camel(`use_suspense_${name}_infinite`); + } } diff --git a/src/query-key-builder.test.ts b/src/query-key-builder.test.ts new file mode 100644 index 0000000..f2983ee --- /dev/null +++ b/src/query-key-builder.test.ts @@ -0,0 +1,339 @@ +import { Service } from 'basketry'; +import { QueryKeyBuilderFile } from './query-key-builder'; +import { NamespacedReactQueryOptions } from './types'; + +describe('QueryKeyBuilderFile', () => { + const createService = (interfaces: any[]): Service => ({ + kind: 'Service', + basketry: '1.1-rc', + title: { value: 'TestService' }, + majorVersion: { value: 1 }, + sourcePath: '', + interfaces, + types: [], + enums: [], + unions: [], + }); + + const createInterface = (name: string, methods: any[]) => ({ + kind: 'Interface' as const, + name: { value: name }, + methods, + protocols: { + http: [], + }, + }); + + const createMethod = ( + name: string, + parameters: any[] = [], + httpMethod: string = 'GET', + ) => ({ + kind: 'Method' as const, + name: { value: name }, + parameters, + returnType: undefined, + security: [], + }); + + const createParameter = (name: string, required = true) => ({ + kind: 'Parameter' as const, + name: { value: name }, + typeName: { value: 'string' }, + isArray: false, + isPrimitive: true, + description: null, + deprecated: null, + errors: [], + warnings: [], + sourcePath: '', + rules: [ + ...(required + ? [ + { + kind: 'Rule' as const, + id: 'required', + value: null, + errors: [], + warnings: [], + sourcePath: '', + }, + ] + : []), + ], + }); + + describe('QueryKeyMap generation', () => { + it('generates correct interface structure', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidgets', [createParameter('status', false)]), + createMethod('getWidgetById', [createParameter('id')]), + ]), + createInterface('Gizmo', [createMethod('getGizmos')]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('export interface QueryKeyMap {'); + expect(output).toContain('widget: {'); + expect(output).toContain('getWidgets: GetWidgetsParams | undefined;'); + expect(output).toContain('getWidgetById: GetWidgetByIdParams;'); + expect(output).toContain('gizmo: {'); + expect(output).toContain('getGizmos: GetGizmosParams | undefined;'); + }); + + it('handles methods with no parameters', () => { + const service = createService([ + createInterface('Widget', [createMethod('getAllWidgets')]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain( + 'getAllWidgets: GetAllWidgetsParams | undefined;', + ); + }); + + it('handles methods with required parameters', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidget', [ + createParameter('id', true), + createParameter('version', true), + ]), + ]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('getWidget: GetWidgetParams;'); + }); + + it('handles methods with optional parameters', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('searchWidgets', [ + createParameter('query', false), + createParameter('limit', false), + ]), + ]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain( + 'searchWidgets: SearchWidgetsParams | undefined;', + ); + }); + }); + + describe('Type helpers generation', () => { + it('generates ServiceKeys type', () => { + const service = createService([ + createInterface('Widget', []), + createInterface('Gizmo', []), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('export type ServiceKeys = keyof QueryKeyMap;'); + }); + + it('generates OperationKeys type', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain( + 'export type OperationKeys = keyof QueryKeyMap[S];', + ); + }); + + it('generates OperationParams type', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('export type OperationParams<'); + expect(output).toContain('S extends ServiceKeys,'); + expect(output).toContain('O extends OperationKeys'); + expect(output).toContain('> = QueryKeyMap[S][O];'); + }); + }); + + describe('matchQueryKey function generation', () => { + it('generates function with three overloads', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + // Service-only overload + expect(output).toContain( + 'export function matchQueryKey(', + ); + expect(output).toContain('service: S'); + expect(output).toContain('): readonly [S];'); + + // Service + operation overload + expect(output).toMatch( + /export function matchQueryKey<[\s\S]*?S extends ServiceKeys,[\s\S]*?O extends OperationKeys[\s\S]*?>/, + ); + + // Full overload with params + expect(output).toContain( + 'params: OperationParams extends undefined ? undefined : OperationParams', + ); + }); + + it('generates correct implementation', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + // Check implementation logic + expect(output).toContain( + 'if (arguments.length === 3 && operation !== undefined) {', + ); + expect(output).toContain( + 'const finalParams = params === undefined ? {} : params;', + ); + expect(output).toContain( + 'return [service, operation, finalParams] as const;', + ); + expect(output).toContain('if (operation !== undefined) {'); + expect(output).toContain('return [service, operation] as const;'); + expect(output).toContain('return [service] as const;'); + }); + + it('includes comprehensive JSDoc examples', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('@example'); + expect(output).toContain('// Match all queries for a service'); + expect(output).toContain('matchQueryKey("widget")'); + expect(output).toContain('// Returns: ["widget"]'); + expect(output).toContain('// Match all queries for a specific operation'); + expect(output).toContain('matchQueryKey("widget", "getWidgets")'); + expect(output).toContain('// Match specific query with parameters'); + }); + }); + + describe('import management', () => { + it('imports types module correctly', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidget', [createParameter('id')]), + ]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toMatch( + /import type \{ GetWidgetParams \} from '\.\.\/types'/, + ); + }); + + it('respects custom types module path', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidget', [createParameter('id')]), + ]), + ]); + + const options: NamespacedReactQueryOptions = { + reactQuery: { + typesModule: '../../custom-types', + }, + }; + + const builder = new QueryKeyBuilderFile(service, options); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toMatch( + /import type \{ GetWidgetParams \} from '\.\.\/\.\.\/custom-types'/, + ); + }); + + it('handles type imports setting', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidget', [createParameter('id')]), + ]), + ]); + + const options: NamespacedReactQueryOptions = { + typescript: { + typeImports: true, + }, + }; + + const builder = new QueryKeyBuilderFile(service, options); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toMatch( + /import type \{ GetWidgetParams \} from '\.\.\/types'/, + ); + }); + }); + + describe('edge cases', () => { + it('handles multiple interfaces with multiple methods', () => { + const service = createService([ + createInterface('Widget', [ + createMethod('getWidgets'), + createMethod('createWidget', [], 'POST'), + createMethod('updateWidget', [createParameter('id')], 'PUT'), + ]), + createInterface('Gizmo', [ + createMethod('getGizmos', [createParameter('type', false)]), + createMethod('deleteGizmo', [createParameter('id')], 'DELETE'), + ]), + ]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + // Check all methods are included + expect(output).toContain('getWidgets: GetWidgetsParams | undefined;'); + expect(output).toContain('createWidget: CreateWidgetParams | undefined;'); + expect(output).toContain('updateWidget: UpdateWidgetParams;'); + expect(output).toContain('getGizmos: GetGizmosParams | undefined;'); + expect(output).toContain('deleteGizmo: DeleteGizmoParams;'); + }); + + it('handles empty service', () => { + const service = createService([]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('export interface QueryKeyMap {'); + expect(output).toContain('}'); // Empty interface + expect(output).toContain('export function matchQueryKey'); + }); + + it('handles interface with no methods', () => { + const service = createService([createInterface('Widget', [])]); + + const builder = new QueryKeyBuilderFile(service, {}); + const output = Array.from(builder.build()).join('\n'); + + expect(output).toContain('widget: {'); + expect(output).toContain('};'); // Empty methods object + }); + }); +}); diff --git a/src/query-key-builder.ts b/src/query-key-builder.ts new file mode 100644 index 0000000..c05784b --- /dev/null +++ b/src/query-key-builder.ts @@ -0,0 +1,166 @@ +import { Interface, isRequired, Method, Service } from 'basketry'; + +import { buildParamsType, buildTypeName } from '@basketry/typescript'; + +import { camel } from 'case'; +import { NamespacedReactQueryOptions } from './types'; +import { ModuleBuilder } from './module-builder'; +import { ImportBuilder } from './import-builder'; + +export class QueryKeyBuilderFile 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 { + // Generate QueryKeyMap interface + yield* this.generateQueryKeyMap(); + yield ''; + + // Generate type extraction helpers + yield* this.generateTypeHelpers(); + yield ''; + + // Generate matchQueryKey function + 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 { + // ServiceKeys type + yield '/**'; + yield ' * Extract all service names from QueryKeyMap'; + yield ' */'; + yield 'export type ServiceKeys = keyof QueryKeyMap;'; + yield ''; + + // OperationKeys type + yield '/**'; + yield ' * Extract operation names for a given service'; + yield ' */'; + yield 'export type OperationKeys = keyof QueryKeyMap[S];'; + yield ''; + + // OperationParams type + 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 = Array.from(buildParamsType(method)).join(''); + + if (!paramsType) { + return 'undefined'; + } + + // Register the type with the import builder + if (paramsType) { + this.types.type(paramsType); + } + + const hasRequired = method.parameters.some((p) => isRequired(p)); + return hasRequired ? paramsType : `${paramsType} | undefined`; + } +} diff --git a/src/snapshot/v1/hooks/auth-permutations.ts b/src/snapshot/v1/hooks/auth-permutations.ts new file mode 100644 index 0000000..c2a2d05 --- /dev/null +++ b/src/snapshot/v1/hooks/auth-permutations.ts @@ -0,0 +1,142 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { ComboAuthSchemesParams } from '../types'; +import { getAuthPermutationService } from './context'; +import { CompositeError } from './runtime'; + +export const allAuthSchemesQueryOptions = () => { + const authPermutationService = getAuthPermutationService(); + return queryOptions({ + queryKey: ['authPermutation', 'all-auth-schemes', {}] as const, + queryFn: async () => { + const res = await authPermutationService.allAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { all-auth-schemesQueryOptions } from './hooks/authPermutations'; + * + * // Old pattern (deprecated) + * const result = useAllAuthSchemes(params); + * + * // New pattern + * const result = useQuery(all-auth-schemesQueryOptions(params)); + * ``` + */ +export const useAllAuthSchemes = ( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = allAuthSchemesQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { all-auth-schemesQueryOptions } from './hooks/authPermutations'; + * + * // Old pattern (deprecated) + * const result = useAllAuthSchemes(params); + * + * // New pattern + * const result = useSuspenseQuery(all-auth-schemesQueryOptions(params)); + * ``` + */ +export const useAllAuthSchemes = ( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = allAuthSchemesQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +}; + +export const comboAuthSchemesMutationOptions = () => { + const authPermutationService = getAuthPermutationService(); + return mutationOptions({ + mutationFn: async () => { + const res = await authPermutationService.comboAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { combo-auth-schemesMutationOptions } from './hooks/authPermutations'; + * + * // Old pattern (deprecated) + * const mutation = useComboAuthSchemes(); + * + * // New pattern + * const mutation = useMutation(combo-auth-schemesMutationOptions()); + * ``` + */ +export const useComboAuthSchemes = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = comboAuthSchemesMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['authPermutation'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; diff --git a/src/snapshot/v1/hooks/context.tsx b/src/snapshot/v1/hooks/context.tsx new file mode 100644 index 0000000..3251d35 --- /dev/null +++ b/src/snapshot/v1/hooks/context.tsx @@ -0,0 +1,172 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +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 { + fetch: FetchLike; + options: BasketryExampleOptions; +} +const BasketryExampleContext = createContext< + BasketryExampleContextProps | undefined +>(undefined); + +let currentContext: BasketryExampleContextProps | undefined; + +export const BasketryExampleProvider: FC< + PropsWithChildren +> = ({ children, fetch, options }) => { + const value = useMemo( + () => ({ fetch, options }), + [ + fetch, + options.mapUnhandledException, + options.mapValidationError, + options.root, + ], + ); + currentContext = value; + return ( + + {children} + + ); +}; + +export const getGizmoService = () => { + if (!currentContext) { + throw new Error( + 'getGizmoService called outside of BasketryExampleProvider', + ); + } + const gizmoService: GizmoService = new HttpGizmoService( + currentContext.fetch, + currentContext.options, + ); + 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, + context.options, + ); + return gizmoService; +}; + +export const getWidgetService = () => { + if (!currentContext) { + throw new Error( + 'getWidgetService called outside of BasketryExampleProvider', + ); + } + const widgetService: WidgetService = new HttpWidgetService( + currentContext.fetch, + currentContext.options, + ); + 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, + context.options, + ); + return widgetService; +}; + +export const getExhaustiveService = () => { + if (!currentContext) { + throw new Error( + 'getExhaustiveService called outside of BasketryExampleProvider', + ); + } + const exhaustiveService: ExhaustiveService = new HttpExhaustiveService( + currentContext.fetch, + currentContext.options, + ); + 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, + context.options, + ); + return exhaustiveService; +}; + +export const getAuthPermutationService = () => { + if (!currentContext) { + throw new Error( + 'getAuthPermutationService called outside of BasketryExampleProvider', + ); + } + const authPermutationService: AuthPermutationService = + new HttpAuthPermutationService( + currentContext.fetch, + currentContext.options, + ); + 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, context.options); + return authPermutationService; +}; diff --git a/src/snapshot/v1/hooks/exhaustives.ts b/src/snapshot/v1/hooks/exhaustives.ts new file mode 100644 index 0000000..cf3f7ec --- /dev/null +++ b/src/snapshot/v1/hooks/exhaustives.ts @@ -0,0 +1,185 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + queryOptions, + type UndefinedInitialDataOptions, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { ExhaustiveFormatsParams, ExhaustiveParamsParams } from '../types'; +import { getExhaustiveService } from './context'; +import { CompositeError } from './runtime'; + +export const exhaustiveFormatsQueryOptions = ( + params?: ExhaustiveFormatsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveFormats', params || {}] as const, + queryFn: async () => { + const res = await exhaustiveService.exhaustiveFormats(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { exhaustiveFormatsQueryOptions } from './hooks/exhaustives'; + * + * // Old pattern (deprecated) + * const result = useExhaustiveFormats(params); + * + * // New pattern + * const result = useQuery(exhaustiveFormatsQueryOptions(params)); + * ``` + */ +export const useExhaustiveFormats = ( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = exhaustiveFormatsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { exhaustiveFormatsQueryOptions } from './hooks/exhaustives'; + * + * // Old pattern (deprecated) + * const result = useExhaustiveFormats(params); + * + * // New pattern + * const result = useSuspenseQuery(exhaustiveFormatsQueryOptions(params)); + * ``` + */ +export const useExhaustiveFormats = ( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = exhaustiveFormatsQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +}; + +export const exhaustiveParamsQueryOptions = ( + params: ExhaustiveParamsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveParams', params || {}] as const, + queryFn: async () => { + const res = await exhaustiveService.exhaustiveParams(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { exhaustiveParamsQueryOptions } from './hooks/exhaustives'; + * + * // Old pattern (deprecated) + * const result = useExhaustiveParams(params); + * + * // New pattern + * const result = useQuery(exhaustiveParamsQueryOptions(params)); + * ``` + */ +export const useExhaustiveParams = ( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = exhaustiveParamsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { exhaustiveParamsQueryOptions } from './hooks/exhaustives'; + * + * // Old pattern (deprecated) + * const result = useExhaustiveParams(params); + * + * // New pattern + * const result = useSuspenseQuery(exhaustiveParamsQueryOptions(params)); + * ``` + */ +export const useExhaustiveParams = ( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + '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..7a9175d --- /dev/null +++ b/src/snapshot/v1/hooks/gizmos.ts @@ -0,0 +1,265 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateGizmoParams, + GetGizmosParams, + Gizmo, + GizmosResponse, + UpdateGizmoParams, + UploadGizmoParams, +} from '../types'; +import { getGizmoService } from './context'; +import { CompositeError } from './runtime'; + +/** + * 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 gizmoService.createGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { createGizmoMutationOptions } from './hooks/gizmos'; + * + * // Old pattern (deprecated) + * const mutation = useCreateGizmo(); + * + * // New pattern + * const mutation = useMutation(createGizmoMutationOptions()); + * ``` + */ +export const useCreateGizmo = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = createGizmoMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; + +/** + * Only has a summary + * @deprecated + */ +export const getGizmosQueryOptions = (params?: GetGizmosParams) => { + const gizmoService = getGizmoService(); + return queryOptions({ + queryKey: ['gizmo', 'getGizmos', params || {}] as const, + queryFn: async () => { + const res = await gizmoService.getGizmos(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { getGizmosQueryOptions } from './hooks/gizmos'; + * + * // Old pattern (deprecated) + * const result = useGizmos(params); + * + * // New pattern + * const result = useQuery(getGizmosQueryOptions(params)); + * ``` + */ +export const useGizmos = ( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions< + GizmosResponse, + Error, + Gizmo | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getGizmosQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { getGizmosQueryOptions } from './hooks/gizmos'; + * + * // Old pattern (deprecated) + * const result = useSuspenseGizmos(params); + * + * // New pattern + * const result = useSuspenseQuery(getGizmosQueryOptions(params)); + * ``` + */ +export const useSuspenseGizmos = ( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions< + GizmosResponse, + Error, + Gizmo | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getGizmosQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +}; + +export const updateGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params?: UpdateGizmoParams) => { + const res = await gizmoService.updateGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { updateGizmoMutationOptions } from './hooks/gizmos'; + * + * // Old pattern (deprecated) + * const mutation = useUpdateGizmo(); + * + * // New pattern + * const mutation = useMutation(updateGizmoMutationOptions()); + * ``` + */ +export const useUpdateGizmo = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = updateGizmoMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; + +export const uploadGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: UploadGizmoParams) => { + const res = await gizmoService.uploadGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { uploadGizmoMutationOptions } from './hooks/gizmos'; + * + * // Old pattern (deprecated) + * const mutation = useUploadGizmo(); + * + * // New pattern + * const mutation = useMutation(uploadGizmoMutationOptions()); + * ``` + */ +export const useUploadGizmo = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = uploadGizmoMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...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..9ab4250 --- /dev/null +++ b/src/snapshot/v1/hooks/query-key-builder.ts @@ -0,0 +1,129 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import type { + AllAuthSchemesParams, + ComboAuthSchemesParams, + CreateGizmoParams, + CreateWidgetParams, + DeleteWidgetFooParams, + ExhaustiveFormatsParams, + ExhaustiveParamsParams, + GetGizmosParams, + GetWidgetFooParams, + GetWidgetsParams, + PutWidgetParams, + UpdateGizmoParams, + UploadGizmoParams, +} from '../types'; + +/** + * Type mapping for all available query keys in the service + */ +export interface QueryKeyMap { + gizmo: { + getGizmos: GetGizmosParams | undefined; + createGizmo: CreateGizmoParams | undefined; + updateGizmo: UpdateGizmoParams | undefined; + uploadGizmo: UploadGizmoParams; + }; + widget: { + getWidgets: GetWidgetsParams | undefined; + createWidget: CreateWidgetParams | undefined; + putWidget: PutWidgetParams | undefined; + getWidgetFoo: GetWidgetFooParams; + deleteWidgetFoo: DeleteWidgetFooParams; + }; + 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..7bc72bc --- /dev/null +++ b/src/snapshot/v1/hooks/runtime.ts @@ -0,0 +1,109 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import type { + GetNextPageParamFunction, + GetPreviousPageParamFunction, +} from '@tanstack/react-query'; + +export type PageParam = { pageParam?: string }; + +export class CompositeError extends Error { + constructor(readonly errors: { title: string }[]) { + super(errors.map((e) => e.title).join(', ')); + if (Error.captureStackTrace) Error.captureStackTrace(this, CompositeError); + } +} + +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..267a755 --- /dev/null +++ b/src/snapshot/v1/hooks/widgets.ts @@ -0,0 +1,311 @@ +/** + * 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://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateWidgetParams, + DeleteWidgetFooParams, + GetWidgetFooParams, + PutWidgetParams, + Widget, +} from '../types'; +import { getWidgetService } from './context'; +import { CompositeError } from './runtime'; + +export const createWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params?: CreateWidgetParams) => { + const res = await widgetService.createWidget(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { createWidgetMutationOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const mutation = useCreateWidget(); + * + * // New pattern + * const mutation = useMutation(createWidgetMutationOptions()); + * ``` + */ +export const useCreateWidget = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = createWidgetMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['widget'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; + +export const deleteWidgetFooMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params: DeleteWidgetFooParams) => { + const res = await widgetService.deleteWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { deleteWidgetFooMutationOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const mutation = useDeleteWidgetFoo(); + * + * // New pattern + * const mutation = useMutation(deleteWidgetFooMutationOptions()); + * ``` + */ +export const useDeleteWidgetFoo = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = deleteWidgetFooMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['widget'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; + +export const putWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async () => { + const res = await widgetService.putWidget(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; + +/** + * @deprecated This mutation hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useMutation } from '@tanstack/react-query'; + * import { putWidgetMutationOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const mutation = usePutWidget(); + * + * // New pattern + * const mutation = useMutation(putWidgetMutationOptions()); + * ``` + */ +export const usePutWidget = ( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) => { + const queryClient = useQueryClient(); + const mutationOptions = putWidgetMutationOptions(); + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: ['widget'] }); + mutationOptions.onSuccess?.(data, variables, context); + }, + ...options, + }); +}; + +export const getWidgetFooQueryOptions = (params: GetWidgetFooParams) => { + const widgetService = getWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgetFoo', params || {}] as const, + queryFn: async () => { + const res = await widgetService.getWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { getWidgetFooQueryOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const result = useWidgetFoo(params); + * + * // New pattern + * const result = useQuery(getWidgetFooQueryOptions(params)); + * ``` + */ +export const useWidgetFoo = ( + params: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getWidgetFooQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { getWidgetFooQueryOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const result = useSuspenseWidgetFoo(params); + * + * // New pattern + * const result = useSuspenseQuery(getWidgetFooQueryOptions(params)); + * ``` + */ +export const useSuspenseWidgetFoo = ( + params: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getWidgetFooQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +}; + +export const getWidgetsQueryOptions = () => { + const widgetService = getWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgets', {}] as const, + queryFn: async () => { + const res = await widgetService.getWidgets(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useQuery } from '@tanstack/react-query'; + * import { getWidgetsQueryOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const result = useWidgets(params); + * + * // New pattern + * const result = useQuery(getWidgetsQueryOptions(params)); + * ``` + */ +export const useWidgets = ( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getWidgetsQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +}; + +/** + * @deprecated This hook is deprecated and will be removed in a future version. + * Please use the new query options pattern instead: + * + * ```typescript + * import { useSuspenseQuery } from '@tanstack/react-query'; + * import { getWidgetsQueryOptions } from './hooks/widgets'; + * + * // Old pattern (deprecated) + * const result = useSuspenseWidgets(params); + * + * // New pattern + * const result = useSuspenseQuery(getWidgetsQueryOptions(params)); + * ``` + */ +export const useSuspenseWidgets = ( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) => { + const defaultOptions = getWidgetsQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +}; diff --git a/tsconfig.json b/tsconfig.json index d8f8bc7..883bf2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "strictNullChecks": true }, "include": ["src"], - "exclude": ["**/*.test?.*"] + "exclude": ["**/*.test?.*", "src/snapshot/**"] }