diff --git a/README.md b/README.md index 265479b..988ab93 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,16 @@ Install the plugins for the features you need: ```js import { createPinia } from 'pinia' -import { QueryPlugin } from '@pinia/colada' +import { MutationPlugin, QueryPlugin } from '@pinia/colada' app.use(createPinia()) // install after pinia app.use(QueryPlugin, { // optional options }) +app.use(MutationPlugin, { + // optional options +}) ``` ## Usage diff --git a/playground/src/main.ts b/playground/src/main.ts index a8dabd5..8262340 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -1,7 +1,7 @@ import { createApp } from 'vue' import { createRouter, createWebHistory } from 'vue-router/auto' import { createPinia } from 'pinia' -import { QueryPlugin } from '@pinia/colada' +import { MutationPlugin, QueryPlugin } from '@pinia/colada' import './style.css' import 'water.css' @@ -14,6 +14,7 @@ const router = createRouter({ app.use(createPinia()) app.use(QueryPlugin, {}) +app.use(MutationPlugin, {}) app.use(router) app.mount('#app') diff --git a/src/define-mutation.ts b/src/define-mutation.ts index 36778c0..b2bf49b 100644 --- a/src/define-mutation.ts +++ b/src/define-mutation.ts @@ -1,9 +1,9 @@ import type { ErrorDefault } from './types-extension' import { - type UseMutationOptions, type UseMutationReturn, useMutation, } from './use-mutation' +import type { UseMutationOptions } from './mutation-options' /** * Define a mutation with the given options. Similar to `useMutation(options)` but allows you to reuse the mutation in diff --git a/src/index.ts b/src/index.ts index 1a17a2c..b371e12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,13 @@ export { useMutation, type UseMutationReturn, +} from './use-mutation' +export { type UseMutationOptions, type _ReduceContext, type _EmptyObject, type MutationStatus, -} from './use-mutation' +} from './mutation-options' export { defineMutation } from './define-mutation' export { useQuery, type UseQueryReturn } from './use-query' @@ -26,6 +28,7 @@ export { } from './query-options' export { QueryPlugin, type QueryPluginOptions } from './query-plugin' +export { MutationPlugin, type MutationPluginOptions } from './mutation-plugin' export { useQueryCache, diff --git a/src/mutation-options.ts b/src/mutation-options.ts new file mode 100644 index 0000000..f8d5025 --- /dev/null +++ b/src/mutation-options.ts @@ -0,0 +1,155 @@ +import type { InjectionKey } from 'vue' +import { inject } from 'vue' +import type { EntryKey } from './entry-options' +import type { ErrorDefault } from './types-extension' +import type { _Awaitable } from './utils' +import type { MutationPluginOptions } from './mutation-plugin' + +type _MutationKey = + | EntryKey + | ((vars: TVars) => EntryKey) + +// TODO: move to a plugin +/** + * The keys to invalidate when a mutation succeeds. + * @internal + */ +type _MutationKeys = + | EntryKey[] + | ((data: TResult, vars: TVars) => EntryKey[]) + +/** + * The status of the mutation. + * - `pending`: initial state + * - `loading`: mutation is being made + * - `error`: when the last mutation failed + * - `success`: when the last mutation succeeded + */ +export type MutationStatus = 'pending' | 'loading' | 'error' | 'success' + +/** + * To avoid using `{}` + * @internal + */ +export interface _EmptyObject {} + +/** + * Removes the nullish types from the context type to make `A & TContext` work instead of yield `never`. + * @internal + */ +export type _ReduceContext = TContext extends void | null | undefined + ? _EmptyObject + : TContext + +/** + * Context object returned by a global `onMutate` function that is merged with the context returned by a local + * `onMutate`. + * @example + * ```ts + * declare module '@pinia/colada' { + * export interface UseMutationGlobalContext { + * router: Router // from vue-router + * } + * } + * + * // add the `router` to the context + * app.use(MutationPlugin, { + * onMutate() { + * return { router } + * }, + * }) + * ``` + */ +export interface UseMutationGlobalContext {} + +export interface UseMutationCallbacks< + TResult = unknown, + TVars = void, + TError = ErrorDefault, + TContext extends Record | void | null = void, +> { + /** + * Runs before the mutation is executed. **It should be placed before `mutation()` for `context` to be inferred**. It + * can return a value that will be passed to `mutation`, `onSuccess`, `onError` and `onSettled`. If it returns a + * promise, it will be awaited before running `mutation`. + * + * @example + * ```ts + * useMutation({ + * // must appear before `mutation` for `{ foo: string }` to be inferred + * // within `mutation` + * onMutate() { + * return { foo: 'bar' } + * }, + * mutation: (id: number, { foo }) => { + * console.log(foo) // bar + * return fetch(`/api/todos/${id}`) + * }, + * onSuccess(context) { + * console.log(context.foo) // bar + * }, + * }) + * ``` + */ + onMutate?: (vars: TVars) => _Awaitable + + /** + * Runs if the mutation encounters an error. + */ + onError?: ( + context: { error: TError, vars: TVars } & UseMutationGlobalContext & + _ReduceContext, + ) => unknown + + /** + * Runs if the mutation is successful. + */ + onSuccess?: ( + context: { data: TResult, vars: TVars } & UseMutationGlobalContext & + _ReduceContext, + ) => unknown + + /** + * Runs after the mutation is settled, regardless of the result. + */ + onSettled?: ( + context: { + data: TResult | undefined + error: TError | undefined + vars: TVars + } & UseMutationGlobalContext & + _ReduceContext, + ) => unknown +} + +export interface UseMutationOptions< + TResult = unknown, + TVars = void, + TError = ErrorDefault, + TContext extends Record | void | null = void, +> extends UseMutationCallbacks { + /** + * The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it + */ + mutation: (vars: TVars, context: NoInfer) => Promise + + key?: _MutationKey + + // TODO: move this to a plugin that calls invalidateEntry() + /** + * Keys to invalidate if the mutation succeeds so that `useMutation()` refetch if used. + */ + keys?: _MutationKeys + + // TODO: invalidate options exact, refetch, etc +} + +export const USE_MUTATION_OPTIONS_KEY: InjectionKey< + MutationPluginOptions +> = process.env.NODE_ENV !== 'production' ? Symbol('useMutationOptions') : Symbol() + +/** + * Injects the global mutation options. + * @internal + */ +export const useMutationOptions = () => inject(USE_MUTATION_OPTIONS_KEY)! diff --git a/src/mutation-plugin.spec.ts b/src/mutation-plugin.spec.ts new file mode 100644 index 0000000..160b259 --- /dev/null +++ b/src/mutation-plugin.spec.ts @@ -0,0 +1,126 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { createPinia } from 'pinia' +import { useQuery } from './use-query' +import { QueryPlugin } from './query-plugin' +import { MutationPlugin } from './mutation-plugin' +import { useMutation } from './use-mutation' + +describe('MutationPlugin', () => { + const MyComponent = defineComponent({ + template: '
', + setup() { + return { + ...useQuery({ + query: async () => 42, + key: ['key'], + }), + ...useMutation({ + mutation: async (arg: number) => arg, + keys: [['key']], + }), + } + }, + }) + + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + enableAutoUnmount(afterEach) + + it('calls the hooks on success', async () => { + const onMutate = vi.fn() + const onSuccess = vi.fn() + const onSettled = vi.fn() + const onError = vi.fn() + const wrapper = mount(MyComponent, { + global: { + plugins: [ + createPinia(), + [QueryPlugin], + [MutationPlugin, { onMutate, onSuccess, onSettled, onError }], + ], + }, + }) + + await flushPromises() + + wrapper.vm.mutate(1) + + await flushPromises() + + expect(onMutate).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + expect(onError).not.toHaveBeenCalled() + expect(onSuccess).toHaveBeenCalledWith({ + data: 1, + vars: 1, + }) + expect(onSettled).toHaveBeenCalledWith({ + data: 1, + error: undefined, + vars: 1, + }) + }) + + it('calls the hooks on error', async () => { + const onSuccess = vi.fn() + const onSettled = vi.fn() + const onError = vi.fn() + const wrapper = mount( + defineComponent({ + template: '
', + setup() { + return { + ...useQuery({ + query: async () => 42, + key: ['key'], + }), + ...useMutation({ + mutation: async () => { + throw new Error(':(') + }, + keys: [['key']], + }), + } + }, + }), + { + global: { + plugins: [ + createPinia(), + [QueryPlugin], + [MutationPlugin, { onSuccess, onSettled, onError }], + ], + }, + }, + ) + + await flushPromises() + + wrapper.vm.mutate() + + await flushPromises() + + expect(onSuccess).not.toHaveBeenCalled() + expect(onSettled).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(1) + + expect(onError).toHaveBeenCalledWith({ + error: new Error(':('), + vars: undefined, + }) + expect(onSettled).toHaveBeenCalledWith({ + data: undefined, + error: new Error(':('), + vars: undefined, + }) + }) +}) diff --git a/src/mutation-plugin.ts b/src/mutation-plugin.ts new file mode 100644 index 0000000..f904682 --- /dev/null +++ b/src/mutation-plugin.ts @@ -0,0 +1,30 @@ +import type { App } from 'vue' +import type { ErrorDefault } from './types-extension' +import type { _Simplify } from './utils' +import type { UseMutationCallbacks, UseMutationOptions } from './mutation-options' +import { USE_MUTATION_OPTIONS_KEY } from './mutation-options' +import type { UseMutationReturn } from './use-mutation' + +export interface MutationPluginOptions extends UseMutationCallbacks { + /** + * Executes setup code inside `useMutation()` to add custom behavior to all mutations. **Must be synchronous**. + * + * @param context - properties of the `useMutation` return value and the options + */ + setup?: | void | null = void>( + context: _Simplify< + UseMutationReturn & { + options: UseMutationOptions + } + >, + ) => void | Promise +} + +export function MutationPlugin( + app: App, + useMutationOptions: MutationPluginOptions = {}, +) { + app.provide(USE_MUTATION_OPTIONS_KEY, { + ...useMutationOptions, + }) +} diff --git a/src/use-mutation.spec.ts b/src/use-mutation.spec.ts index 975344f..8ca9c14 100644 --- a/src/use-mutation.spec.ts +++ b/src/use-mutation.spec.ts @@ -4,9 +4,10 @@ import { createPinia } from 'pinia' import { defineComponent } from 'vue' import type { GlobalMountOptions } from '../test/utils' import { delay } from '../test/utils' -import type { UseMutationOptions } from './use-mutation' +import type { UseMutationOptions } from './mutation-options' import { useMutation } from './use-mutation' import { QueryPlugin } from './query-plugin' +import { MutationPlugin } from './mutation-plugin' describe('useMutation', () => { beforeEach(() => { @@ -41,7 +42,7 @@ describe('useMutation', () => { }), { global: { - plugins: [createPinia(), QueryPlugin], + plugins: [createPinia(), QueryPlugin, MutationPlugin], ...mountOptions, }, }, diff --git a/src/use-mutation.ts b/src/use-mutation.ts index b4a1277..d10b0be 100644 --- a/src/use-mutation.ts +++ b/src/use-mutation.ts @@ -1,141 +1,10 @@ import { computed, shallowRef } from 'vue' import type { ComputedRef, ShallowRef } from 'vue' import { useQueryCache } from './query-store' -import type { EntryKey } from './entry-options' import type { ErrorDefault } from './types-extension' -import { type _Awaitable, noop } from './utils' - -type _MutationKey = - | EntryKey - | ((vars: TVars) => EntryKey) - -// TODO: move to a plugin -/** - * The keys to invalidate when a mutation succeeds. - * @internal - */ -type _MutationKeys = - | EntryKey[] - | ((data: TResult, vars: TVars) => EntryKey[]) - -/** - * The status of the mutation. - * - `pending`: initial state - * - `loading`: mutation is being made - * - `error`: when the last mutation failed - * - `success`: when the last mutation succeeded - */ -export type MutationStatus = 'pending' | 'loading' | 'error' | 'success' - -/** - * To avoid using `{}` - * @internal - */ -export interface _EmptyObject {} - -/** - * Removes the nullish types from the context type to make `A & TContext` work instead of yield `never`. - * @internal - */ -export type _ReduceContext = TContext extends void | null | undefined - ? _EmptyObject - : TContext - -/** - * Context object returned by a global `onMutate` function that is merged with the context returned by a local - * `onMutate`. - * @example - * ```ts - * declare module '@pinia/colada' { - * export interface UseMutationGlobalContext { - * router: Router // from vue-router - * } - * } - * - * // add the `router` to the context - * app.use(MutationPlugin, { - * onMutate() { - * return { router } - * }, - * }) - * ``` - */ -export interface UseMutationGlobalContext {} - -export interface UseMutationOptions< - TResult = unknown, - TVars = void, - TError = ErrorDefault, - TContext extends Record | void | null = void, -> { - /** - * The key of the mutation. If the mutation is successful, it will invalidate the query with the same key and refetch it - */ - mutation: (vars: TVars, context: NoInfer) => Promise - - key?: _MutationKey - - // TODO: move this to a plugin that calls invalidateEntry() - /** - * Keys to invalidate if the mutation succeeds so that `useQuery()` refetch if used. - */ - keys?: _MutationKeys - - /** - * Runs before the mutation is executed. **It should be placed before `mutation()` for `context` to be inferred**. It - * can return a value that will be passed to `mutation`, `onSuccess`, `onError` and `onSettled`. If it returns a - * promise, it will be awaited before running `mutation`. - * - * @example - * ```ts - * useMutation({ - * // must appear before `mutation` for `{ foo: string }` to be inferred - * // within `mutation` - * onMutate() { - * return { foo: 'bar' } - * }, - * mutation: (id: number, { foo }) => { - * console.log(foo) // bar - * return fetch(`/api/todos/${id}`) - * }, - * onSuccess(context) { - * console.log(context.foo) // bar - * }, - * }) - * ``` - */ - onMutate?: (vars: TVars) => _Awaitable - - /** - * Runs if the mutation encounters an error. - */ - onError?: ( - context: { error: TError, vars: TVars } & UseMutationGlobalContext & - _ReduceContext, - ) => unknown - - /** - * Runs if the mutation is successful. - */ - onSuccess?: ( - context: { data: TResult, vars: TVars } & UseMutationGlobalContext & - _ReduceContext, - ) => unknown - - /** - * Runs after the mutation is settled, regardless of the result. - */ - onSettled?: ( - context: { - data: TResult | undefined - error: TError | undefined - vars: TVars - } & UseMutationGlobalContext & - _ReduceContext, - ) => unknown - - // TODO: invalidate options exact, refetch, etc -} +import { noop } from './utils' +import type { MutationStatus, UseMutationOptions, _ReduceContext } from './mutation-options' +import { useMutationOptions } from './mutation-options' // export const USE_MUTATIONS_DEFAULTS = {} satisfies Partial @@ -209,6 +78,7 @@ export function useMutation< options: UseMutationOptions, ): UseMutationReturn { const store = useQueryCache() + const MUTATION_PLUGIN_OPTIONS = useMutationOptions() // TODO: there could be a mutation store that stores the state based on an optional key (if passed). This would allow to retrieve the state of a mutation with useMutationState(key) const status = shallowRef('pending') @@ -231,13 +101,21 @@ export function useMutation< try { // NOTE: the cast makes it easier to write without extra code. It's safe because { ...null, ...undefined } works and TContext must be a Record context = (await options.onMutate?.(vars)) as _ReduceContext + if (MUTATION_PLUGIN_OPTIONS.onMutate) { + context = { + ...(await MUTATION_PLUGIN_OPTIONS.onMutate(vars)) as _ReduceContext, + ...context, + } + } const newData = (currentData = await options.mutation( vars, context as TContext, )) - await options.onSuccess?.({ data: newData, vars, ...context }) + const onSuccessArgs = { data: newData, vars, ...context } + await options.onSuccess?.(onSuccessArgs) + await MUTATION_PLUGIN_OPTIONS.onSuccess?.(onSuccessArgs) if (pendingCall === currentCall) { data.value = newData @@ -258,19 +136,23 @@ export function useMutation< } } catch (newError: any) { currentError = newError - await options.onError?.({ error: newError, vars, ...context }) + const onErrorArgs = { error: newError, vars, ...context } + await options.onError?.(onErrorArgs) + await MUTATION_PLUGIN_OPTIONS.onError?.(onErrorArgs) if (pendingCall === currentCall) { error.value = newError status.value = 'error' } throw newError } finally { - await options.onSettled?.({ + const onSettledArgs = { data: currentData, error: currentError, vars, ...context, - }) + } + await options.onSettled?.(onSettledArgs) + await MUTATION_PLUGIN_OPTIONS.onSettled?.(onSettledArgs) } return currentData @@ -286,7 +168,7 @@ export function useMutation< status.value = 'pending' } - return { + const mutationReturn: UseMutationReturn = { data, isLoading: computed(() => status.value === 'loading'), status, @@ -297,4 +179,11 @@ export function useMutation< mutateAsync, reset, } + + MUTATION_PLUGIN_OPTIONS.setup?.({ + ...mutationReturn, + options, + }) + + return mutationReturn } diff --git a/src/use-query.spec.ts b/src/use-query.spec.ts index 2557948..f9bfc06 100644 --- a/src/use-query.spec.ts +++ b/src/use-query.spec.ts @@ -12,6 +12,7 @@ import { QUERY_STORE_ID, createQueryEntry, useQueryCache } from './query-store' import { TreeMapNode, entryNodeSize } from './tree-map' import type { UseQueryOptions } from './query-options' import { QueryPlugin } from './query-plugin' +import { MutationPlugin } from './mutation-plugin' describe('useQuery', () => { beforeEach(() => { @@ -52,7 +53,7 @@ describe('useQuery', () => { { global: { ...mountOptions, - plugins: [...(mountOptions?.plugins || [createPinia()]), QueryPlugin], + plugins: [...(mountOptions?.plugins || [createPinia()]), QueryPlugin, MutationPlugin], }, }, )