diff --git a/packages/webapp/public/locales/en/common.json b/packages/webapp/public/locales/en/common.json index b8fca7199c..a71ed790dc 100644 --- a/packages/webapp/public/locales/en/common.json +++ b/packages/webapp/public/locales/en/common.json @@ -79,6 +79,7 @@ "QUANTITY": "Quantity", "REMOVE": "Remove", "REMOVE_ITEM": "Remove item", + "REMOVED": "Removed", "REQUIRED": "Required", "RETIRE": "Retire", "REVISION_INFO": "Revised on {{date}} by {{user}}", diff --git a/packages/webapp/src/components/ProductInventory/ProductForm/PureSoilAmendmentProductForm.tsx b/packages/webapp/src/components/ProductInventory/ProductForm/PureSoilAmendmentProductForm.tsx index 0bc3363b6f..e605a4ea95 100644 --- a/packages/webapp/src/components/ProductInventory/ProductForm/PureSoilAmendmentProductForm.tsx +++ b/packages/webapp/src/components/ProductInventory/ProductForm/PureSoilAmendmentProductForm.tsx @@ -13,13 +13,14 @@ * GNU General Public License for more details, see . */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useFormContext } from 'react-hook-form'; import Input, { getInputErrors } from '../../Form/Input'; import ProductDetails, { type ProductDetailsProps } from '../../Form/ProductDetails'; import { hookFormMaxCharsValidation } from '../../Form/hookformValidationUtils'; import { getSoilAmendmentFormValues } from '../../Form/ProductDetails/utils'; +import { isLibraryProduct } from '../../../util/product'; import { productDefaultValuesByType } from '../../../containers/ProductInventory/ProductForm/constants'; import { TASK_TYPES } from '../../../containers/Task/constants'; import { PRODUCT_FIELD_NAMES } from '../../Task/AddSoilAmendmentProducts/types'; @@ -79,7 +80,11 @@ const PureSoilAmendmentProductForm = ({ } }, [mode]); - const productNames: SoilAmendmentProduct['name'][] = products.map(({ name }) => name); + const customProductNames = useMemo(() => { + return products + .filter((product) => !product.removed && !isLibraryProduct(product)) + .map(({ name }) => name); + }, [products]); return (
@@ -95,7 +100,7 @@ const PureSoilAmendmentProductForm = ({ // Allow duplicate check to pass if keeping the original name during edit if ( !(mode === FormMode.EDIT && value === product?.name) && - productNames.includes(value) + customProductNames.includes(value) ) { return t('ADD_TASK.DUPLICATE_NAME'); } diff --git a/packages/webapp/src/components/Task/AddSoilAmendmentProducts/ProductCard/index.tsx b/packages/webapp/src/components/Task/AddSoilAmendmentProducts/ProductCard/index.tsx index 532747689a..4fa90de0b6 100644 --- a/packages/webapp/src/components/Task/AddSoilAmendmentProducts/ProductCard/index.tsx +++ b/packages/webapp/src/components/Task/AddSoilAmendmentProducts/ProductCard/index.tsx @@ -33,6 +33,13 @@ import { hookFormMaxCharsValidation } from '../../../Form/hookformValidationUtil import { soilAmendmentProductDetailsDefaultValues } from '../../../../containers/ProductInventory/ProductForm/constants'; import { getSoilAmendmentFormValues } from '../../../Form/ProductDetails/utils'; +const findProduct = ( + products?: SoilAmendmentProduct[], + productId?: SoilAmendmentProduct['product_id'], +) => { + return products?.find(({ product_id }) => product_id === productId); +}; + export type ProductCardProps = ProductDetailsProps & { namePrefix: string; system: 'metric' | 'imperial'; @@ -128,7 +135,8 @@ const SoilAmendmentProductCard = ({ const selectRef = useRef(null); const productOptions = products.map(({ product_id, name, ...rest }) => { - return { value: product_id, label: name, data: rest }; + const label = name + (rest.removed ? ` (${t('common:REMOVED')})` : ''); + return { value: product_id, label, data: rest }; }); useEffect(() => { @@ -137,6 +145,11 @@ const SoilAmendmentProductCard = ({ } }, [otherPurposeId]); + useEffect(() => { + const selectedProduct = findProduct(products, productId); + nestedFormMethods.reset(getSoilAmendmentFormValues(selectedProduct)); + }, []); + return (
{!isReadOnly && onRemove && ( @@ -154,7 +167,7 @@ const SoilAmendmentProductCard = ({ options={productOptions} onChange={(e) => { onChange(e?.value); - const selectedProduct = products.find(({ product_id }) => product_id === e?.value); + const selectedProduct = findProduct(products, e?.value); nestedFormMethods.reset(getSoilAmendmentFormValues(selectedProduct)); }} value={productOptions.find(({ value: id }) => id === value)} diff --git a/packages/webapp/src/components/Task/SoilAmendmentTask/index.tsx b/packages/webapp/src/components/Task/SoilAmendmentTask/index.tsx index 597fe3328d..a528b6daba 100644 --- a/packages/webapp/src/components/Task/SoilAmendmentTask/index.tsx +++ b/packages/webapp/src/components/Task/SoilAmendmentTask/index.tsx @@ -33,10 +33,22 @@ import { furrow_hole_depth } from '../../../util/convert-units/unit'; import styles from './styles.module.scss'; import { locationsSelector } from '../../../containers/locationSlice'; +// Return products in inventory plus removed ones already used in the task. +const getAvailableProducts = ( + usedProductsInTask: SoilAmendmentProduct[] | undefined, + products: SoilAmendmentProduct[], +) => { + const usedProductIds = new Set(usedProductsInTask?.map(({ product_id }) => product_id)); + return products.filter((product) => !product.removed || usedProductIds.has(product.product_id)); +}; + type PureSoilAmendmentTaskProps = UseFormReturn & Pick & { disabled: boolean; - task?: { locations: { location_id: number }[] }; + task?: { + locations: { location_id: number }[]; + soil_amendment_task_products: SoilAmendmentProduct[]; + }; locations: { location_id: number }[]; }; @@ -158,7 +170,7 @@ const PureSoilAmendmentTask = ({ product.type === TASK_TYPES.SOIL_AMENDMENT, - ); + const soilAmendmentProducts = + /* @ts-expect-error https://github.com/reduxjs/reselect/issues/550#issuecomment-999701108 */ + useSelector((state) => productsForTaskTypeSelector(state, taskType)) || []; const isReadOnly = mode === FormMode.READ_ONLY; @@ -53,7 +52,7 @@ export default function SoilAmendmentProductForm({ mode, productId }: FormConten isReadOnly={isReadOnly} farm={{ farm_id, interested, country_id }} fertiliserTypeOptions={fertiliserTypeOptions} - products={soilAmendmentCustomProducts} + products={soilAmendmentProducts} productId={productId} /> ); diff --git a/packages/webapp/src/containers/ProductInventory/index.tsx b/packages/webapp/src/containers/ProductInventory/index.tsx index 8b926a10dd..c502eacd84 100644 --- a/packages/webapp/src/containers/ProductInventory/index.tsx +++ b/packages/webapp/src/containers/ProductInventory/index.tsx @@ -24,7 +24,7 @@ import { ReactComponent as BookIcon } from '../../assets/images/book-closed.svg' import useSearchFilter from '../../containers/hooks/useSearchFilter'; import PureProductInventory from '../../components/ProductInventory'; import { getProducts } from '../Task/saga'; -import { productsSelector } from '../productSlice'; +import { productInventorySelector } from '../productSlice'; import { Product, SoilAmendmentProduct } from '../../store/api/types'; import { TASK_TYPES } from '../Task/constants'; import { SearchProps } from '../../components/Animals/Inventory'; @@ -65,7 +65,7 @@ export default function ProductInventory() { dispatch(getProducts()); }, []); - const productInventory = useSelector(productsSelector); + const productInventory = useSelector(productInventorySelector); const inventory = productInventory .filter((product) => product.type === TASK_TYPES.SOIL_AMENDMENT) diff --git a/packages/webapp/src/containers/Task/TaskComplete/StepOne.jsx b/packages/webapp/src/containers/Task/TaskComplete/StepOne.jsx index 524e5c2b90..82bff3b602 100644 --- a/packages/webapp/src/containers/Task/TaskComplete/StepOne.jsx +++ b/packages/webapp/src/containers/Task/TaskComplete/StepOne.jsx @@ -22,7 +22,7 @@ function TaskCompleteStepOne() { const { task_id } = useParams(); const task = useSelector(taskWithProductSelector(task_id)); const selectedTaskType = task?.taskType; - const products = useSelector(productsForTaskTypeSelector(selectedTaskType)); + const products = useSelector((state) => productsForTaskTypeSelector(state, selectedTaskType)); const persistedPaths = [`/tasks/${task_id}/complete`]; const onContinue = (data) => { diff --git a/packages/webapp/src/containers/Task/TaskDetails/index.jsx b/packages/webapp/src/containers/Task/TaskDetails/index.jsx index 32556505f6..7228a7fd09 100644 --- a/packages/webapp/src/containers/Task/TaskDetails/index.jsx +++ b/packages/webapp/src/containers/Task/TaskDetails/index.jsx @@ -28,7 +28,7 @@ function TaskDetails() { const { interested, farm_id } = useSelector(certifierSurveySelector, shallowEqual); const persistedFormData = useSelector(hookFormPersistSelector); const selectedTaskType = useSelector(taskTypeSelector(persistedFormData.task_type_id)); - const products = useSelector(productsForTaskTypeSelector(selectedTaskType)); + const products = useSelector((state) => productsForTaskTypeSelector(state, selectedTaskType)); const managementPlanIds = persistedFormData.managementPlans?.map( ({ management_plan_id }) => management_plan_id, ); diff --git a/packages/webapp/src/containers/Task/TaskReadOnly/index.jsx b/packages/webapp/src/containers/Task/TaskReadOnly/index.jsx index d6ba178c3b..608492ea3e 100644 --- a/packages/webapp/src/containers/Task/TaskReadOnly/index.jsx +++ b/packages/webapp/src/containers/Task/TaskReadOnly/index.jsx @@ -52,7 +52,7 @@ function TaskReadOnly() { const system = useSelector(measurementSelector); const task = useReadonlyTask(task_id); const selectedTaskType = task?.taskType; - const products = useSelector(productsForTaskTypeSelector(selectedTaskType)); + const products = useSelector((state) => productsForTaskTypeSelector(state, selectedTaskType)); const isIrrigationTaskWithExternalPrescription = isTaskType(selectedTaskType, 'IRRIGATION_TASK') && task?.irrigation_task?.irrigation_prescription_external_id != null; diff --git a/packages/webapp/src/containers/productSlice.js b/packages/webapp/src/containers/productSlice.js index 191b857309..5d05d2ecef 100644 --- a/packages/webapp/src/containers/productSlice.js +++ b/packages/webapp/src/containers/productSlice.js @@ -2,6 +2,7 @@ import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { loginSelector, onLoadingFail, onLoadingStart } from './userFarmSlice'; import { pick } from '../util/pick'; import { createSelector } from 'reselect'; +import { isLibraryProduct } from '../util/product'; export const getProduct = (obj) => { return pick(obj, [ @@ -55,6 +56,7 @@ const productSelectors = productAdapter.getSelectors( (state) => state.entitiesReducer[productSlice.name], ); +// Select farm products including removed ones export const productsSelector = createSelector( [productSelectors.selectAll, loginSelector], (products, { farm_id }) => { @@ -62,22 +64,25 @@ export const productsSelector = createSelector( }, ); -export const productsForTaskTypeSelector = (taskType) => { - return createSelector([productSelectors.selectAll, loginSelector], (products, { farm_id }) => { +// Select farm products for a given type including removed ones +export const productsForTaskTypeSelector = createSelector( + [productsSelector, (_state, taskType) => taskType], + (products, taskType) => { if (taskType === undefined) { return undefined; } - return products.filter( - (product) => - product.farm_id === farm_id && product.type === taskType.task_translation_key.toLowerCase(), - ); - }); -}; + return products.filter(({ type }) => type === taskType.task_translation_key.toLowerCase()); + }, +); + +export const productInventorySelector = createSelector([productsSelector], (products) => { + return products.filter((product) => !product.removed); +}); export const hasAvailableProductsSelector = createSelector( - [productsSelector, (_state, type) => type], - (products, type) => { - return products.some((product) => !product.removed && (!type || product.type === type)); + [productInventorySelector, (_state, type) => type], + (productInventory, type) => { + return productInventory.some((product) => !type || product.type === type); }, ); diff --git a/packages/webapp/src/store/api/types/index.ts b/packages/webapp/src/store/api/types/index.ts index fa0046ad34..5f04b1d33c 100644 --- a/packages/webapp/src/store/api/types/index.ts +++ b/packages/webapp/src/store/api/types/index.ts @@ -202,6 +202,7 @@ export interface Product { | typeof TASK_TYPES.PEST_CONTROL; farm_id?: string; on_permitted_substances_list?: 'YES' | 'NO' | 'NOT_SURE' | null; + removed: boolean; } export enum ElementalUnit { diff --git a/packages/webapp/src/stories/Pages/Task/AddSoilAmendmentProducts/products.ts b/packages/webapp/src/stories/Pages/Task/AddSoilAmendmentProducts/products.ts index 9be9816cd6..bd71430455 100644 --- a/packages/webapp/src/stories/Pages/Task/AddSoilAmendmentProducts/products.ts +++ b/packages/webapp/src/stories/Pages/Task/AddSoilAmendmentProducts/products.ts @@ -30,6 +30,7 @@ export const products = [ k: 3, elemental_unit: ElementalUnit.PERCENT, }, + removed: false, }, { product_id: 2, @@ -45,6 +46,7 @@ export const products = [ k: 3.3, elemental_unit: ElementalUnit.RATIO, }, + removed: false, }, { product_id: 3, @@ -60,6 +62,7 @@ export const products = [ k: undefined, elemental_unit: undefined, }, + removed: false, }, { product_id: 4, @@ -75,6 +78,7 @@ export const products = [ k: 1, elemental_unit: ElementalUnit.PERCENT, }, + removed: false, }, { product_id: 5, @@ -90,6 +94,7 @@ export const products = [ k: 1, elemental_unit: ElementalUnit.PERCENT, }, + removed: false, }, { product_id: 6, @@ -105,5 +110,6 @@ export const products = [ k: undefined, elemental_unit: ElementalUnit.RATIO, }, + removed: false, }, ]; diff --git a/packages/webapp/src/util/product.ts b/packages/webapp/src/util/product.ts new file mode 100644 index 0000000000..75869cd980 --- /dev/null +++ b/packages/webapp/src/util/product.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025 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 { Product } from '../store/api/types'; + +// LF-4963 - confirm property that will distinguish custom from library products +export const isLibraryProduct = (product: Product) => { + return !!product.product_translation_key; +};