diff --git a/packages/webapp/package.json b/packages/webapp/package.json index bdce843c97..f35ee2b46f 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -43,7 +43,7 @@ "@mui/x-date-pickers": "^8.0.0", "@react-oauth/google": "^0.7.0", "@reactour/tour": "^3.1.6", - "@reduxjs/toolkit": "^1.9.1", + "@reduxjs/toolkit": "1.9.7", "@sentry/react": "^6.19.7", "@sentry/tracing": "^6.19.7", "@turf/boolean-point-in-polygon": "7.2.0", diff --git a/packages/webapp/pnpm-lock.yaml b/packages/webapp/pnpm-lock.yaml index 606894acba..972e79fd1f 100644 --- a/packages/webapp/pnpm-lock.yaml +++ b/packages/webapp/pnpm-lock.yaml @@ -47,7 +47,7 @@ importers: specifier: ^3.1.6 version: 3.8.0(react@18.3.1) '@reduxjs/toolkit': - specifier: ^1.9.1 + specifier: 1.9.7 version: 1.9.7(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@sentry/react': specifier: ^6.19.7 diff --git a/packages/webapp/src/containers/AddFarm/saga.js b/packages/webapp/src/containers/AddFarm/saga.js index e5c640169d..1066bdf33d 100644 --- a/packages/webapp/src/containers/AddFarm/saga.js +++ b/packages/webapp/src/containers/AddFarm/saga.js @@ -29,8 +29,6 @@ import { axios, getHeader } from '../saga'; import { createAction } from '@reduxjs/toolkit'; import i18n from '../../locales/i18n'; import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice'; -import { invalidateTags } from '../../store/api/apiSlice'; -import { FarmLibraryTags, FarmTags } from '../../store/api/apiTags'; const patchRoleUrl = (farm_id, user_id) => `${userFarmUrl}/role/farm/${farm_id}/user/${user_id}`; const patchFarmUrl = (farm_id) => `${farmUrl}/owner_operated/${farm_id}`; @@ -61,9 +59,6 @@ export function* postFarmSaga({ payload: { showFarmNameCharacterLimitExceededErr }; yield call(axios.patch, patchStepUrl(farm_id, user_id), step, getHeader(user_id, farm_id)); - // Clear old farm RTK Query data - yield put(invalidateTags([...FarmTags, ...FarmLibraryTags])); - const user = getUserResult?.data; yield put( postFarmSuccess({ diff --git a/packages/webapp/src/containers/AddSensors/Partners.tsx b/packages/webapp/src/containers/AddSensors/Partners.tsx index 8f29d63bfc..befb873a40 100644 --- a/packages/webapp/src/containers/AddSensors/Partners.tsx +++ b/packages/webapp/src/containers/AddSensors/Partners.tsx @@ -22,9 +22,9 @@ const Partners = ({ }: { setIsEditing: React.Dispatch>; }) => { - const { data: esciData = [], isLoading: esciLoading } = useGetFarmAddonQuery( - `?addon_partner_id=${PARTNERS.ESCI.id}`, - ); + const { data: esciData = [], isLoading: esciLoading } = useGetFarmAddonQuery({ + params: `?addon_partner_id=${PARTNERS.ESCI.id}`, + }); const hasActiveConnection = { esci: esciData.length > 0, diff --git a/packages/webapp/src/containers/AddSensors/index.tsx b/packages/webapp/src/containers/AddSensors/index.tsx index 39a4503eac..de05e67d2c 100644 --- a/packages/webapp/src/containers/AddSensors/index.tsx +++ b/packages/webapp/src/containers/AddSensors/index.tsx @@ -45,7 +45,11 @@ const AddSensor = ({ isCompactSideMenu }: AddSensorProps) => { const result = await addFarmAddon(values[PARTNER]); if ('error' in result) { - const isInvalidId = 'data' in result.error && result.error.data === 'Organisation not found'; + const isInvalidId = + result.error && + typeof result.error === 'object' && + 'data' in result.error && + result.error.data === 'Organisation not found'; const errorMessage = isInvalidId ? t('SENSOR.ESCI.ORGANISATION_ID_ERROR') : t('SENSOR.ESCI.ORGANISATION_ID_GENERIC_ERROR'); diff --git a/packages/webapp/src/containers/Profile/FarmSettings/Addons/index.tsx b/packages/webapp/src/containers/Profile/FarmSettings/Addons/index.tsx index e588d9f95b..26744c3281 100644 --- a/packages/webapp/src/containers/Profile/FarmSettings/Addons/index.tsx +++ b/packages/webapp/src/containers/Profile/FarmSettings/Addons/index.tsx @@ -28,9 +28,9 @@ const FarmAddons = () => { const history = useHistory(); const routerTabs = useFarmSettingsRouterTabs(); - const { data: esciDataArray = [] } = useGetFarmAddonQuery( - `?addon_partner_id=${PARTNERS.ESCI.id}`, - ); + const { data: esciDataArray = [] } = useGetFarmAddonQuery({ + params: `?addon_partner_id=${PARTNERS.ESCI.id}`, + }); const hasActiveConnection = { esci: esciDataArray.length > 0, diff --git a/packages/webapp/src/containers/Profile/FarmSettings/FarmSettingsContext.tsx b/packages/webapp/src/containers/Profile/FarmSettings/FarmSettingsContext.tsx index 3be46ccb82..be605eb4b5 100644 --- a/packages/webapp/src/containers/Profile/FarmSettings/FarmSettingsContext.tsx +++ b/packages/webapp/src/containers/Profile/FarmSettings/FarmSettingsContext.tsx @@ -24,9 +24,9 @@ type FarmSettingsContextType = { const FarmSettingsContext = createContext(undefined); export const FarmSettingsProvider = ({ children }: { children: ReactNode }) => { - const { data: esciDataArray = [] } = useGetFarmAddonQuery( - `?addon_partner_id=${PARTNERS.ESCI.id}`, - ); + const { data: esciDataArray = [] } = useGetFarmAddonQuery({ + params: `?addon_partner_id=${PARTNERS.ESCI.id}`, + }); const hasActiveConnection = esciDataArray.length > 0; diff --git a/packages/webapp/src/containers/Profile/FarmSettings/MarketDirectory/Consent/index.tsx b/packages/webapp/src/containers/Profile/FarmSettings/MarketDirectory/Consent/index.tsx index cc36644373..27125f9612 100644 --- a/packages/webapp/src/containers/Profile/FarmSettings/MarketDirectory/Consent/index.tsx +++ b/packages/webapp/src/containers/Profile/FarmSettings/MarketDirectory/Consent/index.tsx @@ -55,8 +55,9 @@ const MarketDirectoryConsent = ({ const savedConsent = marketDirectoryInfo?.[CONSENTED_TO_SHARE] || false; - const { data: marketDirectoryPartners = [] } = - useGetMarketDirectoryPartnersQuery('?filter=country'); + const { data: marketDirectoryPartners = [] } = useGetMarketDirectoryPartnersQuery({ + params: '?filter=country', + }); const [updateMarketDirectoryInfo, { isLoading }] = useUpdateMarketDirectoryInfoMutation(); const [isConsentReadonly, setIsConsentReadonly] = useState(savedConsent === true); diff --git a/packages/webapp/src/containers/SensorReadings/v2/Charts/useFormattedSensorReadings.ts b/packages/webapp/src/containers/SensorReadings/v2/Charts/useFormattedSensorReadings.ts index cb21e9b817..6153eff83a 100644 --- a/packages/webapp/src/containers/SensorReadings/v2/Charts/useFormattedSensorReadings.ts +++ b/packages/webapp/src/containers/SensorReadings/v2/Charts/useFormattedSensorReadings.ts @@ -93,10 +93,12 @@ function useFormattedSensorReadings({ data: sensorReadings, } = useGetSensorReadingsQuery( { - esids: sensorIds.join(','), - startTime: startDate, - endTime: getAdjustedEndTime(endDate), - truncPeriod, + params: { + esids: sensorIds.join(','), + startTime: startDate, + endTime: getAdjustedEndTime(endDate), + truncPeriod, + }, }, { refetchOnMountOrArgChange: true }, ); diff --git a/packages/webapp/src/containers/SensorReadings/v2/LatestReadings/useLatestReadings.ts b/packages/webapp/src/containers/SensorReadings/v2/LatestReadings/useLatestReadings.ts index 2ddc321fdb..f1e5a7ead1 100644 --- a/packages/webapp/src/containers/SensorReadings/v2/LatestReadings/useLatestReadings.ts +++ b/packages/webapp/src/containers/SensorReadings/v2/LatestReadings/useLatestReadings.ts @@ -61,9 +61,11 @@ function useLatestReading(sensors: Sensor[]): { // The "minute" and "second" truncation periods do not guarantee data for every minute or second. // Use "minute" here to ensure we get data. return triggerGetSensorReadings({ - esids: sensors.map(({ external_id }) => external_id).join(','), - startTime: adjustedStartTime.toISOString(), - truncPeriod: 'minute', + params: { + esids: sensors.map(({ external_id }) => external_id).join(','), + startTime: adjustedStartTime.toISOString(), + truncPeriod: 'minute', + }, }); }; diff --git a/packages/webapp/src/containers/Task/saga.js b/packages/webapp/src/containers/Task/saga.js index ad6fd54934..097ba6a728 100644 --- a/packages/webapp/src/containers/Task/saga.js +++ b/packages/webapp/src/containers/Task/saga.js @@ -112,7 +112,7 @@ import { getMovementTaskBody, getSoilSampleTaskBody, } from './sagaUtils'; -import { api } from '../../store/api/apiSlice'; +import { invalidateTags } from '../../store/api/apiSlice'; const taskTypeEndpoint = [ 'cleaning_task', @@ -692,7 +692,7 @@ export function* createTaskSaga({ payload }) { task_translation_key === 'HARVEST_TASK' ? result.data[0] : result.data; yield call(getTasksSuccessSaga, { payload: isHarvest ? result.data : [result.data] }); if (task_translation_key === 'IRRIGATION_TASK') { - yield put(api.util.invalidateTags(['IrrigationPrescriptions'])); + yield put(invalidateTags(['IrrigationPrescriptions'], farm_id)); } if (alreadyCompleted) { if (['CLEANING_TASK', 'PEST_CONTROL_TASK'].includes(task_translation_key)) { @@ -905,7 +905,7 @@ export function* completeTaskSaga({ payload: { task_id, data, returnPath } }) { }); if (task_translation_key === 'MOVEMENT_TASK') { - yield put(api.util.invalidateTags(['Animals', 'AnimalBatches'])); + yield put(invalidateTags(['Animals', 'AnimalBatches'], farm_id)); } } } catch (e) { @@ -1096,7 +1096,7 @@ export function* deleteTaskSaga({ payload: data }) { } yield put(deleteTaskSuccess(result.data)); if (task_type.task_translation_key === 'IRRIGATION_TASK') { - yield put(api.util.invalidateTags(['IrrigationPrescriptions'])); + yield put(invalidateTags(['IrrigationPrescriptions'], farm_id)); } yield put(enqueueSuccessSnackbar(i18n.t('TASK.DELETE.SUCCESS'))); } diff --git a/packages/webapp/src/containers/saga.js b/packages/webapp/src/containers/saga.js index 3e49cdd967..f7821a7293 100644 --- a/packages/webapp/src/containers/saga.js +++ b/packages/webapp/src/containers/saga.js @@ -151,8 +151,7 @@ import { onLoadingWatercourseFail, onLoadingWatercourseStart, } from './watercourseSlice'; -import { api, invalidateTags } from '../store/api/apiSlice'; -import { FarmLibraryTags, FarmTags } from '../store/api/apiTags'; +import { api } from '../store/api/apiSlice'; import { getSoilSampleLocationsSuccess, onLoadingSoilSampleLocationFail, @@ -603,7 +602,7 @@ export function* fetchAllSaga() { const { has_consent, user_id, farm_id } = yield select(userFarmSelector); if (!has_consent) return history.push('/consent'); - yield put(api.endpoints.getSensors.initiate()); + yield put(api.endpoints.getSensors.initiate({ farm_id, payload: undefined })); const isAdmin = yield select(isAdminSelector); const adminTasks = [ @@ -629,12 +628,12 @@ export function* fetchAllSaga() { // Animals yield all([ - put(api.endpoints.getAnimals.initiate()), - put(api.endpoints.getAnimalBatches.initiate()), - put(api.endpoints.getDefaultAnimalTypes.initiate()), + put(api.endpoints.getAnimals.initiate({ farm_id, payload: undefined })), + put(api.endpoints.getAnimalBatches.initiate({ farm_id, payload: undefined })), + put(api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined })), put(api.endpoints.getDefaultAnimalBreeds.initiate()), - put(api.endpoints.getCustomAnimalTypes.initiate()), - put(api.endpoints.getCustomAnimalBreeds.initiate()), + put(api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined })), + put(api.endpoints.getCustomAnimalBreeds.initiate({ farm_id, payload: undefined })), put(api.endpoints.getAnimalSexes.initiate()), put(api.endpoints.getAnimalIdentifierTypes.initiate()), put(api.endpoints.getAnimalIdentifierColors.initiate()), @@ -658,7 +657,6 @@ export function* fetchAllSaga() { export function* clearOldFarmStateSaga() { yield put(resetTasks()); yield put(resetDateRange()); - yield put(invalidateTags([...FarmTags, ...FarmLibraryTags])); // Reset finance loading state yield put(setIsFetchingData(true)); diff --git a/packages/webapp/src/hooks/api/useQueries.js b/packages/webapp/src/hooks/api/useQueries.js index 6f20904f49..b828fe228b 100644 --- a/packages/webapp/src/hooks/api/useQueries.js +++ b/packages/webapp/src/hooks/api/useQueries.js @@ -14,7 +14,9 @@ */ const useQueries = (queries) => { - const result = queries.map((query) => query.hook(query.params)); + const result = queries.map((query) => + query.hook(query.params ? { params: query.params } : undefined), + ); const data = queries.reduce( (dataObj, query, index) => ({ diff --git a/packages/webapp/src/hooks/useServiceWorkerListener/useServiceWorkerListener.ts b/packages/webapp/src/hooks/useServiceWorkerListener/useServiceWorkerListener.ts index 856578bfd2..fabad3ed93 100644 --- a/packages/webapp/src/hooks/useServiceWorkerListener/useServiceWorkerListener.ts +++ b/packages/webapp/src/hooks/useServiceWorkerListener/useServiceWorkerListener.ts @@ -14,7 +14,7 @@ */ import { useEffect, useMemo, useRef } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { enqueueSuccessSnackbar, @@ -24,6 +24,7 @@ import { getProducts, getTasks } from '../../containers/Task/saga'; import { getManagementPlans } from '../../containers/saga'; import { invalidateTags } from '../../store/api/apiSlice'; import { OfflineEventPayload, recordOfflineEvent } from '../../util/offlineEventLogger'; +import { loginSelector } from '../../containers/userFarmSlice'; type SyncArea = | 'tasks.create' @@ -98,7 +99,7 @@ export function useServiceWorkerListener() { recordOfflineEvent({ auth, farmId, logs }); }; - + const { farm_id } = useSelector(loginSelector); const syncConfig = useMemo( (): Record => ({ 'tasks.create': { @@ -108,7 +109,7 @@ export function useServiceWorkerListener() { }, onSuccess: (response) => { if (response?.taskType?.task_translation_key === 'IRRIGATION_TASK') { - return invalidateTags(['IrrigationPrescriptions']); + return invalidateTags(['IrrigationPrescriptions'], farm_id); } }, refresh: () => { @@ -127,7 +128,7 @@ export function useServiceWorkerListener() { onSuccess: (response) => { // taskType not returned in the completion API response if (response?.animal_movement_task) { - return invalidateTags(['Animals', 'AnimalBatches']); + return invalidateTags(['Animals', 'AnimalBatches'], farm_id); } }, refresh: () => { @@ -157,13 +158,13 @@ export function useServiceWorkerListener() { }, onSuccess: (response) => { if (response?.taskType?.task_translation_key === 'IRRIGATION_TASK') { - return invalidateTags(['IrrigationPrescriptions']); + return invalidateTags(['IrrigationPrescriptions'], farm_id); } }, refresh: () => dispatch(getTasks()), }, }), - [t, dispatch], + [t, dispatch, farm_id], ); useEffect(() => { diff --git a/packages/webapp/src/store/api/apiSlice.ts b/packages/webapp/src/store/api/apiSlice.ts index 61b39fc107..f550b1e4ab 100644 --- a/packages/webapp/src/store/api/apiSlice.ts +++ b/packages/webapp/src/store/api/apiSlice.ts @@ -61,10 +61,18 @@ import type { SensorReadings, IrrigationPrescription, IrrigationPrescriptionDetails, + WithFarmIdPayload, } from './types'; import { addDaysToDate } from '../../util/date'; -import { API_TAGS, ApiTag } from './apiTags'; +import { API_TAGS, FarmLibraryTag, FarmTag } from './apiTags'; +import { + getFarmTagsFn, + getLazyUseQueryWithFarmId, + getMutationWithFarmId, + getUseQueryWithFarmId, + mapFarmTags, +} from './util'; /** * Invalidates one or more RTK Query cache tags. @@ -76,8 +84,13 @@ import { API_TAGS, ApiTag } from './apiTags'; * @returns - The invalidateTags action, * which can be dispatched directly or used inside a saga (e.g., `yield put(...)`). */ -export const invalidateTags = (tags: ApiTag[]) => api.util.invalidateTags(tags); - +export const invalidateTags = (tags: (FarmTag | FarmLibraryTag)[], farm_id?: string) => { + if (!farm_id) { + console.warn('Cannot invalidate tags — farm_id is missing'); + return undefined; + } + return api.util.invalidateTags(mapFarmTags(tags, farm_id)); +}; const NON_JSON_ENDPOINT_KEYS = new Set(['addSupportTicket']); export const api = createApi({ @@ -105,25 +118,37 @@ export const api = createApi({ endpoints: (build) => ({ // redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-query-and-mutation-endpoints // - getAnimals: build.query({ - query: () => `${animalsUrl}`, - providesTags: ['Animals'], + getAnimals: build.query({ + query: (_args) => `${animalsUrl}`, + providesTags: getFarmTagsFn(['Animals']), }), - getAnimalBatches: build.query({ - query: () => `${animalBatchesUrl}`, - providesTags: ['AnimalBatches'], + getAnimalBatches: build.query({ + query: (_args) => `${animalBatchesUrl}`, + providesTags: getFarmTagsFn(['AnimalBatches']), }), - getDefaultAnimalTypes: build.query({ - query: (param = '') => `${defaultAnimalTypesUrl}${param}`, - providesTags: ['DefaultAnimalTypes'], - }), - getCustomAnimalTypes: build.query({ - query: (param = '') => `${customAnimalTypesUrl}${param}`, - providesTags: ['CustomAnimalTypes'], + getDefaultAnimalTypes: build.query< + DefaultAnimalType[], + WithFarmIdPayload<{ params: string } | void> + >({ + query: ({ payload }) => `${defaultAnimalTypesUrl}${payload?.params ?? ''}`, + providesTags: getFarmTagsFn< + DefaultAnimalType[], + WithFarmIdPayload<{ params: string } | void> + >(['DefaultAnimalTypes']), + }), + getCustomAnimalTypes: build.query< + CustomAnimalType[], + WithFarmIdPayload<{ params: string } | void> + >({ + query: ({ payload }) => `${customAnimalTypesUrl}${payload?.params ?? ''}`, + providesTags: getFarmTagsFn< + CustomAnimalType[], + WithFarmIdPayload<{ params?: string } | void> + >(['CustomAnimalTypes']), }), - getCustomAnimalBreeds: build.query({ - query: () => `${customAnimalBreedsUrl}`, - providesTags: ['CustomAnimalBreeds'], + getCustomAnimalBreeds: build.query({ + query: (_args) => `${customAnimalBreedsUrl}`, + providesTags: getFarmTagsFn(['CustomAnimalBreeds']), }), getDefaultAnimalBreeds: build.query({ query: () => `${defaultAnimalBreedsUrl}`, @@ -157,171 +182,325 @@ export const api = createApi({ query: () => `${animalRemovalReasonsUrl}`, providesTags: ['AnimalRemovalReasons'], }), - removeAnimals: build.mutation[]>({ - query: (patch) => ({ + removeAnimals: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: patch }) => ({ url: `${animalsUrl}/remove`, method: 'PATCH', body: patch, }), - async onQueryStarted(patch, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimals.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); + await dispatch(api.endpoints.getAnimals.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['Animals', 'CustomAnimalTypes', 'DefaultAnimalTypes'], + invalidatesTags: getFarmTagsFn[]>>([ + 'Animals', + 'CustomAnimalTypes', + 'DefaultAnimalTypes', + ]), }), - removeAnimalBatches: build.mutation[]>({ - query: (patch) => ({ + removeAnimalBatches: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: patch }) => ({ url: `${animalBatchesUrl}/remove`, method: 'PATCH', body: patch, }), - async onQueryStarted(patch, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimalBatches.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); + await dispatch(api.endpoints.getAnimalBatches.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['AnimalBatches', 'CustomAnimalTypes', 'DefaultAnimalTypes'], + invalidatesTags: getFarmTagsFn[]>>([ + 'AnimalBatches', + 'CustomAnimalTypes', + 'DefaultAnimalTypes', + ]), }), - deleteAnimals: build.mutation({ - query: (del) => ({ + deleteAnimals: build.mutation>({ + query: ({ farm_id: _farm_id, payload: del }) => ({ url: `${animalsUrl}`, method: 'DELETE', params: del, }), - async onQueryStarted(del, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled, getState }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimals.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); + await dispatch(api.endpoints.getAnimals.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['Animals', 'CustomAnimalTypes', 'DefaultAnimalTypes'], + invalidatesTags: getFarmTagsFn>([ + 'Animals', + 'CustomAnimalTypes', + 'DefaultAnimalTypes', + ]), }), - deleteAnimalBatches: build.mutation({ - query: (del) => ({ + deleteAnimalBatches: build.mutation>({ + query: ({ farm_id: _farm_id, payload: del }) => ({ url: `${animalBatchesUrl}`, method: 'DELETE', params: del, }), - async onQueryStarted(del, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimalBatches.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); + await dispatch(api.endpoints.getAnimalBatches.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['AnimalBatches', 'CustomAnimalTypes', 'DefaultAnimalTypes'], + invalidatesTags: getFarmTagsFn>([ + 'AnimalBatches', + 'CustomAnimalTypes', + 'DefaultAnimalTypes', + ]), }), - addAnimals: build.mutation[]>({ - query: (body) => ({ + addAnimals: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: body }) => ({ url: `${animalsUrl}`, method: 'POST', body, }), - async onQueryStarted(body, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimals.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalBreeds.initiate()); + await dispatch(api.endpoints.getAnimals.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalBreeds.initiate({ farm_id, payload: undefined }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['Animals', 'DefaultAnimalTypes', 'CustomAnimalTypes', 'CustomAnimalBreeds'], + invalidatesTags: getFarmTagsFn[]>>([ + 'Animals', + 'DefaultAnimalTypes', + 'CustomAnimalTypes', + 'CustomAnimalBreeds', + ]), }), - addAnimalBatches: build.mutation[]>({ - query: (body) => ({ + addAnimalBatches: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: body }) => ({ url: `${animalBatchesUrl}`, method: 'POST', body, }), - async onQueryStarted(body, { dispatch, queryFulfilled }) { + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimalBatches.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalBreeds.initiate()); + await dispatch(api.endpoints.getAnimalBatches.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalBreeds.initiate({ farm_id, payload: undefined }), + ); } catch (err) { // handled in component } }, - invalidatesTags: [ + invalidatesTags: getFarmTagsFn[]>>([ 'AnimalBatches', 'DefaultAnimalTypes', 'CustomAnimalTypes', 'CustomAnimalBreeds', - ], + ]), }), - updateAnimals: build.mutation[]>({ - query: (body) => ({ url: `${animalsUrl}`, method: 'PATCH', body }), - async onQueryStarted(body, { dispatch, queryFulfilled }) { + updateAnimals: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: body }) => ({ + url: `${animalsUrl}`, + method: 'PATCH', + body, + }), + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimals.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalBreeds.initiate()); + await dispatch(api.endpoints.getAnimals.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalBreeds.initiate({ farm_id, payload: undefined }), + ); } catch (err) { // handled in component } }, - invalidatesTags: ['Animals', 'DefaultAnimalTypes', 'CustomAnimalTypes', 'CustomAnimalBreeds'], + invalidatesTags: getFarmTagsFn[]>>([ + 'Animals', + 'DefaultAnimalTypes', + 'CustomAnimalTypes', + 'CustomAnimalBreeds', + ]), }), - updateAnimalBatches: build.mutation[]>({ - query: (body) => ({ url: `${animalBatchesUrl}`, method: 'PATCH', body }), - async onQueryStarted(body, { dispatch, queryFulfilled }) { + updateAnimalBatches: build.mutation[]>>({ + query: ({ farm_id: _farm_id, payload: body }) => ({ + url: `${animalBatchesUrl}`, + method: 'PATCH', + body, + }), + async onQueryStarted({ farm_id }, { dispatch, queryFulfilled }) { try { const { data } = await queryFulfilled; - await dispatch(api.endpoints.getAnimalBatches.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate()); - await dispatch(api.endpoints.getDefaultAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate()); - await dispatch(api.endpoints.getCustomAnimalTypes.initiate('?count=true')); - await dispatch(api.endpoints.getCustomAnimalBreeds.initiate()); + await dispatch(api.endpoints.getAnimalBatches.initiate({ farm_id, payload: undefined })); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getDefaultAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ farm_id, payload: undefined }), + ); + await dispatch( + api.endpoints.getCustomAnimalTypes.initiate({ + farm_id, + payload: { params: '?count=true' }, + }), + ); + await dispatch( + api.endpoints.getCustomAnimalBreeds.initiate({ farm_id, payload: undefined }), + ); } catch (err) { // handled in component } }, - invalidatesTags: [ + invalidatesTags: getFarmTagsFn[]>>([ 'AnimalBatches', 'DefaultAnimalTypes', 'CustomAnimalTypes', 'CustomAnimalBreeds', - ], + ]), }), getSoilAmendmentMethods: build.query({ query: () => `${soilAmendmentMethodsUrl}`, @@ -335,73 +514,104 @@ export const api = createApi({ query: () => `${soilAmendmentFertiliserTypesUrl}`, providesTags: ['SoilAmendmentFertiliserTypes'], }), - addSoilAmendmentProduct: build.mutation>({ - query: (body) => ({ + addSoilAmendmentProduct: build.mutation< + SoilAmendmentProduct, + WithFarmIdPayload> + >({ + query: ({ farm_id: _farm_id, payload: body }) => ({ url: `${productUrl}`, method: 'POST', body, }), }), - updateSoilAmendmentProduct: build.mutation>( - { - query: ({ product_id, ...patch }) => ({ - url: `${productUrl}/${product_id}`, - method: 'PATCH', - body: patch, - }), - }, - ), - deleteSoilAmendmentProduct: build.mutation({ - query: (productId) => ({ + updateSoilAmendmentProduct: build.mutation< + SoilAmendmentProduct, + WithFarmIdPayload> + >({ + query: ({ farm_id: _farm_id, payload: { product_id, ...patch } }) => ({ + url: `${productUrl}/${product_id}`, + method: 'PATCH', + body: patch, + }), + }), + deleteSoilAmendmentProduct: build.mutation< + void, + WithFarmIdPayload + >({ + query: ({ farm_id: _farm_id, payload: productId }) => ({ url: `${productUrl}/${productId}`, method: 'DELETE', }), }), - getSensors: build.query({ - query: () => `${sensorUrl}`, + getSensors: build.query({ + query: (_args) => `${sensorUrl}`, keepUnusedDataFor: 60 * 60 * 24 * 365, // 1 year - providesTags: ['Sensors'], + providesTags: getFarmTagsFn(['Sensors']), }), getSensorReadings: build.query< SensorReadings[], - { - esids: string; // as comma separated values e.g. 'LSZDWX,WV2JHV' - startTime?: string; // ISO 8601 - endTime?: string; // ISO 8601 - truncPeriod?: 'minute' | 'hour' | 'day'; - } + WithFarmIdPayload<{ + params: { + esids: string; // as comma separated values e.g. 'LSZDWX,WV2JHV' + startTime?: string; // ISO 8601 + endTime?: string; // ISO 8601 + truncPeriod?: 'minute' | 'hour' | 'day'; + }; + }> >({ - query: ({ esids, startTime, endTime, truncPeriod }) => { + query: ({ + payload: { + params: { esids, startTime, endTime, truncPeriod }, + }, + }) => { const params = new URLSearchParams({ esids }); if (startTime) params.append('startTime', startTime); if (endTime) params.append('endTime', endTime); if (truncPeriod) params.append('truncPeriod', truncPeriod); return `${sensorUrl}/readings?${params.toString()}`; }, - providesTags: ['SensorReadings'], - }), - addFarmAddon: build.mutation({ - query: (body) => ({ + providesTags: getFarmTagsFn< + SensorReadings[], + WithFarmIdPayload<{ + params: { + esids: string; // as comma separated values e.g. 'LSZDWX,WV2JHV' + startTime?: string; // ISO 8601 + endTime?: string; // ISO 8601 + truncPeriod?: 'minute' | 'hour' | 'day'; + }; + }> + >(['SensorReadings']), + }), + addFarmAddon: build.mutation>({ + query: ({ farm_id: _farm_id, payload: body }) => ({ url: `${farmAddonUrl}`, method: 'POST', body, }), - invalidatesTags: ['FarmAddon'], + invalidatesTags: getFarmTagsFn>(['FarmAddon']), }), - getFarmAddon: build.query({ - query: (param = '') => `${farmAddonUrl}${param}`, - providesTags: ['FarmAddon'], + getFarmAddon: build.query>({ + query: ({ payload }) => `${farmAddonUrl}${payload?.params ?? ''}`, + providesTags: getFarmTagsFn>([ + 'FarmAddon', + ]), }), - deleteFarmAddon: build.mutation({ - query: (id) => ({ + deleteFarmAddon: build.mutation>({ + query: ({ farm_id: _farm_id, payload: id }) => ({ url: `${farmAddonUrl}/${id}`, method: 'DELETE', }), - invalidatesTags: (_result, error) => - error ? [] : ['FarmAddon', 'Sensors', 'SensorReadings'], - }), - getIrrigationPrescriptions: build.query({ - query: () => { + invalidatesTags: (_result, error, args) => + error + ? [] + : getFarmTagsFn>([ + 'FarmAddon', + 'Sensors', + 'SensorReadings', + ])(_result, error, args), + }), + getIrrigationPrescriptions: build.query({ + query: (_args) => { const today = new Date(); const startDate = today.toISOString().split('T')[0]; const endDate = addDaysToDate(today, 1).toISOString().split('T')[0]; @@ -409,7 +619,7 @@ export const api = createApi({ return `${irrigationPrescriptionUrl}?${params.toString()}`; }, - async onQueryStarted(_id, { dispatch, queryFulfilled }) { + async onQueryStarted(_args, { dispatch, queryFulfilled }) { try { // TODO: Once tasks is migrated to rtk use invalidatesTags instead of onQueryStarted' dispatch({ type: 'getTasksSaga' }); @@ -420,22 +630,24 @@ export const api = createApi({ console.error('GET: Irrigation Prescriptions', error?.error ? error.error : error); } }, - providesTags: ['IrrigationPrescriptions'], + providesTags: getFarmTagsFn([ + 'IrrigationPrescriptions', + ]), }), - getIrrigationPrescriptionDetails: build.query({ - query: (id) => `${irrigationPrescriptionUrl}/${id}`, - providesTags: ['IrrigationPrescriptionDetails'], + getIrrigationPrescriptionDetails: build.query< + IrrigationPrescriptionDetails, + WithFarmIdPayload + >({ + query: ({ payload: id }) => `${irrigationPrescriptionUrl}/${id}`, + providesTags: getFarmTagsFn>([ + 'IrrigationPrescriptionDetails', + ]), }), }), }); export const { - useGetAnimalsQuery, - useGetAnimalBatchesQuery, - useGetCustomAnimalBreedsQuery, - useGetCustomAnimalTypesQuery, useGetDefaultAnimalBreedsQuery, - useGetDefaultAnimalTypesQuery, useGetAnimalSexesQuery, useGetAnimalIdentifierTypesQuery, useGetAnimalIdentifierColorsQuery, @@ -443,27 +655,134 @@ export const { useGetAnimalOriginsQuery, useGetAnimalUsesQuery, useGetAnimalRemovalReasonsQuery, - useRemoveAnimalsMutation, - useRemoveAnimalBatchesMutation, - useDeleteAnimalsMutation, - useDeleteAnimalBatchesMutation, - useAddAnimalsMutation, - useAddAnimalBatchesMutation, - useUpdateAnimalsMutation, - useUpdateAnimalBatchesMutation, useGetSoilAmendmentMethodsQuery, useGetSoilAmendmentPurposesQuery, useGetSoilAmendmentFertiliserTypesQuery, - useAddSoilAmendmentProductMutation, - useUpdateSoilAmendmentProductMutation, - useDeleteSoilAmendmentProductMutation, - useGetSensorsQuery, - useGetSensorReadingsQuery, - useLazyGetSensorsQuery, - useLazyGetSensorReadingsQuery, - useAddFarmAddonMutation, - useGetFarmAddonQuery, - useDeleteFarmAddonMutation, - useGetIrrigationPrescriptionsQuery, - useGetIrrigationPrescriptionDetailsQuery, } = api; + +// Farm tag endpoints +export const useGetAnimalsQuery = getUseQueryWithFarmId( + api.useGetAnimalsQuery, +); +export const useGetAnimalBatchesQuery = getUseQueryWithFarmId( + api.useGetAnimalBatchesQuery, +); +export const useGetDefaultAnimalTypesQuery = getUseQueryWithFarmId< + DefaultAnimalType[], + WithFarmIdPayload<{ params: string } | void> +>(api.useGetDefaultAnimalTypesQuery); + +export const useGetCustomAnimalTypesQuery = getUseQueryWithFarmId< + CustomAnimalType[], + WithFarmIdPayload<{ params: string } | void> +>(api.useGetCustomAnimalTypesQuery); +export const useGetCustomAnimalBreedsQuery = getUseQueryWithFarmId< + CustomAnimalBreed[], + WithFarmIdPayload +>(api.useGetCustomAnimalBreedsQuery); +export const useGetFarmAddonQuery = getUseQueryWithFarmId< + FarmAddon[], + WithFarmIdPayload<{ params: string } | void> +>(api.useGetFarmAddonQuery); +export const useGetIrrigationPrescriptionsQuery = getUseQueryWithFarmId< + IrrigationPrescription[], + WithFarmIdPayload +>(api.useGetIrrigationPrescriptionsQuery); +export const useGetIrrigationPrescriptionDetailsQuery = getUseQueryWithFarmId< + IrrigationPrescriptionDetails, + WithFarmIdPayload +>(api.useGetIrrigationPrescriptionDetailsQuery); +export const useGetSensorsQuery = getUseQueryWithFarmId( + api.useGetSensorsQuery, +); +export const useGetSensorReadingsQuery = getUseQueryWithFarmId< + SensorReadings[], + WithFarmIdPayload<{ + params: { + esids: string; // as comma separated values e.g. 'LSZDWX,WV2JHV' + startTime?: string; // ISO 8601 + endTime?: string; // ISO 8601 + truncPeriod?: 'minute' | 'hour' | 'day'; + }; + }> +>(api.useGetSensorReadingsQuery); + +export const useLazyGetSensorsQuery = getLazyUseQueryWithFarmId( + api.useLazyGetSensorsQuery, +); + +export const useLazyGetSensorReadingsQuery = getLazyUseQueryWithFarmId< + SensorReadings[], + WithFarmIdPayload<{ + params: { + esids: string; // as comma separated values e.g. 'LSZDWX,WV2JHV' + startTime?: string; // ISO 8601 + endTime?: string; // ISO 8601 + truncPeriod?: 'minute' | 'hour' | 'day'; + }; + }> +>(api.useLazyGetSensorReadingsQuery); + +// Mutations wrapped with getMutationWithFarmId +export const useRemoveAnimalsMutation = getMutationWithFarmId< + Animal[], + WithFarmIdPayload[]> +>(api.useRemoveAnimalsMutation); + +export const useRemoveAnimalBatchesMutation = getMutationWithFarmId< + AnimalBatch[], + WithFarmIdPayload[]> +>(api.useRemoveAnimalBatchesMutation); + +export const useDeleteAnimalsMutation = getMutationWithFarmId< + Animal[], + WithFarmIdPayload +>(api.useDeleteAnimalsMutation); + +export const useDeleteAnimalBatchesMutation = getMutationWithFarmId< + AnimalBatch[], + WithFarmIdPayload +>(api.useDeleteAnimalBatchesMutation); + +export const useAddAnimalsMutation = getMutationWithFarmId< + Animal[], + WithFarmIdPayload[]> +>(api.useAddAnimalsMutation); + +export const useAddAnimalBatchesMutation = getMutationWithFarmId< + AnimalBatch[], + WithFarmIdPayload[]> +>(api.useAddAnimalBatchesMutation); + +export const useUpdateAnimalsMutation = getMutationWithFarmId< + void, + WithFarmIdPayload[]> +>(api.useUpdateAnimalsMutation); + +export const useUpdateAnimalBatchesMutation = getMutationWithFarmId< + void, + WithFarmIdPayload[]> +>(api.useUpdateAnimalBatchesMutation); + +export const useAddSoilAmendmentProductMutation = getMutationWithFarmId< + SoilAmendmentProduct, + WithFarmIdPayload> +>(api.useAddSoilAmendmentProductMutation); + +export const useUpdateSoilAmendmentProductMutation = getMutationWithFarmId< + SoilAmendmentProduct, + WithFarmIdPayload> +>(api.useUpdateSoilAmendmentProductMutation); + +export const useDeleteSoilAmendmentProductMutation = getMutationWithFarmId< + void, + WithFarmIdPayload +>(api.useDeleteSoilAmendmentProductMutation); + +export const useAddFarmAddonMutation = getMutationWithFarmId>( + api.useAddFarmAddonMutation, +); + +export const useDeleteFarmAddonMutation = getMutationWithFarmId>( + api.useDeleteFarmAddonMutation, +); diff --git a/packages/webapp/src/store/api/apiTags.ts b/packages/webapp/src/store/api/apiTags.ts index 89952cdbc2..d6e4f659fc 100644 --- a/packages/webapp/src/store/api/apiTags.ts +++ b/packages/webapp/src/store/api/apiTags.ts @@ -57,11 +57,24 @@ export const FarmTags = [ ] as const; /** - * These tags contain endpoints that could either return farm specific data - * or farm neutral defaults data. + * These tags contain endpoints that could either send or return + * farm specific data and/or farm neutral default data. * - * For data safety these data should also not persist when switching farms, - * or should be stored in a separate farm store. + * For data safety if the sent data or returned data + * should not be used between farms add it here. + * + * Using this may be an indicator of possible improvements to backend architecture + * + * Does not belong here: + * eg. 'DefaultAnimalBreeds' sends nothing and returns only library data + * eg. If 'MarketDirectoryPartners' sent farm.country instead of '?filter=country' and returned country specific library data + * even though farm.country is farm related it is generic/anonymous enough to be reused between farms + * + * Belongs here: + * eg. 'DefaultAnimalTypes' sends '?count=true' and returns farm specific data + * even though 'DefaultAnimalTypes' sends nothing and returns library data + * eg. 'MarketDirectoryPartners' sends '?filter=country' and returns country specific library data + * this query does not send the country to filter by and so cannot be reused between farms */ export const FarmLibraryTags = [ // 'count' param returns farm specific data @@ -73,3 +86,5 @@ export const FarmLibraryTags = [ export const API_TAGS = [...LibraryTags, ...FarmTags, ...FarmLibraryTags] as const; export type ApiTag = (typeof API_TAGS)[number]; +export type FarmTag = (typeof FarmTags)[number]; +export type FarmLibraryTag = (typeof FarmLibraryTags)[number]; diff --git a/packages/webapp/src/store/api/marketDirectoryInfoApi.ts b/packages/webapp/src/store/api/marketDirectoryInfoApi.ts index 91efc69389..bcbcdc8b6c 100644 --- a/packages/webapp/src/store/api/marketDirectoryInfoApi.ts +++ b/packages/webapp/src/store/api/marketDirectoryInfoApi.ts @@ -15,40 +15,60 @@ import { api } from './apiSlice'; import { marketDirectoryInfoUrl } from '../../apiConfig'; -import { MarketDirectoryInfo } from './types'; +import { MarketDirectoryInfo, WithFarmIdPayload } from './types'; +import { getFarmTagsFn, getMutationWithFarmId, getUseQueryWithFarmId } from './util'; export const marketDirectoryInfo = api.injectEndpoints({ endpoints: (build) => ({ - getMarketDirectoryInfo: build.query({ - query: () => ({ + getMarketDirectoryInfo: build.query({ + query: (_args) => ({ url: `${marketDirectoryInfoUrl}`, method: 'GET', }), - providesTags: ['MarketDirectoryInfo'], + providesTags: getFarmTagsFn(['MarketDirectoryInfo']), }), - addMarketDirectoryInfo: build.mutation>({ - query: (body) => ({ + addMarketDirectoryInfo: build.mutation< + void, + WithFarmIdPayload> + >({ + query: ({ farm_id: _farm_id, payload: body }) => ({ url: `${marketDirectoryInfoUrl}`, method: 'POST', body, }), - invalidatesTags: ['MarketDirectoryInfo'], + invalidatesTags: getFarmTagsFn>>([ + 'MarketDirectoryInfo', + ]), }), - updateMarketDirectoryInfo: build.mutation>({ - query: ({ id, ...patch }) => { + updateMarketDirectoryInfo: build.mutation< + void, + WithFarmIdPayload> + >({ + query: ({ farm_id: _farm_id, payload: { id, ...patch } }) => { return { url: `${marketDirectoryInfoUrl}/${id}`, method: 'PATCH', body: patch, }; }, - invalidatesTags: ['MarketDirectoryInfo'], + invalidatesTags: getFarmTagsFn>>([ + 'MarketDirectoryInfo', + ]), }), }), }); -export const { - useAddMarketDirectoryInfoMutation, - useUpdateMarketDirectoryInfoMutation, - useGetMarketDirectoryInfoQuery, -} = marketDirectoryInfo; +export const useGetMarketDirectoryInfoQuery = getUseQueryWithFarmId< + MarketDirectoryInfo, + WithFarmIdPayload +>(marketDirectoryInfo.useGetMarketDirectoryInfoQuery); + +export const useAddMarketDirectoryInfoMutation = getMutationWithFarmId< + void, + WithFarmIdPayload> +>(marketDirectoryInfo.useAddMarketDirectoryInfoMutation); + +export const useUpdateMarketDirectoryInfoMutation = getMutationWithFarmId< + void, + WithFarmIdPayload> +>(marketDirectoryInfo.useUpdateMarketDirectoryInfoMutation); diff --git a/packages/webapp/src/store/api/marketDirectoryPartnersApi.ts b/packages/webapp/src/store/api/marketDirectoryPartnersApi.ts index 7bc13a4e78..26ee60d90a 100644 --- a/packages/webapp/src/store/api/marketDirectoryPartnersApi.ts +++ b/packages/webapp/src/store/api/marketDirectoryPartnersApi.ts @@ -15,14 +15,18 @@ import { api } from './apiSlice'; import { marketDirectoryPartnersUrl } from '../../apiConfig'; -import { MarketDirectoryPartner } from './types'; +import { MarketDirectoryPartner, WithFarmIdPayload } from './types'; import { PARTNERS_INFO } from '../../containers/Profile/FarmSettings/MarketDirectory/Consent/partners'; +import { getUseQueryWithFarmId } from './util'; export const marketDirectoryPartners = api.injectEndpoints({ endpoints: (build) => ({ - getMarketDirectoryPartners: build.query({ - query: (param = '') => ({ - url: `${marketDirectoryPartnersUrl}${param}`, + getMarketDirectoryPartners: build.query< + MarketDirectoryPartner[], + WithFarmIdPayload<{ params: string } | void> + >({ + query: ({ payload }) => ({ + url: `${marketDirectoryPartnersUrl}${payload?.params ?? ''}`, method: 'GET', }), transformResponse: (response: MarketDirectoryPartner[]) => { @@ -33,4 +37,7 @@ export const marketDirectoryPartners = api.injectEndpoints({ }), }); -export const { useGetMarketDirectoryPartnersQuery } = marketDirectoryPartners; +export const useGetMarketDirectoryPartnersQuery = getUseQueryWithFarmId< + MarketDirectoryPartner[], + WithFarmIdPayload<{ params: string } | void> +>(marketDirectoryPartners.useGetMarketDirectoryPartnersQuery); diff --git a/packages/webapp/src/store/api/selectors/sensorSelectors.ts b/packages/webapp/src/store/api/selectors/sensorSelectors.ts index ac92a44be0..51e0ba2da2 100644 --- a/packages/webapp/src/store/api/selectors/sensorSelectors.ts +++ b/packages/webapp/src/store/api/selectors/sensorSelectors.ts @@ -15,27 +15,42 @@ import { createSelector } from '@reduxjs/toolkit'; import { api } from '../apiSlice'; +import { loginSelector } from '../../../containers/userFarmSlice'; // For map use / to use with old selectors only (e.g. pointSelector). In components, use the query hook and selectFromWithin +const selectSensorsData = createSelector( + [ + loginSelector, + (state) => state, // Just pass state, don't type it here + ], + (login, state) => { + const { farm_id } = login; + if (!farm_id) return undefined; + + // Call the selector directly with state + return api.endpoints.getSensors.select({ farm_id, payload: undefined })(state); + }, +); + export const allSensorsSelector = createSelector( - [api.endpoints.getSensors.select()], + [selectSensorsData], (sensorResult) => - sensorResult.data?.sensors.map((sensor) => ({ + sensorResult?.data?.sensors.map((sensor) => ({ ...sensor, isAddonSensor: 'true', type: 'sensor', - })) || [], + })) ?? [], ); export const sensorArraysSelector = createSelector( - [api.endpoints.getSensors.select()], + [selectSensorsData], (sensorResult) => - sensorResult.data?.sensor_arrays.map((sensorArray) => ({ + sensorResult?.data?.sensor_arrays.map((sensorArray) => ({ ...sensorArray, isAddonSensor: 'true', type: 'sensor_array', - })) || [], + })) ?? [], ); export const standaloneSensorsSelector = createSelector( diff --git a/packages/webapp/src/store/api/types/index.ts b/packages/webapp/src/store/api/types/index.ts index 2f6c69580a..95a73817cb 100644 --- a/packages/webapp/src/store/api/types/index.ts +++ b/packages/webapp/src/store/api/types/index.ts @@ -31,6 +31,8 @@ export interface BasicEnum { id: number; key: string; } + +export type WithFarmIdPayload = { farm_id: string; payload: T }; export interface Animal { animal_use_relationships?: { animal_id: number; diff --git a/packages/webapp/src/store/api/util.ts b/packages/webapp/src/store/api/util.ts new file mode 100644 index 0000000000..ca2a0fc24f --- /dev/null +++ b/packages/webapp/src/store/api/util.ts @@ -0,0 +1,329 @@ +/* + * Copyright 2026 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { + FetchBaseQueryError, + QueryStatus, + skipToken, + SubscriptionOptions, +} from '@reduxjs/toolkit/query/react'; +import { useSelector } from 'react-redux'; +import { loginSelector } from '../../containers/userFarmSlice'; +import { WithFarmIdPayload } from './types'; +import { FarmLibraryTag, FarmTag } from './apiTags'; + +type RefetchQueryResult = Promise> & { + arg: A; + requestId: string; + subscriptionOptions: SubscriptionOptions | undefined; + abort(): void; + unwrap(): Promise; + unsubscribe(): void; + refetch(): RefetchQueryResult; + updateSubscriptionOptions(options: SubscriptionOptions): void; + queryCacheKey: string; +}; + +type RawQueryResult = { + data?: T; + currentData?: T; + isUninitialized: boolean; + isLoading: boolean; + isFetching: boolean; + isSuccess: boolean; + isError: boolean; + error?: any; + status: QueryStatus; + startedTimeStamp?: number; + fulfilledTimeStamp?: number; + endpointName?: string; + originalArgs?: unknown; + requestId?: string; + refetch(): RefetchQueryResult; +}; + +type RawMutationResult = { + originalArgs?: unknown; + data?: T; + error?: unknown; + endpointName?: string; + fulfilledTimeStamp?: number; + isUninitialized: boolean; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + startedTimeStamp?: number; + reset: () => void; +}; + +type ExtraArgs = Arg extends undefined ? undefined : Omit; + +type BaseUseQueryOptions = { + pollingInterval?: number; + skipPollingIfUnfocused?: boolean; + refetchOnReconnect?: boolean; + refetchOnFocus?: boolean; + skip?: boolean; + refetchOnMountOrArgChange?: boolean | number; +}; + +type UseQueryOptionsWithSelect = BaseUseQueryOptions & { + selectFromResult: (result: Result) => Selected; +}; + +type UseLazyQueryOptions = Omit; +type UseLazyQueryOptionsWithSelect = UseLazyQueryOptions & { + selectFromResult: (result: Result) => Selected; +}; + +type BaseUseMutationOptions = { + fixedCacheKey?: string; +}; + +type UseMutationOptionsWithSelect = BaseUseMutationOptions & { + selectFromResult: (result: Result) => Selected; +}; + +/** + * Helper function that contains the shared logic for injecting farm_id + * into RTK Query hooks that expect an object argument. + * + * This is **not** meant to be used directly — it's an internal implementation detail. + * + * @internal + */ +function queryWithFarmId( + rawHook: (arg: any, options?: any) => any, + payload?: any, + options?: any, +): any { + const { farm_id } = useSelector(loginSelector); + if (!farm_id) { + return rawHook(skipToken); + } + + const fullArg = { farm_id, payload }; + return rawHook(fullArg, options); +} + +/** + * Creates a farm-aware version of an RTK Query `useQuery` hook. + * + * Automatically injects `{ farm_id }` into the query argument object. + * Supports both plain usage and usage with `selectFromResult`. + * + * @template Data - The type of data returned by the query (e.g. `Animal[]`, `Crop[]`, ...) + * @template Args - The shape of the raw query argument + * + * @example + * ```ts + * // Plain usage — returns full query result + * const { data, isLoading } = useGetAnimalsQuery(); + * + * // With selectFromResult — returns only the selected slice with type inference + * const { selectedAnimal } = useGetAnimalsQuery(undefined, { + * selectFromResult: ({ data }) => ({ + * selectedAnimal: data?.find(a => a.id === someId), + * }) + * }); + * ``` + */ +export function getUseQueryWithFarmId( + rawHook: (arg: Args, options?: any) => any, +) { + type InnerPayload = Args extends WithFarmIdPayload ? P : never; + // Overload 1: when selectFromResult is present + function useQueryWithFarmId( + payload: InnerPayload | undefined, + options: UseQueryOptionsWithSelect, Selected>, + ): Selected; + + // Overload 2: plain call (no selectFromResult or no options) + function useQueryWithFarmId( + payload?: InnerPayload, + options?: BaseUseQueryOptions, + ): RawQueryResult; + + function useQueryWithFarmId(payload?: InnerPayload, options?: any): any { + return queryWithFarmId(rawHook, payload, options); + } + + return useQueryWithFarmId; +} + +export function getLazyUseQueryWithFarmId( + rawLazyHook: ( + options?: any, + ) => [(payload: any, preferCacheValue?: boolean) => Promise, any, any], +) { + type InnerPayload = Args extends WithFarmIdPayload ? P : never; + // Overload 1: when selectFromResult is present + function useLazyQueryWithFarmId( + options: UseQueryOptionsWithSelect, Selected>, + ): [(payload?: InnerPayload, preferCacheValue?: boolean) => Promise, Selected, Args]; + + // Overload 2: plain call (no selectFromResult or no options) + function useLazyQueryWithFarmId( + options?: BaseUseQueryOptions, + ): [ + (payload?: InnerPayload, preferCacheValue?: boolean) => Promise, + RawQueryResult, + Args, + ]; + + function useLazyQueryWithFarmId(options?: any): any { + const { farm_id } = useSelector(loginSelector); + const [trigger, result, lastPromiseInfo] = rawLazyHook(options); + if (!farm_id) { + return [ + (payload?: InnerPayload, preferCacheValue?: boolean) => + trigger(skipToken as unknown as Args, preferCacheValue), + result, + lastPromiseInfo, + ]; + } + return [ + (payload?: InnerPayload, preferCacheValue?: boolean) => + trigger({ payload, farm_id }, preferCacheValue), + result, + lastPromiseInfo, + ]; + } + + return useLazyQueryWithFarmId; +} + +// Helper to assert the shape we know exists at runtime taken from rtk types +// Rtk 2.0 should help reduce this bloat with better exported types +type AssertedMutationPromise = Promise< + | { + data: T; + } + | { + error: unknown; + } +> & { + /** + * A unique string generated for the request sequence + */ + requestId: string; + /** + * A method to cancel the mutation promise. Note that this is not intended to prevent the mutation + * that was fired off from reaching the server, but only to assist in handling the response. + * + * Calling `abort()` prior to the promise resolving will force it to reach the error state with + * the serialized error: + * `{ name: 'AbortError', message: 'Aborted' }` + * + * @example + * ```ts + * const [updateUser] = useUpdateUserMutation(); + * + * useEffect(() => { + * const promise = updateUser(id); + * promise + * .unwrap() + * .catch((err) => { + * if (err.name === 'AbortError') return; + * // else handle the unexpected error + * }) + * + * return () => { + * promise.abort(); + * } + * }, [id, updateUser]) + * ``` + */ + abort(): void; + /** + * Unwraps a mutation call to provide the raw response/error. + * + * @remarks + * If you need to access the error or success payload immediately after a mutation, you can chain .unwrap(). + * + * @example + * ```ts + * // codeblock-meta title="Using .unwrap" + * addPost({ id: 1, name: 'Example' }) + * .unwrap() + * .then((payload) => console.log('fulfilled', payload)) + * .catch((error) => console.error('rejected', error)); + * ``` + * + * @example + * ```ts + * // codeblock-meta title="Using .unwrap with async await" + * try { + * const payload = await addPost({ id: 1, name: 'Example' }).unwrap(); + * console.log('fulfilled', payload) + * } catch (error) { + * console.error('rejected', error); + * } + * ``` + */ + unwrap(): Promise; + /** + * A method to manually unsubscribe from the mutation call, meaning it will be removed from cache after the usual caching grace period. + The value returned by the hook will reset to `isUninitialized` afterwards. + */ + reset(): void; + /** @deprecated has been renamed to `reset` */ + unsubscribe(): void; +}; + +export function getMutationWithFarmId( + rawMutationHook: ( + options?: any, + ) => readonly [(arg: Args) => Promise, RawMutationResult], +) { + type InnerPayload = Args extends WithFarmIdPayload ? P : never; + + // Overload 1: when selectFromResult is present + function useMutationWithFarmId( + options: UseMutationOptionsWithSelect, Selected>, + ): [(arg?: Args['payload']) => AssertedMutationPromise, Selected]; + + // Overload 2: plain call (no selectFromResult or no options) + function useMutationWithFarmId( + options?: BaseUseQueryOptions, + ): [(arg?: Args['payload']) => AssertedMutationPromise, RawMutationResult]; + + function useMutationWithFarmId(options?: any): any { + const { farm_id } = useSelector(loginSelector); + const [trigger, result] = rawMutationHook(options); + if (!farm_id) { + return [(payload?: InnerPayload) => trigger(skipToken as unknown as Args), result]; + } + return [ + (payload?: InnerPayload) => + trigger({ farm_id, payload } as Args) as AssertedMutationPromise, + result, + ]; + } + + return useMutationWithFarmId; +} + +export function mapFarmTags(tags: (FarmTag | FarmLibraryTag)[], farm_id: string) { + return tags.map((tag) => ({ type: tag, id: farm_id })); +} + +export function getFarmTagsFn( + tags: (FarmTag | FarmLibraryTag)[], +) { + return (_result: Data | undefined, _error: FetchBaseQueryError | undefined, args: Args) => { + return mapFarmTags(tags, args.farm_id); + }; +} diff --git a/packages/webapp/src/store/api/weatherApi.ts b/packages/webapp/src/store/api/weatherApi.ts index 250ae30f6d..cff0559734 100644 --- a/packages/webapp/src/store/api/weatherApi.ts +++ b/packages/webapp/src/store/api/weatherApi.ts @@ -15,16 +15,22 @@ import { api } from './apiSlice'; import { weatherUrl } from '../../apiConfig'; -import { WeatherData } from './types'; +import { WeatherData, WithFarmIdPayload } from './types'; +import { getFarmTagsFn, getUseQueryWithFarmId } from './util'; export const weatherApi = api.injectEndpoints({ endpoints: (build) => ({ - getWeather: build.query({ - query: ({ measurementSystem }) => `${weatherUrl}`, - providesTags: ['Weather'], + getWeather: build.query>({ + query: (_args) => `${weatherUrl}`, + providesTags: getFarmTagsFn>([ + 'Weather', + ]), keepUnusedDataFor: 7200, // Cache data for 2 hours (7200 seconds) }), }), }); -export const { useGetWeatherQuery } = weatherApi; +export const useGetWeatherQuery = getUseQueryWithFarmId< + WeatherData, + WithFarmIdPayload<{ measurementSystem?: string }> +>(weatherApi.useGetWeatherQuery);