import { injectable } from '@servicetitan/react-ioc'; import { AxiosError } from 'axios'; import forEach from 'lodash/forEach'; import isEmpty from 'lodash/isEmpty'; import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import type { GlobalClientOptions } from './client-helpers'; import { shouldShareWindowClient } from './client-helpers'; import { QueryClientStore } from './query-client.store'; import type { MutationObserverOptions, MutateFunction, MutateOptions, MutationObserverResult, QueryKey, } from '@tanstack/query-core'; import { MutationObserver } from '@tanstack/query-core'; /** * Configuration options for mutations, extending TanStack Query's MutationObserverOptions * with additional integration-specific options. * * @template TData - The type of data returned by the mutation function * @template TVariables - The type of variables passed to the mutation function * @template TError - The error type (default: AxiosError) * * @remarks * Extends TanStack Query's standard mutation options with MobX integration features. * All standard options (mutationFn, onSuccess, onError, etc.) are available. * * @see [TanStack Query MutationOptions](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation) * @see {@link MutationApi.runMutation} for execution details * @see README.md "Mutations" section for usage examples * * @example Complete mutation configuration * ```typescript * deleteJob = this.addMutation(() => ({ * mutationFn: async (arg) => { * await this.api?.deleteJob(arg.id); * }, * // Automatically refetch jobs and stats after successful delete * invalidatedQueries: [['scheduling', 'jobs'], ['scheduling', 'stats']], * // Optional: use shared query client across micro-frontend pages * globalClient: 'page', * // Standard TanStack Query options * onSuccess: (data, variables) => { * console.log(`Deleted job ${variables.id}`); * }, * onError: (error) => { * console.error('Delete failed:', error); * }, * retry: 2, * })); * ``` */ export interface MutationApiOptions< TData = unknown, TVariables = void, TError = AxiosError, > extends MutationObserverOptions { /** * Array of query keys to invalidate (refetch) after successful mutation. * Queries matching these keys will be automatically refetched, keeping data in sync. * * @example * ```typescript * // Invalidate single query * invalidatedQueries: [['scheduling', 'jobs']] * * // Invalidate multiple queries * invalidatedQueries: [['scheduling', 'jobs'], ['scheduling', 'stats'], ['myapp', 'users']] * * // Invalidate specific job query * invalidatedQueries: [['scheduling', 'job', jobId]] * ``` */ invalidatedQueries?: QueryKey[]; /** * Use a shared 'page' or 'app' level query client for micro-frontend scenarios. * * @see {@link getQueryClient} for client scoping details * @see client-helpers.ts for shared client implementation * @see TESTING.md — ContainerBuilder tests isolate from `window.queryClients` by default * * @example * ```typescript * globalClient: 'page' // Share across page, dispose on page unmount * globalClient: 'app' // Share across app, persist until browser refresh * ``` */ globalClient?: GlobalClientOptions; } /** * Manages mutation operations (create, update, delete) with TanStack Query, * exposing mutation state as MobX observables. * * @template TData - The type of data returned by the mutation function * @template TVariables - The type of variables passed to the mutation function * @template TError - The error type (default: AxiosError) * * @remarks * MutationApi integrates TanStack Query mutations with MobX, providing observable * state (`isPending`, `isSuccess`, `isError`) that can be used in React components. * Supports automatic query invalidation after successful mutations. * * Typically created via the `mutation()` factory with the `@queryStore` decorator, * or via `QueryApiStore.addMutation()` in the inheritance approach. * * @see README.md "Mutations" section for common usage patterns * @see {@link MutationApiOptions} for full configuration options * * @example Recommended: `@queryStore` decorator with `mutation()` factory * ```typescript * @queryStore * class JobsStore extends Store { * @inject(JobsApi) private api?: JobsApi; * * deleteJob = mutation(() => ({ * mutationFn: async (arg) => await this.api?.deleteJob(arg.id), * invalidatedQueries: [['scheduling', 'jobs']], * })); * } * ``` * * @example Execute mutation with callbacks * ```typescript * await store.deleteJob.runMutation( * { id: 123 }, * { * onSuccess: () => console.log('Deleted successfully'), * onError: (error) => console.error('Failed to delete', error), * } * ); * ``` */ @injectable() export class MutationApi { /** Reactive mutation configuration used to build the underlying TanStack MutationObserver. */ @observable mutationOptions = {} as MutationApiOptions; /** Mirrors TanStack Query's `isPending` derived status flag for the latest mutation execution. */ @observable isPending = false; /** Mirrors TanStack Query's `isSuccess` derived status flag for the latest mutation execution. */ @observable isSuccess = false; /** Mirrors TanStack Query's `isError` derived status flag for the latest mutation execution. */ @observable isError = false; /** Mirrors TanStack Query's `error` value. Defaults to `null` when the mutation has not failed. */ @observable error: TError | null = null; /** * Remaining TanStack mutation result fields that are not exposed as top-level properties here. * This can include values such as `data`, `status`, `variables`, `reset`, and retry metadata. */ @observable mutationInfo?: Partial>; clientStore?: QueryClientStore; private mutate?: MutateFunction; private observer?: MutationObserver; private disposables: (() => void)[] = []; constructor(options: () => MutationApiOptions) { if (options) { this.reactionOptions = options; } makeObservable(this); } @computed private get getMutationOptions(): MutationObserverOptions { const { invalidatedQueries, globalClient, ...rest } = this.mutationOptions ?? {}; return rest; } /** Connects this mutation to a QueryClientStore and starts observing mutation state. */ setup(client?: QueryClientStore) { if (this.reactionOptions) { // set options so setup will run this.mutationOptions = this.reactionOptions(); this.disposables.push( reaction(this.reactionOptions, opt => { runInAction(() => { this.mutationOptions = opt; }); }) ); } this.clientStore = shouldShareWindowClient(this.mutationOptions?.globalClient, client) ? new QueryClientStore(this.mutationOptions?.globalClient) : client; this.setupMutation(); } /** Cleans up reactions, mutation subscriptions, and any owned shared query client. */ dispose() { forEach(this.disposables, disposer => disposer()); this.disposables = []; if (shouldShareWindowClient(this.mutationOptions?.globalClient, this.clientStore)) { this.clientStore?.dispose(); } this.observer = undefined; } /** * Executes the mutation function with the provided arguments. * Automatically invalidates configured queries after successful completion. * * @param args - The variables to pass to the mutation function * @param options - Optional mutation options (onSuccess, onError, etc.) * @returns Promise that resolves with the mutation result, or undefined if mutation not initialized * * @remarks * This method executes the mutation and automatically invalidates queries specified * in {@link MutationApiOptions.invalidatedQueries}. The invalidation happens after * successful mutation, triggering refetch of dependent queries. * * Observable state (`isPending`, `isSuccess`, `isError`) is automatically updated * during mutation lifecycle and can be observed in React components. * * @see {@link MutationApiOptions.invalidatedQueries} for configuring automatic query invalidation * @see {@link MutationApiOptions.mutationFn} for defining the mutation function * @see README.md "Mutations" section for common usage patterns * * @example Basic usage * ```typescript * await this.createJob.runMutation( * { title: 'New Job', description: 'Job description' } * ); * ``` * * @example With success and error callbacks * ```typescript * await this.createJob.runMutation( * { title: 'New Job', description: 'Job description' }, * { * onSuccess: (job) => { * console.log('Created job:', job.id); * navigate(`/jobs/${job.id}`); * }, * onError: (error) => { * console.error('Failed to create job:', error); * showNotification('Failed to create job'); * }, * } * ); * ``` * * @example Observing mutation state in component * ```typescript * const [store] = useDependencies(JobsStore); * * // Access observable mutation state * if (store.createJob.isPending) return ; * if (store.createJob.isError) return ; * ``` */ runMutation = async (args: TVariables, options?: MutateOptions) => { if (!this.mutate) { return; } const { onSuccess: onSuccessOption, ...rest } = options ?? {}; try { const response = await this.mutate(args, { onSuccess: (data, variables, context) => { this.clientStore?.invalidate(this.mutationOptions?.invalidatedQueries); onSuccessOption?.(data, variables, context); }, ...rest, }); return response; } catch (error) { if (this.mutationOptions?.throwOnError) { return Promise.reject(error); } } }; @action private setupMutation = () => { if (!this.observer) { if (isEmpty(this.mutationOptions)) { return; } if (!this.clientStore) { // eslint-disable-next-line no-console console.error('QueryClientStore must be defined'); throw new Error('QueryClientStore must be defined'); } this.observer = new MutationObserver( this.clientStore.queryClient, this.getMutationOptions ); // initialize state this.setMutationState(); this.disposables.push( reaction( () => this.getMutationOptions, options => this.observer?.setOptions(options) ) ); this.disposables.push(this.observer.subscribe(this.setMutationState)); } }; @action private setMutationState = () => { if (!this.observer) { return; } const result = this.observer.getCurrentResult(); const { isPending, isError, error, mutate, isSuccess, ...other } = result; runInAction(() => { this.isPending = isPending; this.isError = isError; this.error = error; this.isSuccess = isSuccess; this.mutationInfo = other; this.mutate = mutate; }); }; private reactionOptions: () => MutationApiOptions = () => ({}) as MutationApiOptions; }