Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add mutation plugin #46

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion playground/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,6 +14,7 @@ const router = createRouter({

app.use(createPinia())
app.use(QueryPlugin, {})
app.use(MutationPlugin, {})
app.use(router)

app.mount('#app')
Expand Down
2 changes: 1 addition & 1 deletion src/define-mutation.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -26,6 +28,7 @@ export {
} from './query-options'

export { QueryPlugin, type QueryPluginOptions } from './query-plugin'
export { MutationPlugin, type MutationPluginOptions } from './mutation-plugin'

export {
useQueryCache,
Expand Down
155 changes: 155 additions & 0 deletions src/mutation-options.ts
Original file line number Diff line number Diff line change
@@ -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<TVars> =
| EntryKey
| ((vars: TVars) => EntryKey)

// TODO: move to a plugin
/**
* The keys to invalidate when a mutation succeeds.
* @internal
*/
type _MutationKeys<TVars, TResult> =
| 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> = 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<any, any> | 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<TContext>

/**
* Runs if the mutation encounters an error.
*/
onError?: (
context: { error: TError, vars: TVars } & UseMutationGlobalContext &
_ReduceContext<TContext>,
) => unknown

/**
* Runs if the mutation is successful.
*/
onSuccess?: (
context: { data: TResult, vars: TVars } & UseMutationGlobalContext &
_ReduceContext<TContext>,
) => unknown

/**
* Runs after the mutation is settled, regardless of the result.
*/
onSettled?: (
context: {
data: TResult | undefined
error: TError | undefined
vars: TVars
} & UseMutationGlobalContext &
_ReduceContext<TContext>,
) => unknown
}

export interface UseMutationOptions<
TResult = unknown,
TVars = void,
TError = ErrorDefault,
TContext extends Record<any, any> | void | null = void,
> extends UseMutationCallbacks<TResult, TVars, TError, TContext> {
/**
* 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<TContext>) => Promise<TResult>

key?: _MutationKey<TVars>

// TODO: move this to a plugin that calls invalidateEntry()
/**
* Keys to invalidate if the mutation succeeds so that `useMutation()` refetch if used.
*/
keys?: _MutationKeys<TVars, TResult>

// 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)!
126 changes: 126 additions & 0 deletions src/mutation-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '<div></div>',
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: '<div></div>',
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,
})
})
})
30 changes: 30 additions & 0 deletions src/mutation-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, unknown, unknown> {
/**
* 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?: <TResult = unknown, TVars = void, TError = ErrorDefault, TContext extends Record<any, any> | void | null = void>(
context: _Simplify<
UseMutationReturn<TResult, TVars, TError> & {
options: UseMutationOptions<TResult, TVars, TError, TContext>
}
>,
) => void | Promise<never>
}

export function MutationPlugin(
app: App,
useMutationOptions: MutationPluginOptions = {},
) {
app.provide(USE_MUTATION_OPTIONS_KEY, {
...useMutationOptions,
})
}
Loading