diff --git a/.changeset/smart-spiders-sing.md b/.changeset/smart-spiders-sing.md new file mode 100644 index 00000000000..c98d04cd144 --- /dev/null +++ b/.changeset/smart-spiders-sing.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-query': minor +--- + +feat(svelte-query): add 'mutationOptions' diff --git a/docs/config.json b/docs/config.json index d905826fb49..bfdcf8790f6 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1140,6 +1140,10 @@ { "label": "Functions / infiniteQueryOptions", "to": "framework/svelte/reference/functions/infiniteQueryOptions" + }, + { + "label": "Functions / mutationOptions", + "to": "framework/svelte/reference/functions/mutationOptions" } ] }, diff --git a/docs/framework/svelte/reference/functions/mutationOptions.md b/docs/framework/svelte/reference/functions/mutationOptions.md new file mode 100644 index 00000000000..ba6aa257cb4 --- /dev/null +++ b/docs/framework/svelte/reference/functions/mutationOptions.md @@ -0,0 +1,78 @@ +--- +id: mutationOptions +title: mutationOptions +--- + +# Function: mutationOptions() + +## Call Signature + +```ts +function mutationOptions(options): WithRequired, 'mutationKey'> +``` + +Defined in: [packages/svelte-query/src/mutationOptions.ts](https://github.com/TanStack/query/blob/main/packages/svelte-query/src/mutationOptions.ts) + +### Type Parameters + +#### TData + +`TData` = `unknown` + +#### TError + +`TError` = `Error` + +#### TVariables + +`TVariables` = `void` + +#### TOnMutateResult + +`TOnMutateResult` = `unknown` + +### Parameters + +#### options + +`WithRequired`\<[`CreateMutationOptions`](../type-aliases/CreateMutationOptions.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\>, `'mutationKey'`\> + +### Returns + +`WithRequired`\<[`CreateMutationOptions`](../type-aliases/CreateMutationOptions.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\>, `'mutationKey'`\> + +## Call Signature + +```ts +function mutationOptions(options): Omit, 'mutationKey'> +``` + +Defined in: [packages/svelte-query/src/mutationOptions.ts](https://github.com/TanStack/query/blob/main/packages/svelte-query/src/mutationOptions.ts) + +### Type Parameters + +#### TData + +`TData` = `unknown` + +#### TError + +`TError` = `Error` + +#### TVariables + +`TVariables` = `void` + +#### TOnMutateResult + +`TOnMutateResult` = `unknown` + +### Parameters + +#### options + +`Omit`\<[`CreateMutationOptions`](../type-aliases/CreateMutationOptions.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\>, `'mutationKey'`\> + +### Returns + +`Omit`\<[`CreateMutationOptions`](../type-aliases/CreateMutationOptions.md)\<`TData`, `TError`, `TVariables`, `TOnMutateResult`\>, `'mutationKey'`\> diff --git a/packages/svelte-query/src/index.ts b/packages/svelte-query/src/index.ts index 1b74a9be912..dfd6b86cee9 100644 --- a/packages/svelte-query/src/index.ts +++ b/packages/svelte-query/src/index.ts @@ -17,6 +17,7 @@ export { queryOptions } from './queryOptions.js' export { createQueries } from './createQueries.svelte.js' export { createInfiniteQuery } from './createInfiniteQuery.js' export { infiniteQueryOptions } from './infiniteQueryOptions.js' +export { mutationOptions } from './mutationOptions.js' export { createMutation } from './createMutation.svelte.js' export { useMutationState } from './useMutationState.svelte.js' export { useQueryClient } from './useQueryClient.js' diff --git a/packages/svelte-query/src/mutationOptions.ts b/packages/svelte-query/src/mutationOptions.ts new file mode 100644 index 00000000000..7f967804a04 --- /dev/null +++ b/packages/svelte-query/src/mutationOptions.ts @@ -0,0 +1,41 @@ +import type { DefaultError, WithRequired } from '@tanstack/query-core' +import type { CreateMutationOptions } from './types.js' + +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: WithRequired< + CreateMutationOptions, + 'mutationKey' + >, +): WithRequired< + CreateMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: Omit< + CreateMutationOptions, + 'mutationKey' + >, +): Omit< + CreateMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>( + options: CreateMutationOptions, +): CreateMutationOptions { + return options +} diff --git a/packages/svelte-query/tests/mutationOptions/BaseExample.svelte b/packages/svelte-query/tests/mutationOptions/BaseExample.svelte new file mode 100644 index 00000000000..0a625125725 --- /dev/null +++ b/packages/svelte-query/tests/mutationOptions/BaseExample.svelte @@ -0,0 +1,57 @@ + + + + +
isMutating: {isMutating.current}
+
clientIsMutating: {clientIsMutating}
+
+ mutationState: {JSON.stringify(mutationState.map((state) => state.data))} +
diff --git a/packages/svelte-query/tests/mutationOptions/MultiExample.svelte b/packages/svelte-query/tests/mutationOptions/MultiExample.svelte new file mode 100644 index 00000000000..d0272a4827b --- /dev/null +++ b/packages/svelte-query/tests/mutationOptions/MultiExample.svelte @@ -0,0 +1,61 @@ + + + + + +
isMutating: {isMutating.current}
+
clientIsMutating: {clientIsMutating}
+
+ mutationState: {JSON.stringify(mutationState.map((state) => state.data))} +
diff --git a/packages/svelte-query/tests/mutationOptions/mutationOptions.svelte.test.ts b/packages/svelte-query/tests/mutationOptions/mutationOptions.svelte.test.ts new file mode 100644 index 00000000000..214a7e17511 --- /dev/null +++ b/packages/svelte-query/tests/mutationOptions/mutationOptions.svelte.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/svelte' +import { sleep } from '@tanstack/query-test-utils' +import { mutationOptions } from '../../src/index.js' +import BaseExample from './BaseExample.svelte' +import MultiExample from './MultiExample.svelte' + +describe('mutationOptions', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => { + const object = { + mutationKey: ['key'], + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => { + const object = { + mutationFn: () => sleep(10).then(() => 5), + } as const + + expect(mutationOptions(object)).toStrictEqual(object) + }) + + it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { mutationOpts: () => mutationOpts }, + }) + + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { mutationOpts: () => mutationOpts }, + }) + + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with useIsMutating', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + }, + }) + + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isMutating: 2')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['key'], + mutationFn: () => sleep(50).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(50).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + isMutatingFilters: { mutationKey: mutationOpts1.mutationKey }, + }, + }) + + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('isMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(51) + expect(rendered.getByText('isMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { + mutationOpts: () => mutationOpts, + isMutatingFilters: { mutationKey: mutationOpts.mutationKey }, + }, + }) + + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(501) + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { mutationOpts: () => mutationOpts }, + }) + + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(501) + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + }, + }) + + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('clientIsMutating: 2')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(501) + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + }) + + it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpts1.mutationKey)', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(500).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(500).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + isMutatingFilters: { mutationKey: mutationOpts1.mutationKey }, + }, + }) + + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(501) + expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument() + }) + + it('should return mutation states when used with useMutationState (with mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { + mutationOpts: () => mutationOpts, + mutationStateOpts: { + filters: { + mutationKey: mutationOpts.mutationKey, + status: 'success', + }, + }, + }, + }) + + expect(rendered.getByText('mutationState: []')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('mutationState: ["data"]')).toBeInTheDocument() + }) + + it('should return mutation states when used with useMutationState (without mutationKey in mutationOptions)', async () => { + const mutationOpts = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data'), + }) + + const rendered = render(BaseExample, { + props: { + mutationOpts: () => mutationOpts, + mutationStateOpts: { + filters: { status: 'success' }, + }, + }, + }) + + expect(rendered.getByText('mutationState: []')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('mutationState: ["data"]')).toBeInTheDocument() + }) + + it('should return mutation states when used with useMutationState', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + mutationStateOpts: { + filters: { status: 'success' }, + }, + }, + }) + + expect(rendered.getByText('mutationState: []')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(11) + expect( + rendered.getByText('mutationState: ["data1","data2"]'), + ).toBeInTheDocument() + }) + + it('should return mutation states when used with useMutationState (filter mutationOpts1.mutationKey)', async () => { + const mutationOpts1 = mutationOptions({ + mutationKey: ['mutation'], + mutationFn: () => sleep(10).then(() => 'data1'), + }) + const mutationOpts2 = mutationOptions({ + mutationFn: () => sleep(10).then(() => 'data2'), + }) + + const rendered = render(MultiExample, { + props: { + mutationOpts1: () => mutationOpts1, + mutationOpts2: () => mutationOpts2, + mutationStateOpts: { + filters: { + mutationKey: mutationOpts1.mutationKey, + status: 'success', + }, + }, + }, + }) + + expect(rendered.getByText('mutationState: []')).toBeInTheDocument() + + fireEvent.click(rendered.getByRole('button', { name: /mutate1/i })) + fireEvent.click(rendered.getByRole('button', { name: /mutate2/i })) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('mutationState: ["data1"]')).toBeInTheDocument() + }) +}) diff --git a/packages/svelte-query/tests/mutationOptions/mutationOptions.test-d.ts b/packages/svelte-query/tests/mutationOptions/mutationOptions.test-d.ts new file mode 100644 index 00000000000..cbeddf921ba --- /dev/null +++ b/packages/svelte-query/tests/mutationOptions/mutationOptions.test-d.ts @@ -0,0 +1,224 @@ +import { assertType, describe, expectTypeOf, test } from 'vitest' +import { QueryClient } from '@tanstack/query-core' +import { + createMutation, + mutationOptions, + useIsMutating, + useMutationState, +} from '../../src/index.js' +import type { + DefaultError, + MutationFunctionContext, + MutationState, + WithRequired, +} from '@tanstack/query-core' +import type { + CreateMutationOptions, + CreateMutationResult, +} from '../../src/types.js' + +describe('mutationOptions', () => { + test('Should not allow excess properties', () => { + // @ts-expect-error this is a good error, because onMutates does not exist! + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutates: 1000, + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + test('Should infer types for callbacks', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + test('Should infer types for onError callback', () => { + mutationOptions({ + mutationFn: () => { + throw new Error('fail') + }, + mutationKey: ['key'], + onError: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) + }) + + test('Should infer types for variables', () => { + mutationOptions({ + mutationFn: (vars) => { + expectTypeOf(vars).toEqualTypeOf<{ id: string }>() + return Promise.resolve(5) + }, + mutationKey: ['with-vars'], + }) + }) + + test('Should infer result type correctly', () => { + mutationOptions({ + mutationFn: () => Promise.resolve(5), + mutationKey: ['key'], + onMutate: () => { + return { name: 'onMutateResult' } + }, + onSuccess: (_data, _variables, onMutateResult) => { + expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>() + }, + }) + }) + + test('Should infer context type correctly', () => { + mutationOptions({ + mutationFn: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + return Promise.resolve(5) + }, + mutationKey: ['key'], + onMutate: (_variables, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onError: (_error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + onSettled: (_data, _error, _variables, _onMutateResult, context) => { + expectTypeOf(context).toEqualTypeOf() + }, + }) + }) + + test('Should error if mutationFn return type mismatches TData', () => { + assertType( + mutationOptions({ + // @ts-expect-error this is a good error, because return type is string, not number + mutationFn: async () => Promise.resolve('wrong return'), + }), + ) + }) + + test('Should allow mutationKey to be omitted', () => { + return mutationOptions({ + mutationFn: () => Promise.resolve(123), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }) + }) + + test('Should infer all types when not explicitly provided', () => { + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + mutationKey: ['key'], + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + WithRequired< + CreateMutationOptions, + 'mutationKey' + > + >() + expectTypeOf( + mutationOptions({ + mutationFn: (id: string) => Promise.resolve(id.length), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ).toEqualTypeOf< + Omit, 'mutationKey'> + >() + }) + + test('Should work when used with createMutation', () => { + const mutation = createMutation(() => + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + expectTypeOf(mutation).toEqualTypeOf< + CreateMutationResult + >() + + createMutation(() => + // should allow when used with createMutation without mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve('data'), + onSuccess: (data) => { + expectTypeOf(data).toEqualTypeOf() + }, + }), + ) + }) + + test('Should work when used with useIsMutating', () => { + const isMutating = useIsMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating.current).toEqualTypeOf() + + useIsMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + test('Should work when used with queryClient.isMutating', () => { + const queryClient = new QueryClient() + + const isMutating = queryClient.isMutating( + mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + ) + expectTypeOf(isMutating).toEqualTypeOf() + + queryClient.isMutating( + // @ts-expect-error filters should have mutationKey + mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + ) + }) + + test('Should work when used with useMutationState', () => { + const mutationState = useMutationState({ + filters: mutationOptions({ + mutationKey: ['key'], + mutationFn: () => Promise.resolve(5), + }), + }) + expectTypeOf(mutationState).toEqualTypeOf< + Array> + >() + + useMutationState({ + // @ts-expect-error filters should have mutationKey + filters: mutationOptions({ + mutationFn: () => Promise.resolve(5), + }), + }) + }) +})