diff --git a/packages/core/src/api/contractorCurrencies.test.ts b/packages/core/src/api/contractorCurrencies.test.ts new file mode 100644 index 000000000..0fdbc85c1 --- /dev/null +++ b/packages/core/src/api/contractorCurrencies.test.ts @@ -0,0 +1,16 @@ +import { selectContractorCurrencyCodes } from './contractorCurrencies'; + +describe('selectContractorCurrencyCodes', () => { + it('returns only the currency codes', () => { + const response = { + data: { + data: [ + { code: 'EUR', source: 'guaranteed' }, + { code: 'USD', source: 'guaranteed' }, + ], + }, + } as Parameters[0]; + + expect(selectContractorCurrencyCodes(response)).toEqual(['EUR', 'USD']); + }); +}); diff --git a/packages/core/src/api/contractorCurrencies.ts b/packages/core/src/api/contractorCurrencies.ts new file mode 100644 index 000000000..fc0f00f13 --- /dev/null +++ b/packages/core/src/api/contractorCurrencies.ts @@ -0,0 +1,29 @@ +import { getV1ContractorsEmploymentsEmploymentIdContractorCurrencies } from '../client/sdk.gen'; +import type { Client } from '../client/client'; + +export const contractorCurrenciesOptions = ( + client: Client, + employmentId: string, +) => ({ + queryKey: ['contractor-currencies', employmentId] as const, + queryFn: () => + getV1ContractorsEmploymentsEmploymentIdContractorCurrencies({ + client, + path: { employment_id: employmentId }, + query: { + restrict_to_guaranteed_pay_out_currencies: false, + }, + }), +}); + +type ContractorCurrenciesResponse = Awaited< + ReturnType['queryFn']> +>; + +export const selectContractorCurrencies = ({ + data, +}: ContractorCurrenciesResponse) => data?.data; + +export const selectContractorCurrencyCodes = ( + response: ContractorCurrenciesResponse, +) => selectContractorCurrencies(response)?.map((currency) => currency.code); diff --git a/packages/core/src/api/employment.ts b/packages/core/src/api/employment.ts new file mode 100644 index 000000000..d87409687 --- /dev/null +++ b/packages/core/src/api/employment.ts @@ -0,0 +1,21 @@ +import { getV1EmploymentsEmploymentId } from '../client/sdk.gen'; +import type { Client } from '../client/client'; +import type { $TSFixMe } from '../types'; + +export const employmentOptions = ( + client: Client, + employmentId: string, + queryParams?: $TSFixMe, +) => ({ + queryKey: ['employment', employmentId] as const, + retry: false as const, + queryFn: () => + getV1EmploymentsEmploymentId({ + client, + headers: { + Authorization: ``, + }, + path: { employment_id: employmentId }, + query: queryParams, + }), +}); diff --git a/packages/core/src/api/invoiceSchedules.test.ts b/packages/core/src/api/invoiceSchedules.test.ts new file mode 100644 index 000000000..118121d41 --- /dev/null +++ b/packages/core/src/api/invoiceSchedules.test.ts @@ -0,0 +1,22 @@ +import { selectExistingInvoiceSchedule } from './invoiceSchedules'; + +describe('selectExistingInvoiceSchedule', () => { + it('returns the first schedule that is still editable', () => { + const response = { + data: { + data: { + contractor_invoice_schedules: [ + { id: 'done', status: 'completed' }, + { id: 'open', status: 'processing' }, + { id: 'later', status: 'pending_company_action' }, + ], + }, + }, + } as Parameters[0]; + + expect(selectExistingInvoiceSchedule(response)).toEqual({ + id: 'open', + status: 'processing', + }); + }); +}); diff --git a/packages/core/src/api/invoiceSchedules.ts b/packages/core/src/api/invoiceSchedules.ts new file mode 100644 index 000000000..b70609141 --- /dev/null +++ b/packages/core/src/api/invoiceSchedules.ts @@ -0,0 +1,132 @@ +import { + getV1ContractorInvoiceSchedules, + patchV1ContractorInvoiceSchedulesId2, + postV1ContractorInvoiceSchedules, + postV1EmploymentsEmploymentIdContractorInvoicesPreview, +} from '../client/sdk.gen'; +import type { + BulkContractorInvoiceScheduleCreateParams, + ContractorInvoiceScheduleCreateParams, + PreviewContractorInvoiceParams, + UpdateScheduleContractorInvoiceParams, +} from '../client/types.gen'; +import type { Client } from '../client/client'; +import { INVOICE_SCHEDULE_STATUS } from '../domains/invoice-schedule/constants'; +import { + buildInvoicePreviewPayload, + buildInvoiceSchedulePayload, +} from '../domains/invoice-schedule/utils'; +import type { FieldValues } from '../types'; + +export const invoiceSchedulesOptions = ( + client: Client, + employmentId: string, +) => ({ + queryKey: ['contractor-invoice-schedules', employmentId] as const, + queryFn: async () => { + const response = await getV1ContractorInvoiceSchedules({ + client, + query: { employment_id: employmentId }, + }); + + if (response.error || !response.data) { + throw new Error('Failed to fetch invoice schedules'); + } + + return response; + }, +}); + +type InvoiceSchedulesResponse = Awaited< + ReturnType['queryFn']> +>; + +const EDITABLE_STATUSES: string[] = [ + INVOICE_SCHEDULE_STATUS.PENDING_CONTRACTOR_ACTION, + INVOICE_SCHEDULE_STATUS.PROCESSING, + INVOICE_SCHEDULE_STATUS.PENDING_COMPANY_ACTION, +]; + +export const selectExistingInvoiceSchedule = ( + response: InvoiceSchedulesResponse, +) => + response.data?.data?.contractor_invoice_schedules?.find((schedule) => + EDITABLE_STATUSES.includes(schedule.status), + ); + +export const createInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ + employmentId, + values, + }: { + employmentId: string; + values: FieldValues; + }) => { + const payload: BulkContractorInvoiceScheduleCreateParams = { + contractor_invoice_schedules: [ + { + employment_id: employmentId, + ...buildInvoiceSchedulePayload(values), + } as ContractorInvoiceScheduleCreateParams, + ], + }; + + return postV1ContractorInvoiceSchedules({ + client, + body: payload, + }); + }, +}); + +export const updateInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ + scheduleId, + values, + }: { + scheduleId: string; + values: FieldValues; + }) => { + const payload: UpdateScheduleContractorInvoiceParams = + buildInvoiceSchedulePayload(values); + + return patchV1ContractorInvoiceSchedulesId2({ + client, + path: { id: scheduleId }, + body: payload, + }); + }, +}); + +export const skipInvoiceScheduleMutation = (client: Client) => ({ + mutationFn: async ({ scheduleId }: { scheduleId: string }) => { + const payload: UpdateScheduleContractorInvoiceParams = { + status: 'deleted', + }; + + return patchV1ContractorInvoiceSchedulesId2({ + client, + path: { id: scheduleId }, + body: payload, + }); + }, +}); + +export const previewContractorInvoiceMutation = (client: Client) => ({ + mutationFn: async ({ + employmentId, + values, + }: { + employmentId: string; + values: FieldValues; + }) => { + const payload = buildInvoicePreviewPayload( + values, + ) as PreviewContractorInvoiceParams; + + return postV1EmploymentsEmploymentIdContractorInvoicesPreview({ + client, + path: { employment_id: employmentId }, + body: payload, + }); + }, +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3668b5f5..c30040354 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,10 +37,8 @@ export type { JSONSchemaFormResultWithFieldsets, UploadedFileReference, } from './types'; -export { INVOICE_SCHEDULE_STATUS } from './domains/invoice-schedule/constants'; export { buildCreateInvoiceScheduleSchema } from './domains/invoice-schedule/jsonSchema'; export { - buildInvoicePreviewPayload, buildInvoiceSchedulePayload, buildInvoiceScheduleFormValues, } from './domains/invoice-schedule/utils'; @@ -48,3 +46,17 @@ export type { ContractorInvoicePreview } from './domains/invoice-schedule/types' export { clearBase64Data, formatCurrency } from './format'; export { createClient } from './createClient'; export type { AuthResponse, CreateClientOptions } from './createClient'; +export { + contractorCurrenciesOptions, + selectContractorCurrencies, + selectContractorCurrencyCodes, +} from './api/contractorCurrencies'; +export { employmentOptions } from './api/employment'; +export { + createInvoiceScheduleMutation, + invoiceSchedulesOptions, + previewContractorInvoiceMutation, + selectExistingInvoiceSchedule, + skipInvoiceScheduleMutation, + updateInvoiceScheduleMutation, +} from './api/invoiceSchedules'; diff --git a/src/common/api/contractor-contract-details.ts b/src/common/api/contractor-contract-details.ts index 82d046da5..2239a73f0 100644 --- a/src/common/api/contractor-contract-details.ts +++ b/src/common/api/contractor-contract-details.ts @@ -1,11 +1,13 @@ import { useMemo } from 'react'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { FieldValues } from 'react-hook-form'; +import { getV1CountriesCountryCodeContractorContractDetails } from '@/src/client'; import { - getV1CountriesCountryCodeContractorContractDetails, - getV1ContractorsEmploymentsEmploymentIdContractorCurrencies, -} from '@/src/client'; + contractorCurrenciesOptions, + selectContractorCurrencies, +} from '@remoteoss/remote-flows-core'; import { useClient } from '@/src/context'; +import { withClient } from '@/src/lib/withClient'; import { Client } from '@/src/client/client'; // oxlint-disable-next-line no-restricted-imports -- TODO: move onto useHeadlessForm, see docs/USE_HEADLESS_FORM_ROLLOUT.md import { createHeadlessForm } from '@/src/common/createHeadlessForm'; @@ -15,31 +17,22 @@ import { } from '@/src/flows/types'; import { $TSFixMe } from '@/src/types/remoteFlows'; +export const useContractorCurrenciesOptions = withClient( + contractorCurrenciesOptions, +); + export const useContractorCurrencies = ({ employmentId, options, }: { employmentId: string; options?: { queryOptions?: { enabled?: boolean } }; -}) => { - const { client } = useClient(); - return useQuery({ - queryKey: ['contractor-currencies', employmentId], - queryFn: async () => { - return getV1ContractorsEmploymentsEmploymentIdContractorCurrencies({ - client: client as Client, - path: { employment_id: employmentId }, - query: { - restrict_to_guaranteed_pay_out_currencies: false, - }, - }); - }, +}) => + useQuery({ + ...useContractorCurrenciesOptions(employmentId), enabled: options?.queryOptions?.enabled, - select: ({ data }) => { - return data?.data; - }, + select: selectContractorCurrencies, }); -}; /** * Get the contractor onboarding details schema for a given country diff --git a/src/common/api/employment.ts b/src/common/api/employment.ts index 05aadc10f..5b4f74a20 100644 --- a/src/common/api/employment.ts +++ b/src/common/api/employment.ts @@ -1,5 +1,4 @@ import { - getV1EmploymentsEmploymentId, GetV1EmploymentsEmploymentIdResponse, getV2EmploymentsEmploymentIdBasicInformation, GetV2EmploymentsEmploymentIdBasicInformationResponse, @@ -9,6 +8,7 @@ import { useClient } from '@/src/context'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; import { Client } from '@/src/client/client'; +import { employmentOptions } from '@remoteoss/remote-flows-core'; /** * Hook to retrieve employment details for a specific employment ID. @@ -32,18 +32,7 @@ export const useEmploymentQuery = ({ > => { const { client } = useClient(); return useQuery({ - queryKey: ['employment', employmentId], - retry: false, - queryFn: () => { - return getV1EmploymentsEmploymentId({ - client: client as Client, - headers: { - Authorization: ``, - }, - path: { employment_id: employmentId }, - query: queryParams, - }); - }, + ...employmentOptions(client as Client, employmentId, queryParams), enabled: !!employmentId && enabled, select: ({ data }) => data?.data?.employment, }); diff --git a/src/common/invoice-schedules/api.ts b/src/common/invoice-schedules/api.ts index 2b890a4c8..f78c07038 100644 --- a/src/common/invoice-schedules/api.ts +++ b/src/common/invoice-schedules/api.ts @@ -1,27 +1,19 @@ import { useMemo } from 'react'; -import { useMutation, useQuery, queryOptions } from '@tanstack/react-query'; -import { FieldValues } from 'react-hook-form'; -import { - BulkContractorInvoiceScheduleCreateParams, - ContractorInvoiceScheduleCreateParams, - getV1ContractorInvoiceSchedules, - patchV1ContractorInvoiceSchedulesId2, - postV1ContractorInvoiceSchedules, - postV1EmploymentsEmploymentIdContractorInvoicesPreview, - PreviewContractorInvoiceParams, - UpdateScheduleContractorInvoiceParams, -} from '@/src/client'; -import { Client } from '@/src/client/client'; -import { useClient } from '@/src/context'; +import { useQuery } from '@tanstack/react-query'; import { useHeadlessForm } from '@/src/common/useHeadlessForm'; -import { useContractorCurrencies } from '@/src/common/api/contractor-contract-details'; +import { useContractorCurrenciesOptions } from '@/src/common/api/contractor-contract-details'; +import { JSFModify } from '@/src/flows/types'; +import { createClientMutation, withClient } from '@/src/lib/withClient'; import { buildCreateInvoiceScheduleSchema, - buildInvoicePreviewPayload, - buildInvoiceSchedulePayload, - INVOICE_SCHEDULE_STATUS, + createInvoiceScheduleMutation, + invoiceSchedulesOptions as invoiceSchedulesOptionsCore, + previewContractorInvoiceMutation, + selectContractorCurrencyCodes, + selectExistingInvoiceSchedule, + skipInvoiceScheduleMutation, + updateInvoiceScheduleMutation, } from '@remoteoss/remote-flows-core'; -import { JSFModify } from '@/src/flows/types'; type UseGetCreateInvoiceScheduleSchemaParams = { enabled?: boolean; @@ -56,11 +48,10 @@ export const useGetCreateInvoiceScheduleSchema = ({ includeFieldsets, initialValues, }: UseGetCreateInvoiceScheduleSchemaParams) => { - const { data: currencies, isLoading } = useContractorCurrencies({ - employmentId: employmentId as string, - options: { - queryOptions: { enabled: enabled && Boolean(employmentId) }, - }, + const { data: currencies, isLoading } = useQuery({ + ...useContractorCurrenciesOptions(employmentId as string), + enabled: enabled && Boolean(employmentId), + select: selectContractorCurrencyCodes, }); const schema = useMemo(() => { @@ -71,9 +62,7 @@ export const useGetCreateInvoiceScheduleSchema = ({ } return buildCreateInvoiceScheduleSchema({ - currencies: currencies.map( - (currency: { code: string; source: string }) => currency.code, - ), + currencies, isContractorOfRecord, includeFieldsets, }); @@ -95,32 +84,9 @@ export const useGetCreateInvoiceScheduleSchema = ({ }; }; -/** - * Query options factory for fetching invoice schedules by employment id - * @param client - The API client - * @param employmentId - The employment ID - * @returns Query options for contractor invoice schedules - */ -export const invoiceSchedulesOptions = ( - client: Client, - employmentId: string, -) => { - return queryOptions({ - queryKey: ['contractor-invoice-schedules', employmentId] as const, - queryFn: async () => { - const response = await getV1ContractorInvoiceSchedules({ - client, - query: { employment_id: employmentId }, - }); - - if (response.error || !response.data) { - throw new Error('Failed to fetch invoice schedules'); - } - - return response; - }, - }); -}; +export const useInvoiceSchedulesOptions = withClient( + invoiceSchedulesOptionsCore, +); /** * Get the last invoice schedule for an employment that matches specific statuses. @@ -137,20 +103,10 @@ export const useGetExistingInvoiceSchedule = ({ employmentId: string; options?: { queryOptions?: { enabled?: boolean } }; }) => { - const { client } = useClient(); return useQuery({ - ...invoiceSchedulesOptions(client as Client, employmentId), + ...useInvoiceSchedulesOptions(employmentId), enabled: options?.queryOptions?.enabled, - select: (response) => { - // Find the first invoice schedule that matches the editing criteria - return response.data?.data?.contractor_invoice_schedules?.find( - (invoice) => - invoice.status === - INVOICE_SCHEDULE_STATUS.PENDING_CONTRACTOR_ACTION || - invoice.status === INVOICE_SCHEDULE_STATUS.PROCESSING || - invoice.status === INVOICE_SCHEDULE_STATUS.PENDING_COMPANY_ACTION, - ); - }, + select: selectExistingInvoiceSchedule, }); }; @@ -160,32 +116,9 @@ export const useGetExistingInvoiceSchedule = ({ * @param values - The form values containing invoice schedule data * @returns The created invoice schedules */ -export const useCreateInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - employmentId, - values, - }: { - employmentId: string; - values: FieldValues; - }) => { - const payload: BulkContractorInvoiceScheduleCreateParams = { - contractor_invoice_schedules: [ - { - employment_id: employmentId, - ...buildInvoiceSchedulePayload(values), - } as ContractorInvoiceScheduleCreateParams, - ], - }; - - return postV1ContractorInvoiceSchedules({ - client: client as Client, - body: payload, - }); - }, - }); -}; +export const useCreateInvoiceSchedule = createClientMutation( + createInvoiceScheduleMutation, +); /** * Updates an existing contractor invoice schedule @@ -193,49 +126,18 @@ export const useCreateInvoiceSchedule = () => { * @param values - The form values containing invoice schedule data * @returns The updated invoice schedule */ -export const useUpdateInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - scheduleId, - values, - }: { - scheduleId: string; - values: FieldValues; - }) => { - const payload: UpdateScheduleContractorInvoiceParams = - buildInvoiceSchedulePayload(values); - - return patchV1ContractorInvoiceSchedulesId2({ - client: client as Client, - path: { id: scheduleId }, - body: payload, - }); - }, - }); -}; +export const useUpdateInvoiceSchedule = createClientMutation( + updateInvoiceScheduleMutation, +); /** * Skips (cancels) an existing contractor invoice schedule by marking it as deleted. * @param scheduleId - The invoice schedule ID * @returns The updated invoice schedule */ -export const useSkipInvoiceSchedule = () => { - const { client } = useClient(); - return useMutation({ - mutationFn: async ({ scheduleId }: { scheduleId: string }) => { - const payload: UpdateScheduleContractorInvoiceParams = { - status: 'deleted', - }; - - return patchV1ContractorInvoiceSchedulesId2({ - client: client as Client, - path: { id: scheduleId }, - body: payload, - }); - }, - }); -}; +export const useSkipInvoiceSchedule = createClientMutation( + skipInvoiceScheduleMutation, +); /** * Previews a contractor invoice as a draft PDF, without persisting it. @@ -243,25 +145,6 @@ export const useSkipInvoiceSchedule = () => { * @param values - The form values containing invoice details * @returns The base64-encoded PDF preview */ -export const usePreviewContractorInvoice = () => { - const { client } = useClient(); - return useMutation({ - mutationFn: async ({ - employmentId, - values, - }: { - employmentId: string; - values: FieldValues; - }) => { - const payload = buildInvoicePreviewPayload( - values, - ) as PreviewContractorInvoiceParams; - - return postV1EmploymentsEmploymentIdContractorInvoicesPreview({ - client: client as Client, - path: { employment_id: employmentId }, - body: payload, - }); - }, - }); -}; +export const usePreviewContractorInvoice = createClientMutation( + previewContractorInvoiceMutation, +); diff --git a/src/flows/ContractorOnboarding/hooks.tsx b/src/flows/ContractorOnboarding/hooks.tsx index e8f93b0e2..72a7ae8cd 100644 --- a/src/flows/ContractorOnboarding/hooks.tsx +++ b/src/flows/ContractorOnboarding/hooks.tsx @@ -346,16 +346,16 @@ export const useContractorOnboarding = ({ mutationToPromise(setContractOriginMutation); const { mutateAsyncOrThrow: createInvoiceScheduleMutationAsync } = - mutationToPromise(createInvoiceScheduleMutation); + createInvoiceScheduleMutation; const { mutateAsyncOrThrow: updateInvoiceScheduleMutationAsync } = - mutationToPromise(updateInvoiceScheduleMutation); + updateInvoiceScheduleMutation; const { mutateAsyncOrThrow: skipInvoiceScheduleMutationAsync } = - mutationToPromise(skipInvoiceScheduleMutation); + skipInvoiceScheduleMutation; const { mutateAsyncOrThrow: previewContractorInvoiceMutationAsync } = - mutationToPromise(previewContractorInvoiceMutation); + previewContractorInvoiceMutation; // if the employment is loaded, country code has not been set yet // we set the internal country code with the employment country code diff --git a/src/flows/InvoiceSchedule/hooks.tsx b/src/flows/InvoiceSchedule/hooks.tsx index 5c9d4badf..9f5a254c6 100644 --- a/src/flows/InvoiceSchedule/hooks.tsx +++ b/src/flows/InvoiceSchedule/hooks.tsx @@ -18,7 +18,6 @@ import { UseInvoiceScheduleOptions, } from '@/src/flows/InvoiceSchedule/types'; import { clearBase64Data, createStructuredError } from '@/src/lib/utils'; -import { mutationToPromise } from '@/src/lib/mutations'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { Fields } from '@remoteoss/remote-json-schema-form-kit'; @@ -89,13 +88,11 @@ export const useInvoiceSchedule = ({ ); const createInvoiceScheduleMutation = useCreateInvoiceSchedule(); - const { mutateAsyncOrThrow } = mutationToPromise( - createInvoiceScheduleMutation, - ); + const { mutateAsyncOrThrow } = createInvoiceScheduleMutation; const previewContractorInvoiceMutation = usePreviewContractorInvoice(); const { mutateAsyncOrThrow: previewContractorInvoiceAsync } = - mutationToPromise(previewContractorInvoiceMutation); + previewContractorInvoiceMutation; const parseFormValues = useCallback( async (values: InvoiceScheduleFormValues) => { diff --git a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx index d61244f5f..71002855e 100644 --- a/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx +++ b/src/flows/Onboarding/tests/useOnboardingJsonSchemaVersion.test.tsx @@ -13,6 +13,8 @@ const mockPutV1EmploymentsEmploymentIdBenefitOffers = vi.fn(); const mockPostV1EmploymentsEmploymentIdContractEligibility = vi.fn(); // Mock the client functions +vi.mock('@remoteoss/remote-flows-core/client/sdk.gen', () => ({})); + vi.mock('@/src/client', () => ({ getV1CountriesCountryCodeForm: (...args: $TSFixMe[]) => mockGetV1CountriesCountryCodeForm(...args), diff --git a/src/lib/__tests__/withClient.test.tsx b/src/lib/__tests__/withClient.test.tsx new file mode 100644 index 000000000..6add03d76 --- /dev/null +++ b/src/lib/__tests__/withClient.test.tsx @@ -0,0 +1,71 @@ +import { PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { RemoteFlowContext } from '@/src/context'; +import { client, queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { createClientMutation } from '@/src/lib/withClient'; + +const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + +); + +describe('createClientMutation', () => { + afterEach(() => { + queryClient.clear(); + }); + + it('passes the context client and the extra arguments to the factory', () => { + const factory = vi.fn( + (_client: unknown, _countryCode: string, _version: number) => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + }), + ); + const useTestMutation = createClientMutation(factory); + + renderHook(() => useTestMutation('PRT', 2), { wrapper: Wrapper }); + + expect(factory).toHaveBeenCalledWith(client, 'PRT', 2); + }); + + it('resolves mutateAsyncOrThrow with the unwrapped data', async () => { + const useTestMutation = createClientMutation(() => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + })); + + const { result } = renderHook(() => useTestMutation(), { + wrapper: Wrapper, + }); + + await expect( + result.current.mutateAsyncOrThrow({ name: 'Ana' }), + ).resolves.toEqual({ name: 'Ana' }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + }); + + it('keeps mutateAsyncOrThrow stable across re-renders', () => { + const useTestMutation = createClientMutation(() => ({ + mutationFn: async (values: { name: string }) => ({ + data: values, + error: null, + }), + })); + + const { result, rerender } = renderHook(() => useTestMutation(), { + wrapper: Wrapper, + }); + const first = result.current.mutateAsyncOrThrow; + + rerender(); + + expect(result.current.mutateAsyncOrThrow).toBe(first); + }); +}); diff --git a/src/lib/withClient.ts b/src/lib/withClient.ts new file mode 100644 index 000000000..0fd5afbb9 --- /dev/null +++ b/src/lib/withClient.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Client } from '@/src/client/client'; +import { useClient } from '@/src/context'; +import { mutationToPromise } from '@/src/lib/mutations'; + +/** + * Binds a core options factory to the client from context, keeping its remaining arguments. + * Name the result `useX`: it reads context, so it must be called as a hook. + */ +export const withClient = + ( + factory: (client: Client, ...args: TArgs) => TOptions, + ) => + (...args: TArgs): TOptions => { + const { client } = useClient(); + return factory(client as Client, ...args); + }; + +/** + * Turns a core mutation factory into a hook that returns the mutation plus `mutateAsyncOrThrow`. + */ +export const createClientMutation = + ( + factory: ( + client: Client, + ...args: TArgs + ) => { mutationFn: (vars: TVars) => Promise }, + ) => + (...args: TArgs) => { + const { client } = useClient(); + const mutation = useMutation(factory(client as Client, ...args)); + const { mutate } = mutation; + const { mutateAsyncOrThrow } = useMemo( + () => mutationToPromise({ mutate } as typeof mutation), + [mutate], + ); + return { ...mutation, mutateAsyncOrThrow }; + };