diff --git a/.github/workflows/generators-integration-tests.yml b/.github/workflows/generators-integration-tests.yml index 780bef7..bb90f81 100644 --- a/.github/workflows/generators-integration-tests.yml +++ b/.github/workflows/generators-integration-tests.yml @@ -39,19 +39,19 @@ jobs: successRegexp: 'Successfully ran target lint for project examples-components-my-components-ui', } - { - packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=apollo --tags=domain:social-qa && pnpm nx lint my-feature-services-my-data-service', + packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=apollo --tags=domain:social-qa --useDefaultCrudFunctions && pnpm nx lint my-feature-services-my-data-service', successRegexp: 'Successfully ran target lint for project my-feature-services-my-data-service', } - { - packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=apollo --tags=domain:social-qa && pnpm nx test my-feature-services-my-data-service', + packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=apollo --tags=domain:social-qa --useDefaultCrudFunctions && pnpm nx test my-feature-services-my-data-service', successRegexp: 'Successfully ran target test for project my-feature-services-my-data-service', } - { - packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=react-query --tags=domain:social-qa && pnpm nx lint my-feature-services-my-data-service', + packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=react-query --tags=domain:social-qa --useDefaultCrudFunctions && pnpm nx lint my-feature-services-my-data-service', successRegexp: 'Successfully ran target lint for project my-feature-services-my-data-service', } - { - packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=react-query --tags=domain:social-qa && pnpm nx test my-feature-services-my-data-service', + packageCommand: 'pnpm nx g @brainly-gene/tools:service --name=my-data --directory=my-feature/services --serviceType=react-query --tags=domain:social-qa --useDefaultCrudFunctions && pnpm nx test my-feature-services-my-data-service', successRegexp: 'Successfully ran target test for project my-feature-services-my-data-service', } - { diff --git a/docs/docs-site/pages/gene/services/fetchOnDemand.mdx b/docs/docs-site/pages/gene/services/fetchOnDemand.mdx deleted file mode 100644 index b26ae80..0000000 --- a/docs/docs-site/pages/gene/services/fetchOnDemand.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Implementing On-Demand Data Fetching and Updates with Service Hooks ---- - -# Implementing On-Demand Data Fetching and Updates with Service Hooks - -Service hooks provide a consistent API across all services. However, some situations require on-demand data retrieval, such as when a user interacts with an element. For these cases, the `useMyServiceLazy` hook can be used. This hook, automatically created with a `Lazy` suffix when a service is generated, is designed for executing data fetching and mutations as needed. It also provides methods for updating the UI immediately and keeping data in sync after changes. - -## Reactive Operations with `useMyServiceLazy` - -The `useMyServiceLazy` hook is especially useful in scenarios where data fetching and updates are triggered by user actions, such as button clicks. - -### Example of Fetching Data On-Demand - -```tsx -const { fetch, data, loading, error } = useMyServiceLazy(); - -const handleClick = (variables) => { - fetch(variables).then((data) => { - // Do something with data - }); -}; - -if (loading) { - return 'loading'; -} - -if (error) { - return error; -} -``` - -### Strategies for Data Synchronization - -- **Optimistic Updates**: Provide a simulated response (`optimisticResponse`) for immediate visual feedback in the UI, assuming the mutation will succeed. -- **Manual Cache Updates (`updates`)**: Directly modify the cache after a mutation using the provided update functions. -- **Automated Query Refetching (`refetchQueries`)**: Specify which queries to rerun after a successful mutation for automatic data sync. - -### Practical Examples - -#### Modifying an Existing TODO Item - -```tsx -import { queryKey as getTodoListQueryKey } from '@acme/services/get-todos-service'; -import { useEditTodoLazy } from '@acme/services/update-todo-service'; - -const { fetch: updateTodo } = useEditTodoLazy(); - -function updateTodoItem(todoId, updatedDetails) { - updateTodo({ - variables: { id: todoId, ...updatedDetails }, - optimisticResponse: { - // Construct the optimistic UI update here... - ...updatedDetails, - }, - updates: [ - { - queryKey: getTodoListQueryKey({ userId }), - updateFn: (existingTodos, updatedTodo) => ({ - ...existingTodos, - todos: existingTodos.todos.map((todo) => - todo.id === todoId ? { ...todo, ...updatedTodo } : todo - ), - }), - }, - ], - refetchQueries: [ - // Alternatively, refetch the query for automatic updates - getTodoListQueryKey({ userId }), - ], - }); -} -``` - -#### Adding a New TODO Item - -```tsx -import { queryKey as getTodoListQueryKey } from '@acme/services/get-todos-service'; -import { useAddTodoLazy } from '@acme/services/add-todo-service'; - -const { fetch: addTodo } = useAddTodoLazy(); - -function addNewTodoItem(todoDetails) { - addTodo({ - variables: { ...todoDetails }, - optimisticResponse: { - id: 'temp-id', // Temporary ID until backend confirmation - ...todoDetails, - }, - updates: [ - { - queryKey: getTodoListQueryKey(), - updateFn: (existingTodos, newTodo) => ({ - ...existingTodos, - todos: [...existingTodos.todos, newTodo], - }), - }, - ], - refetchQueries: [ - // Alternatively, refetch the query for automatic updates - getTodoListQueryKey({ userId }), - ], - }); -} -``` - -These examples demonstrate the use of optimistic updates for seamless user interaction and two methods for updating the cache: manual updates via `updates` and automatic synchronization using `refetchQueries`. This flexibility allows developers to handle various mutation scenarios effectively, ensuring a reliable and responsive application state. \ No newline at end of file diff --git a/docs/docs-site/pages/gene/services/react-query.mdx b/docs/docs-site/pages/gene/services/react-query.mdx index 83550e5..c76a9c6 100644 --- a/docs/docs-site/pages/gene/services/react-query.mdx +++ b/docs/docs-site/pages/gene/services/react-query.mdx @@ -11,48 +11,114 @@ import { Callout } from 'nextra-theme-docs'; [Generator code](https://github.com/brainly/gene/blob/master/packages/gene-tools/src/generators/service-generator/README.md) ```bash -nx generate @brainly-gene/tools:service --name=myData --directory=my-feature/services --serviceType=react-query +nx generate @brainly-gene/tools:service --name=post --directory=my-blog/services --serviceType=react-query --tags=domain:my-blog ``` -Running this command will generate the following files: - +After running this command you will be additionally prompted to provide the CRUD operations for the service: + +```sh +> NX Generating @brainly-gene/tools:service + +✔ What is the service name? (the name should be an entity name in singular form - for example post, user, book, etc.) · post +✔ What is the service directory? · my-blog/services +✔ What are the service tags? · domain:my-blog +✔ What is the service type? · react-query +? Select CRUD functions you want to generate + ◉ usePosts - to get multiple posts + ◯ useCreatePost - to create a new post + ◉ useUpdatePost - to update a single post + ◯ useDeletePost - to delete a single post +❯◉ usePost - to get a single post ``` -CREATE libs/my-feature/services/myData-service/README.md -CREATE libs/my-feature/services/myData-service/.babelrc -CREATE libs/my-feature/services/myData-service/src/index.ts -CREATE libs/my-feature/services/myData-service/tsconfig.json -CREATE libs/my-feature/services/myData-service/tsconfig.lib.json + +After selecting desired CRUD operations, the generator will create the following files: + +```sh {11,12,13} +CREATE libs/my-blog/services/post-service/project.json +CREATE libs/my-blog/services/post-service/.eslintrc.json +CREATE libs/my-blog/services/post-service/.babelrc +CREATE libs/my-blog/services/post-service/src/index.ts +CREATE libs/my-blog/services/post-service/tsconfig.lib.json +CREATE libs/my-blog/services/post-service/tsconfig.json +CREATE libs/my-blog/services/post-service/jest.config.ts +CREATE libs/my-blog/services/post-service/tsconfig.spec.json UPDATE tsconfig.base.json -UPDATE nx.json -CREATE libs/my-feature/services/myData-service/.eslintrc.json -CREATE libs/my-feature/services/myData-service/jest.config.js -CREATE libs/my-feature/services/myData-service/tsconfig.spec.json -CREATE libs/my-feature/services/myData-service/src/lib/queries.ts -CREATE libs/my-feature/services/myData-service/src/lib/useMyData.ts -CREATE libs/my-feature/services/myData-service/src/lib/useMyDataStatic.ts +CREATE libs/my-blog/services/post-service/src/README.md +CREATE libs/my-blog/services/post-service/src/lib/useUpdatePost.ts +CREATE libs/my-blog/services/post-service/src/lib/usePost.ts +CREATE libs/my-blog/services/post-service/src/lib/usePosts.ts ``` -This generator creates a React Query service in the specified directory and updates all NX configuration files accordingly. After generation, update the endpoint in the `query` function in `queries.ts`: +This generator creates a React Query service in the specified directory with the provided CRUD operations. +Each service hook comes with example API calls and types: ```tsx -export type MyDataTypeAPI = { - content: string; // SET UP YOUR API RESPONSE TYPE <------- +// Change this to match output of your API +export type PostsAPIType = { + id: number; + userId: number; + title: string; + body: string; +}[]; + +// Change this to match input of your API +export type VariablesType = { + userId: number; }; -function queryFn(variables?: VariablesType, context?: QueryFunctionContext) { - const url = variables?.url || 'https://jsonplaceholder.typicode.com/todos/1'; // CHANGE URL <------ +export const queryKey = (variables?: VariablesType) => [ + 'get-posts-key', + variables, +]; + +export function defaultQueryFn( + variables: VariablesType, + context?: QueryFunctionContext +) { + const url = `https://jsonplaceholder.typicode.com/posts?userId=${variables.userId}`; const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; - return reactQueryFetchWrapper(() => fetchMethod(url)); + return reactQueryFetchWrapper(() => fetchMethod(url)); +} + +// Use this function to run this query on SSR, pass the subapp as queryFn +export async function queryPosts( + client: QueryClient, + variables: VariablesType, + queryFn = defaultQueryFn +) { + return client.fetchQuery({ + queryFn: () => queryFn(variables), + queryKey: queryKey(variables), + }); +} + +export function usePosts(props: { variables: VariablesType }) { + const queryClient = useInjectedReactQueryClient(); + + // useInfiniteQuery if paging is needed + const result = useQuery( + { + queryKey: queryKey(props.variables), + queryFn: (ctx) => defaultQueryFn(props.variables, ctx), + }, + queryClient + ); + + return transformReactQueryResponse(result); } ``` ## Usage in a Module ```tsx -import { useMyData } from '@acme/my-feature/services/myData'; +import { usePosts } from '@acme/my-blog/services/post-service'; function MyModule() { - const { data, loading, error } = useMyData(); + const { data, error, loading } = usePosts({ + variables: { + userId: 1, + }, + }); if (loading) { return ; @@ -75,29 +141,41 @@ To hydrate your query using server-side rendering, run the query on the page in Example page with SSR: ```tsx filename=pages/example.tsx +import { compose } from 'ramda'; +import { getRequestHeaders } from '@brainly-gene/next'; +import { getHomePageContainer } from '../ioc/getHomePageIoc'; +import { GetServerSideProps } from 'next/types'; -import {compose} from 'ramda'; -import {getRequestHeaders} from '@brainly-gene/next'; -import {getHomePageContainer} from '../ioc/getHomePageIoc'; -import {GetServerSideProps} from 'next/types'; - -import {withIoc, reactQueryFactory} from '@brainly-gene/core'; +import { withIoc, reactQueryFactory } from '@brainly-gene/core'; import { QueryClient } from '@tanstack/react-query'; -import { queryMyTest } from '@acme/services/my-test-service'; +import { queryPosts } from '@acme/my-blog/services/post-service'; +import { getPosts } from '@acme/my-blog/api/post-api'; function HomePage() { return
Hello ExamplePage!
; } -export const getServerSideProps: GetServerSideProps = async ({req}) => { +export const getServerSideProps: GetServerSideProps = async ({ req }) => { const reactQueryClient = reactQueryFactory(() => new QueryClient()); const reactQueryClientInstance = reactQueryClient.getClient(); const reqHeaders = getRequestHeaders(req); // Invoke service queries here: - await queryMyTest(reactQueryClientInstance); + await queryPosts(reactQueryClientInstance, { userId: 1 }); + + // You can also pass custom query function to fetch the data directly, + // without calling external endpoint + await queryPosts(reactQueryClientInstance, { userId: 1 }, () => { + return getPosts({ userId: 1 }); + }); + + // IF you have multiple queries, you can (and you should) run them in parallel + await Promise.all([ + queryPosts(), + queryFunnyCats() + ]); // End of queries invokes @@ -110,19 +188,25 @@ export const getServerSideProps: GetServerSideProps = async ({req}) => { }; export default compose(withIoc(getHomePageContainer))(HomePage); - ``` -Bind the Query client in the IOC container: + + The variables passed to `queryPosts` must be consistent between the + server and client. Cache keys depend on these elements. If they differ between + client and server, hydration will not work and the query will be re-executed on the client. + + + +Query client is hydrated in the IOC container: ```tsx filename=/ioc/getHomePageIoc.ts -import {Container} from 'inversify'; -import {getBaseContainer} from './baseIoc'; -import {HomePagePropsType} from '../types/types'; -import {ServiceTypes} from '@brainly-gene/core'; +import { Container } from 'inversify'; +import { getBaseContainer } from './baseIoc'; +import { HomePagePropsType } from '../types/types'; +import { ServiceTypes } from '@brainly-gene/core'; -import {Factory, factory} from '@brainly-gene/core'; -import {ReactQueryClientType} from '@brainly-gene/core'; +import { Factory, factory } from '@brainly-gene/core'; +import { ReactQueryClientType } from '@brainly-gene/core'; export function getHomePageContainer(props?: HomePagePropsType) { const baseContainer = getBaseContainer(); @@ -144,127 +228,158 @@ export function getHomePageContainer(props?: HomePagePropsType) { return container; } - ``` -Then in your service, you can use the injected React Query client: +## Pagination + +To implement pagination, use `useInfiniteQuery` instead of `useQuery`. Here is an example of how to implement pagination: + +```tsx {27, 35, 39...45} +export type CharactersAPIType = { + info: { + count: number; + pages: number; + next: string | null; + prev: string | null; + }; + results: Character[]; +}; + +type Character = { + id: number; + name: string; +}; + +export type VariablesType = {}; -```tsx my-test-service/src/lib/useMyTest.ts -import {queryFn, queryKey, VariablesType} from './queries'; -import { - transformReactQueryResponse, - useInjectedReactQueryClient, -} from '@brainly-gene/core'; -import {useQuery} from '@tanstack/react-query'; +export const queryKey = (variables?: VariablesType) => [ + 'get-characters-key', + variables, +]; -export function useMyTest(props?: {variables: VariablesType}) { +export function defaultQueryFn( + variables: VariablesType, + context?: QueryFunctionContext +) { + const url = context.pageParam as string; // use the pageParam to get the next page + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapper(() => fetchMethod(url)); +} + +export function useCharacters(props: { variables: VariablesType }) { const queryClient = useInjectedReactQueryClient(); - // useInfiniteQuery if paging is needed - const result = useQuery( + const result = useInfiniteQuery( // useInfiniteQuery instead of useQuery { - queryKey: queryKey(props?.variables), - queryFn: ctx => queryFn(props?.variables, ctx), + queryKey: queryKey(props.variables), + queryFn: (ctx) => defaultQueryFn(props.variables, ctx), + initialPageParam: 'https://rickandmortyapi.com/api/character?page=1', // set the initial page param + getNextPageParam: (lastPage) => { + // get the next page param based on the last page response, + // this can be page number, cursor or whole URL + return lastPage.info.next; + }, }, queryClient ); return transformReactQueryResponse(result); } - ``` +## Fetch on demand +You can fetch data on demand by utilizing `enabled` property of `useQuery` or `useInfiniteQuery` hooks: -### Stale Time - -To avoid fetching data on the first render and utilize cached data from the server, set `staleTime` in `getMyDataObservable` within `queries.ts`. By default, `staleTime` is `0`, which refetches queries on page load. Setting `staleTime` to `5000` milliseconds (5 seconds) is recommended to avoid double fetching. Setting it to `Infinity` prevents refetching altogether. - -```tsx {9} -export function useMyData(props?: { variables: VariablesType }) { +```tsx {1, 9} +export function useTodos(props: { variables: VariablesType }, skip = false) { const queryClient = useInjectedReactQueryClient(); // useInfiniteQuery if paging is needed const result = useQuery( { - queryKey: queryKey(props?.variables), - queryFn: (ctx) => queryFn(props?.variables, ctx), - staleTime: 0, + queryKey: getTodosQueryKey(props.variables), + queryFn: (ctx) => defaultQueryFn(props.variables, ctx), + enabled: !skip, }, queryClient ); return transformReactQueryResponse(result); } + ``` -### Adding Query for an Existing Page +## Crud Operations and strategies for data synchronization + +- **Optimistic Updates**: Provide a simulated response (`optimisticResponse`) for immediate visual feedback in the UI, assuming the mutation will succeed. +- **Manual Cache Updates (`updates`)**: Directly modify the cache after a mutation using the provided update functions. +- **Automated Query Refetching (`refetchQueries`)**: Specify which queries to rerun after a successful mutation for automatic data sync. + +### Practical Examples -Once the above setup is ready, add your query to the server as follows: +#### Modifying an Existing TODO Item ```tsx -export async function getServerSideProps() { - const reactQueryClient = clients.getReactQueryClient(); - const reactQueryClientInstance = reactQueryClient.getClient(); +import { getTodosQueryKey } from '@acme/todo/services/todo-service'; +import { useUpdateTodo } from '@acme/todo/services/todo-service'; - await Promise.all([ - queryMyData(), - // ADD YOUR QUERY FUNCTION HERE <-------- - ]); +const {fetch: updateTodo} = useUpdateTodo() - return { - props: { - dehydratedQueryClient: reactQueryClient.dehydrate(), +function updateTodoItem(todoId, updatedDetails) { + updateTodo({ + variables: { id: todoId, ...updatedDetails }, + optimisticResponse: { + // Construct the optimistic UI update here... + ...updatedDetails, }, - }; + updates: [ + { + queryKey: getTodoListQueryKey({ userId }), + updateFn: (existingTodos, updatedTodo) => ({ + ...existingTodos, + todos: existingTodos.todos.map((todo) => + todo.id === todoId ? { ...todo, ...updatedTodo } : todo + ), + }), + }, + ], + refetchQueries: [ + // Alternatively, refetch the query for automatic updates + getTodosQueryKey({ userId }), + ], + }); } ``` - - The URL and variables passed to `queryFn` must be consistent between the - server and client. Cache keys depend on these elements. If they differ between - client and server, hydration will not work and the query will be re-executed. - - -## Pagination - -To implement pagination, call `fetchMore` from the service API: +#### Adding a New TODO Item ```tsx -function MyModule() { - const { data, loading, error, fetchMore } = useMyData({ page: 1 }); - const ref = useRef(); - - useMediator('onNextPageButtonClick', (page) => fetchMore({ page }), ref); +import { getTodosQueryKey } from '@acme/todo/services/todo-service'; +import { useCreateTodo } from '@acme/todo/services/todo-service'; - if (loading) { - return ; - } - - if (error) { - return ; - } - - return ; -} -``` +const { fetch: addTodo } = useCreateTodo(); -Update the service hook: - -```tsx {4, 8..11} -export function useMyData(props?: { variables: VariablesType }) { - const queryClient = useInjectedReactQueryClient(); - - const result = useInfiniteQuery( // Change useQuery to useInfiniteQuery - { - queryKey: queryKey(props?.variables), - queryFn: (ctx) => queryFn(props?.variables, ctx), - initialPageParam: 1, - getNextPageParam: (lastPage, allPages) => { - return 2; // Modify to return the next page number based on lastPage - }, +function addNewTodoItem(todoDetails) { + addTodo({ + variables: { ...todoDetails }, + optimisticResponse: { + id: 'temp-id', // Temporary ID until backend confirmation + ...todoDetails, }, - queryClient - ); - - return transformReactQueryResponse(result); + updates: [ + { + queryKey: getTodoListQueryKey(), + updateFn: (existingTodos, newTodo) => ({ + ...existingTodos, + todos: [...existingTodos.todos, newTodo], + }), + }, + ], + refetchQueries: [ + // Alternatively, refetch the query for automatic updates + getTodosQueryKey({ userId }), + ], + }); } ``` + +For more information on React Query, see the [React Query documentation](https://tanstack.com/query/v5/docs/framework/react/overview). \ No newline at end of file diff --git a/docs/docs-site/styles/generated.css b/docs/docs-site/styles/generated.css index cc8a264..a5ff3e1 100644 --- a/docs/docs-site/styles/generated.css +++ b/docs/docs-site/styles/generated.css @@ -973,6 +973,10 @@ video { animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } +.running { + animation-play-state: running; +} + html { font-family: 'Outfit', sans-serif; } diff --git a/packages/gene-tools/src/generators/service-generator/index.spec.ts b/packages/gene-tools/src/generators/service-generator/index.spec.ts index d209c14..9725222 100644 --- a/packages/gene-tools/src/generators/service-generator/index.spec.ts +++ b/packages/gene-tools/src/generators/service-generator/index.spec.ts @@ -1,13 +1,23 @@ import { logger, Tree } from '@nrwl/devkit'; import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; import serviceGenerator from './index'; +import * as inquirer from 'inquirer'; +jest.mock('inquirer', () => ({ prompt: jest.fn(), registerPrompt: jest.fn() })); + +const mockCrudOptions = (options: string[]) => { + (inquirer.prompt as unknown as jest.Mock).mockImplementationOnce( + ([{ name }]) => { + return { crudFunctions: options }; + } + ); +}; describe('Service generator', () => { let appTree: Tree; let projectName: string; beforeEach(async () => { - projectName = 'my-service'; + projectName = 'question'; appTree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); jest.spyOn(logger, 'warn').mockImplementation(() => 1); @@ -24,23 +34,23 @@ describe('Service generator', () => { expect( appTree.exists( - 'libs/my-service/services/my-service-service/src/lib/useMyService.ts' + 'libs/question/services/question-service/src/lib/useQuestion.ts' ) ).toBeTruthy(); expect( appTree.exists( - 'libs/my-service/services/my-service-service/src/lib/queries.ts' + 'libs/question/services/question-service/src/lib/queries.ts' ) ).toBeTruthy(); expect( appTree.exists( - 'libs/my-service/services/my-service-service/src/lib/my-service-service.ts' + 'libs/question/services/question-service/src/lib/question-service.ts' ) ).not.toBeTruthy(); expect( appTree.exists( - 'libs/my-service/services/my-service-service/src/lib/my-service-service.spec.ts' + 'libs/question/services/question-service/src/lib/question-service.spec.ts' ) ).not.toBeTruthy(); }); @@ -54,21 +64,27 @@ describe('Service generator', () => { }); const hookContent = appTree - .read( - 'libs/my-service/services/my-service-service/src/lib/useMyService.ts' - ) + .read('libs/question/services/question-service/src/lib/useQuestion.ts') ?.toString(); expect(hookContent).toContain('useInjectedApolloClient'); const queriesContent = appTree - .read('libs/my-service/services/my-service-service/src/lib/queries.ts') + .read('libs/question/services/question-service/src/lib/queries.ts') ?.toString(); expect(queriesContent).toContain('ApolloClient'); }); it('should generate react-query service', async () => { + mockCrudOptions([ + 'useQuestions', + 'useQuestion', + 'useCreateQuestion', + 'useUpdateQuestion', + 'useDeleteQuestion', + ]); + await serviceGenerator(appTree, { directory: '', name: projectName, @@ -76,19 +92,142 @@ describe('Service generator', () => { tags: '', }); - const hookContent = appTree + // useQuestions + let hookContent = appTree + .read('libs/question/services/question-service/src/lib/useQuestions.ts') + ?.toString(); + + expect(hookContent).toContain('useQuestions'); + + // useQuestion + hookContent = appTree + .read('libs/question/services/question-service/src/lib/useQuestion.ts') + ?.toString(); + + expect(hookContent).toContain('useQuestion'); + + // useCreateQuestion + hookContent = appTree .read( - 'libs/my-service/services/my-service-service/src/lib/useMyService.ts' + 'libs/question/services/question-service/src/lib/useCreateQuestion.ts' ) ?.toString(); - expect(hookContent).toContain('useInjectedReactQueryClient'); + expect(hookContent).toContain('useCreateQuestion'); + expect(hookContent).toContain(`method: 'POST'`); - const queriesContent = appTree - .read('libs/my-service/services/my-service-service/src/lib/queries.ts') + // useDeleteQuestion + hookContent = appTree + .read( + 'libs/question/services/question-service/src/lib/useDeleteQuestion.ts' + ) + ?.toString(); + + expect(hookContent).toContain('useDeleteQuestion'); + expect(hookContent).toContain(`method: 'DELETE'`); + + // useUpdateQuestion + hookContent = appTree + .read( + 'libs/question/services/question-service/src/lib/useUpdateQuestion.ts' + ) + ?.toString(); + + expect(hookContent).toContain('useUpdateQuestion'); + expect(hookContent).toContain(`method: 'PATCH'`); + + const indexFile = appTree + .read('libs/question/services/question-service/src/index.ts') + ?.toString(); + + expect(indexFile).toContain( + "export { useQuestions, queryQuestions, getQuestionsQueryKey } from './lib/useQuestions';" + ); + expect(indexFile).toContain( + "export { useQuestion, queryQuestion, getQuestionQueryKey } from './lib/useQuestion';" + ); + expect(indexFile).toContain( + "export { useCreateQuestion } from './lib/useCreateQuestion';" + ); + expect(indexFile).toContain( + "export { useDeleteQuestion } from './lib/useDeleteQuestion';" + ); + expect(indexFile).toContain( + "export { useUpdateQuestion } from './lib/useUpdateQuestion';" + ); + }); + + it('should generate react-query service with only selected crud operations', async () => { + mockCrudOptions(['useQuestions', 'useUpdateQuestion']); + + await serviceGenerator(appTree, { + directory: '', + name: projectName, + serviceType: 'react-query', + tags: '', + }); + + // useQuestions + let hookContent = appTree + .read('libs/question/services/question-service/src/lib/useQuestions.ts') + ?.toString(); + + expect(hookContent).toContain('useQuestions'); + + // useQuestion + hookContent = appTree + .read('libs/question/services/question-service/src/lib/useQuestion.ts') + ?.toString(); + + expect(hookContent).toBe(undefined); + + // useCreateQuestion + hookContent = appTree + .read( + 'libs/question/services/question-service/src/lib/useCreateQuestion.ts' + ) + ?.toString(); + + expect(hookContent).toBe(undefined); + + // useDeleteQuestion + hookContent = appTree + .read( + 'libs/question/services/question-service/src/lib/useDeleteQuestion.ts' + ) + ?.toString(); + + expect(hookContent).toBe(undefined); + + // useUpdateQuestion + hookContent = appTree + .read( + 'libs/question/services/question-service/src/lib/useUpdateQuestion.ts' + ) + ?.toString(); + + expect(hookContent).toContain('useUpdateQuestion'); + expect(hookContent).toContain(`method: 'PATCH'`); + + const indexFile = appTree + .read('libs/question/services/question-service/src/index.ts') ?.toString(); - expect(queriesContent).toContain('QueryClient'); + expect(indexFile).toContain( + "export { useQuestions, queryQuestions, getQuestionsQueryKey } from './lib/useQuestions';" + ); + expect(indexFile).not.toContain( + "export { useQuestion, queryQuestion, getQuestionQueryKey } from './lib/useQuestion';" + ); + expect(indexFile).not.toContain( + "export { useCreateQuestion } from './lib/useCreateQuestion';" + ); + expect(indexFile).not.toContain( + "export { useDeleteQuestion } from './lib/useDeleteQuestion';" + ); + expect(indexFile).toContain( + "export { useUpdateQuestion } from './lib/useUpdateQuestion';" + ); }); it('should camelize query name if service name includes "-"', async () => { diff --git a/packages/gene-tools/src/generators/service-generator/index.ts b/packages/gene-tools/src/generators/service-generator/index.ts index 687a19b..9922c88 100644 --- a/packages/gene-tools/src/generators/service-generator/index.ts +++ b/packages/gene-tools/src/generators/service-generator/index.ts @@ -11,6 +11,7 @@ import { import libraryGenerator from '../library-generator'; import { BrainlyServiceGenerator } from './schema'; import { getNpmScope, stringUtils } from '@nrwl/workspace'; +import inquirer = require('inquirer'); type GeneratorOptions = { name: string; @@ -51,11 +52,63 @@ const getDirectoryPath = ( return `${schema.directory}`; }; +const promptCrudFunctions = async ( + serviceName: string, + useDefaultCrudFunctions: boolean +) => { + const classifiedName = stringUtils.classify(serviceName); + if (useDefaultCrudFunctions) { + return [`use${classifiedName}s`]; + } + + const { crudFunctions } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'crudFunctions', + message: `Select CRUD functions you want to generate`, + choices: [ + { + name: `use${classifiedName}s - to get multiple ${serviceName}s`, + value: `use${classifiedName}s`, + checked: true, + }, + { + name: `useCreate${classifiedName} - to create a new ${serviceName}`, + value: `useCreate${classifiedName}`, + }, + { + name: `useUpdate${classifiedName} - to update a single ${serviceName}`, + value: `useUpdate${classifiedName}`, + }, + { + name: `useDelete${classifiedName} - to delete a single ${serviceName}`, + value: `useDelete${classifiedName}`, + }, + { + name: `use${classifiedName} - to get a single ${serviceName}`, + value: `use${classifiedName}`, + }, + ], + }, + ]); + + return crudFunctions; +}; + export default async function (tree: Tree, schema: BrainlyServiceGenerator) { const currentPackageJson = readJson(tree, 'package.json'); const name = stringUtils.dasherize(schema.name); const directory = getDirectoryPath(schema, name); + let crudFunctions: string[] = []; + + if (schema.serviceType === 'react-query') { + crudFunctions = await promptCrudFunctions( + name, + schema.useDefaultCrudFunctions + ); + } + await libraryGenerator(tree, { name: `${name}-service`, directory: directory, @@ -118,6 +171,32 @@ export default async function (tree: Tree, schema: BrainlyServiceGenerator) { }); } + if (schema.serviceType === 'react-query') { + // Remove not needed service hooks + const findNotNeededServiceHooks = tree + .listChanges() + .filter(({ path }) => { + const filename = path.split('/').pop(); + if (!filename.startsWith('use')) { + return false; + } + + return !crudFunctions.find((crudFunction) => + filename.includes(crudFunction) + ); + }) + .map(({ path }) => path); + + findNotNeededServiceHooks.forEach((path) => tree.delete(path)); + + // Update exports + const index = tree.read(`${baseOptions.targetLocation}/index.ts`, 'utf-8'); + const lines = index.split('\n').filter((line) => { + return crudFunctions.find((crudFunction) => line.includes(crudFunction)); + }); + tree.write(`${baseOptions.targetLocation}/index.ts`, lines.join('\n')); + } + await formatFiles(tree); // revert possible changes to package.json writeJson(tree, 'package.json', currentPackageJson); diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/index.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/index.ts__tmpl__ index 5708c2d..9a67bd5 100644 --- a/packages/gene-tools/src/generators/service-generator/react-query/files/index.ts__tmpl__ +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/index.ts__tmpl__ @@ -1,4 +1,5 @@ -export {use<%= fileName %>} from './lib/use<%= fileName %>'; -export {use<%= fileName %>Lazy} from './lib/use<%= fileName %>Lazy'; -export type {<%= fileName %>DataTypeAPI} from './lib/queries'; -export { query<%= fileName %> } from './lib/queries'; +export { use<%= fileName %>, query<%= fileName %>, get<%= fileName %>QueryKey } from './lib/use<%= fileName %>'; +export { use<%= fileName %>s, query<%= fileName %>s, get<%= fileName %>sQueryKey } from './lib/use<%= fileName %>s'; +export { useCreate<%= fileName %> } from './lib/useCreate<%= fileName %>'; +export { useUpdate<%= fileName %> } from './lib/useUpdate<%= fileName %>'; +export { useDelete<%= fileName %> } from './lib/useDelete<%= fileName %>'; diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/queries.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/queries.ts__tmpl__ deleted file mode 100644 index b356e4b..0000000 --- a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/queries.ts__tmpl__ +++ /dev/null @@ -1,32 +0,0 @@ -import {QueryClient, QueryFunctionContext} from '@tanstack/react-query'; -import { - reactQueryFetchWrapper, -} from '@brainly-gene/core'; -import nodeFetch from 'node-fetch'; - -export type <%= fileName %>DataTypeAPI = { - title: string; -}; -export type VariablesType = { - id: number -}; - -export const queryKey = (variables?: VariablesType) => [ - '<%= lowerCaseFileName %>-key', - variables, -]; - -export function queryFn(variables: VariablesType, context?: QueryFunctionContext) { - const url = 'https://jsonplaceholder.typicode.com/todos/${variables.id}'; - const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; - return reactQueryFetchWrapper<<%= fileName %>DataTypeAPI>(() => - fetchMethod(url) - ); -} - -export async function query<%= fileName %>(client: QueryClient, variables: VariablesType) { - return client.fetchQuery({ - queryFn: () => queryFn(variables), - queryKey: queryKey(variables), - }); -} diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useCreate__fileName__.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useCreate__fileName__.ts__tmpl__ new file mode 100644 index 0000000..e293241 --- /dev/null +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useCreate__fileName__.ts__tmpl__ @@ -0,0 +1,56 @@ +import { + useInjectedReactQueryClient, + useReactQueryLazy, +} from '@brainly-gene/core'; + +import {QueryFunctionContext} from '@tanstack/react-query'; +import { + reactQueryFetchWrapper, +} from '@brainly-gene/core'; +import nodeFetch from 'node-fetch'; + +// Change this to match output of your API +export type Create<%= fileName %>APIType = { + id: number, + userId: number, + title: string, + body: string, +}; + +// Change this to match input of your API +export type VariablesType = { + title: string, + body: string, + userId: number, +}; + +export const queryKey = (variables?: VariablesType) => [ + 'create-<%= lowerCaseFileName %>-key', + variables, +]; + +export function defaultQueryFn(variables: VariablesType, context?: QueryFunctionContext) { + const url = 'https://jsonplaceholder.typicode.com/posts'; + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapperAPIType>(() => + fetchMethod(url, { + method: 'POST', + body: JSON.stringify({ + ...variables + }), + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }) + ); +} + +export function useCreate<%= fileName %>() { + const queryClient = useInjectedReactQueryClient(); + + return useReactQueryLazyAPIType, VariablesType>({ + reactQueryClient: queryClient, + queryFn: (_client, variables) => defaultQueryFn(variables), + queryKey, + }) +} diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useDelete__fileName__.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useDelete__fileName__.ts__tmpl__ new file mode 100644 index 0000000..458520b --- /dev/null +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useDelete__fileName__.ts__tmpl__ @@ -0,0 +1,45 @@ +import { + useInjectedReactQueryClient, + useReactQueryLazy, +} from '@brainly-gene/core'; + +import {QueryFunctionContext} from '@tanstack/react-query'; +import { + reactQueryFetchWrapper, +} from '@brainly-gene/core'; +import nodeFetch from 'node-fetch'; + +// Change this to match output of your API +export type Delete<%= fileName %>APIType = { + +}; + +// Change this to match input of your API +export type VariablesType = { + id: number +}; + +export const queryKey = (variables?: VariablesType) => [ + 'delete-<%= lowerCaseFileName %>-key', + variables, +]; + +export function defaultQueryFn(variables: VariablesType, context?: QueryFunctionContext) { + const url = `https://jsonplaceholder.typicode.com/posts/${variables.id}`; + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapperAPIType>(() => + fetchMethod(url, { + method: 'DELETE' + }) + ); +} + +export function useDelete<%= fileName %>() { + const queryClient = useInjectedReactQueryClient(); + + return useReactQueryLazyAPIType, VariablesType>({ + reactQueryClient: queryClient, + queryFn: (_client, variables) => defaultQueryFn(variables), + queryKey, + }) +} diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useUpdate__fileName__.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useUpdate__fileName__.ts__tmpl__ new file mode 100644 index 0000000..8bac527 --- /dev/null +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/useUpdate__fileName__.ts__tmpl__ @@ -0,0 +1,56 @@ +import { + useInjectedReactQueryClient, + useReactQueryLazy, +} from '@brainly-gene/core'; + +import {QueryFunctionContext} from '@tanstack/react-query'; +import { + reactQueryFetchWrapper, +} from '@brainly-gene/core'; +import nodeFetch from 'node-fetch'; + +// Change this to match output of your API +export type Update<%= fileName %>APIType = { + id: number, + userId: number, + title: string, + body: string, +}; + +// Change this to match input of your API +export type VariablesType = { + title: string, + body: string, + userId: number, +}; + +export const queryKey = (variables?: VariablesType) => [ + 'update-<%= lowerCaseFileName %>-key', + variables, +]; + +export function defaultQueryFn(variables: VariablesType, context?: QueryFunctionContext) { + const url = `https://jsonplaceholder.typicode.com/posts/${variables.id}`; + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapperAPIType>(() => + fetchMethod(url, { + method: 'PATCH', + body: JSON.stringify({ + ...variables + }), + headers: { + 'Content-type': 'application/json; charset=UTF-8', + }, + }) + ); +} + +export function useUpdate<%= fileName %>() { + const queryClient = useInjectedReactQueryClient(); + + return useReactQueryLazyAPIType, VariablesType>({ + reactQueryClient: queryClient, + queryFn: (_client, variables) => defaultQueryFn(variables), + queryKey, + }) +} diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__.ts__tmpl__ index ea902e6..96144ef 100644 --- a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__.ts__tmpl__ +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__.ts__tmpl__ @@ -1,18 +1,57 @@ -import { queryFn, queryKey, VariablesType } from './queries'; import { transformReactQueryResponse, useInjectedReactQueryClient, } from '@brainly-gene/core'; -import { useQuery } from '@tanstack/react-query'; -export function use<%= fileName %>(props: {variables: VariablesType}) { +import {QueryClient, QueryFunctionContext, useQuery} from '@tanstack/react-query'; +import { + reactQueryFetchWrapper, +} from '@brainly-gene/core'; +import nodeFetch from 'node-fetch'; + +// Change this to match output of your API +export type <%= fileName %>APIType = { + id: number, + userId: number, + title: string, + body: string, +}; + +// Change this to match input of your API +export type VariablesType = { + postId: number +}; + +export const get<%= fileName %>QueryKey = (variables?: VariablesType) => [ + 'get-<%= lowerCaseFileName %>-key', + variables, +]; + +export function defaultQueryFn(variables: VariablesType, context?: QueryFunctionContext) { + const url = `https://jsonplaceholder.typicode.com/posts/${variables.postId}`; + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapper<<%= fileName %>APIType>(() => + fetchMethod(url) + ); +} + +// Use this function to run this query on SSR, pass the subapp as queryFn +export async function query<%= fileName %>(client: QueryClient, variables: VariablesType, queryFn = defaultQueryFn) { + return client.fetchQuery({ + queryFn: () => queryFn(variables), + queryKey: get<%= fileName %>QueryKey(variables), + }); +} + +export function use<%= fileName %>(props: {variables: VariablesType}, skip = false) { const queryClient = useInjectedReactQueryClient(); // useInfiniteQuery if paging is needed const result = useQuery( { - queryKey: queryKey(props.variables), - queryFn: (ctx) => queryFn(props.variables, ctx), + queryKey: get<%= fileName %>QueryKey(props.variables), + queryFn: (ctx) => defaultQueryFn(props.variables, ctx), + enabled: !skip }, queryClient ); diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__Lazy.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__Lazy.ts__tmpl__ deleted file mode 100644 index 040316a..0000000 --- a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__Lazy.ts__tmpl__ +++ /dev/null @@ -1,15 +0,0 @@ -import {queryFn, <%= fileName %>DataTypeAPI, queryKey, VariablesType} from './queries'; -import { - useInjectedReactQueryClient, - useReactQueryLazy, -} from '@brainly-gene/core'; - -export function use<%= fileName %>Lazy() { - const queryClient = useInjectedReactQueryClient(); - - return useReactQueryLazy<<%= fileName %>DataTypeAPI, VariablesType>({ - reactQueryClient: queryClient, - queryFn: (_client, variables) => queryFn(variables), - queryKey, - }) -} diff --git a/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__s.ts__tmpl__ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__s.ts__tmpl__ new file mode 100644 index 0000000..4019cde --- /dev/null +++ b/packages/gene-tools/src/generators/service-generator/react-query/files/lib/use__fileName__s.ts__tmpl__ @@ -0,0 +1,60 @@ +import { + transformReactQueryResponse, + useInjectedReactQueryClient, +} from '@brainly-gene/core'; + +import {QueryClient, QueryFunctionContext, useQuery} from '@tanstack/react-query'; +import { + reactQueryFetchWrapper, +} from '@brainly-gene/core'; +import nodeFetch from 'node-fetch'; + +// Change this to match output of your API +export type <%= fileName %>sAPIType = { + id: number, + userId: number, + title: string, + body: string, +}[]; + +// Change this to match input of your API +export type VariablesType = { + userId: number +}; + +export const get<%= fileName %>sQueryKey = (variables?: VariablesType) => [ + 'get-<%= lowerCaseFileName %>s-key', + variables, +]; + +export function defaultQueryFn(variables: VariablesType, context?: QueryFunctionContext) { + const url = `https://jsonplaceholder.typicode.com/posts?userId=${variables.userId}`; + const fetchMethod = typeof window === 'undefined' ? nodeFetch : fetch; + return reactQueryFetchWrapper<<%= fileName %>sAPIType>(() => + fetchMethod(url) + ); +} + +// Use this function to run this query on SSR, pass the subapp as queryFn +export async function query<%= fileName %>s(client: QueryClient, variables: VariablesType, queryFn = defaultQueryFn) { + return client.fetchQuery({ + queryFn: () => queryFn(variables), + queryKey: get<%= fileName %>sQueryKey(variables), + }); +} + +export function use<%= fileName %>s(props: {variables: VariablesType}, skip = false) { + const queryClient = useInjectedReactQueryClient(); + + // useInfiniteQuery if paging is needed + const result = useQuery( + { + queryKey: get<%= fileName %>sQueryKey(props.variables), + queryFn: (ctx) => defaultQueryFn(props.variables, ctx), + enabled: !skip + }, + queryClient + ); + + return transformReactQueryResponse(result); +} diff --git a/packages/gene-tools/src/generators/service-generator/schema.d.ts b/packages/gene-tools/src/generators/service-generator/schema.d.ts index e549cb1..cf5554e 100644 --- a/packages/gene-tools/src/generators/service-generator/schema.d.ts +++ b/packages/gene-tools/src/generators/service-generator/schema.d.ts @@ -5,4 +5,5 @@ export interface BrainlyServiceGenerator { directory: string; tags: string; serviceType: ServiceType; + useDefaultCrudFunctions?: boolean } diff --git a/packages/gene-tools/src/generators/service-generator/schema.json b/packages/gene-tools/src/generators/service-generator/schema.json index 536143f..cd51260 100644 --- a/packages/gene-tools/src/generators/service-generator/schema.json +++ b/packages/gene-tools/src/generators/service-generator/schema.json @@ -13,11 +13,12 @@ "name": { "type": "string", "description": "Service name", + "default": "post", "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "What is the service name? (without -service suffix)" + "x-prompt": "What is the service name? (the name should be an entity name in singular form - for example post, user, book, etc.)" }, "directory": { "type": "string", @@ -31,10 +32,15 @@ }, "serviceType": { "type": "string", - "enum": ["apollo", "react-query"], + "enum": ["react-query", "apollo"], "description": "Service type to create", - "default": "apollo", + "default": "react-query", "x-prompt": "What is the service type?" + }, + "useDefaultCrudFunctions": { + "type": "boolean", + "description": "Use default CRUD functions", + "default": false } }, "required": ["name"]