Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/webapp/public/locales/en/message.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,12 @@
"PRODUCT": {
"ERROR": {
"CREATE": "Failed to create product",
"REMOVE": "Failed to remove product",
"UPDATE": "Failed to update product"
},
"SUCCESS": {
"CREATE": "Successfully created product",
"REMOVE": "Successfully removed product",
"UPDATE": "Successfully updated product"
}
},
Expand Down
10 changes: 9 additions & 1 deletion packages/webapp/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,15 @@
"INVENTORY": {
"PRODUCT_NAME": "Product name",
"PRODUCT_TYPE": "Product type",
"SOIL_AMENDMENT": "Soil amendment"
"REMOVE_CONFIRMATION": {
"BODY": "Are you sure you want to remove the product <strong>{{name}}</strong> from the inventory?",
"TITLE": "Remove product from inventory?"
},
"SOIL_AMENDMENT": "Soil amendment",
"UNABLE_TO_REMOVE": {
"BODY": "This product is still used in a planned task. At least one soil amendment task is scheduled with “{{name}}”.",
"TITLE": "Unable to remove"
}
},
"INVITATION": {
"BIRTH_YEAR": "Birth year",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type ModalComponentProps = {
title: ReactNode;
titleClassName?: string;
icon?: React.ReactNode;
contents?: string[];
contents?: ReactNode[];
dismissModal: () => void;
buttonGroup?: React.ReactNode;
children?: React.ReactNode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

import ModalComponent from '../ModalComponent/v2';
import { useTranslation, Trans } from 'react-i18next';
import Button from '../../Form/Button';

interface RemoveProductConfirmationModalProps {
dismissModal: () => void;
handleRemove: () => void;
productName?: string;
}

export default function RemoveProductConfirmationModal({
dismissModal,
handleRemove,
productName,
}: RemoveProductConfirmationModalProps) {
const { t } = useTranslation();

return (
<ModalComponent
title={t('INVENTORY.REMOVE_CONFIRMATION.TITLE')}
contents={[
<Trans
i18nKey="INVENTORY.REMOVE_CONFIRMATION.BODY"
values={{ name: productName }}
components={{ strong: <strong /> }}
/>,
]}
dismissModal={dismissModal}
buttonGroup={
<>
<Button onClick={dismissModal} color={'secondary'} type={'button'} sm>
{t('common:CANCEL')}
</Button>
<Button onClick={handleRemove} type={'submit'} sm>
{t('common:REMOVE')}
</Button>
</>
}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

import { useTranslation } from 'react-i18next';
import ModalComponent from '../ModalComponent/v2';

export default function UnableToRemoveProductModal({
dismissModal,
productName,
}: {
dismissModal: () => void;
productName: string;
}) {
const { t } = useTranslation();
return (
<ModalComponent
title={t('INVENTORY.UNABLE_TO_REMOVE.TITLE')}
contents={[t('INVENTORY.UNABLE_TO_REMOVE.BODY', { name: productName })]}
dismissModal={dismissModal}
error
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import { FormProvider, useForm } from 'react-hook-form';
import { TFunction, useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { useSelector } from 'react-redux';
import { isAdminSelector } from '../../userFarmSlice';
import Drawer, { DesktopDrawerVariants } from '../../../components/Drawer';
import InFormButtons from '../../../components/Form/InFormButtons';
import TextButton from '../../../components/Form/Button/TextButton';
Expand All @@ -23,6 +25,9 @@ import { ReactComponent as EditIcon } from '../../../assets/images/edit.svg';
import { ReactComponent as CopyIcon } from '../../../assets/images/copy-01.svg';
import { ReactComponent as TrashIcon } from '../../../assets/images/animals/trash_icon_new.svg';
import useSaveProduct, { type SoilAmendmentProductFormAllFields } from './useSaveProduct';
import useRemoveProduct from './useRemoveProduct';
import RemoveProductConfirmationModal from '../../../components/Modals/RemoveProductConfirmationModal';
import UnableToRemoveProductModal from '../../../components/Modals/UnableToRemoveProductModal';
import { TASK_TYPES } from '../../Task/constants';
import { FormMode } from '..';
import { Product } from '../../../store/api/types';
Expand All @@ -45,6 +50,7 @@ const renderDrawerTitle = (
mode: ProductFormProps['mode'],
onActionButtonClick: ProductFormProps['onActionButtonClick'],
t: TFunction,
isAdmin: boolean,
) => {
if (mode === FormMode.READ_ONLY) {
return (
Expand All @@ -55,9 +61,11 @@ const renderDrawerTitle = (
<TextButton onClick={() => onActionButtonClick(FormMode.DUPLICATE)}>
<CopyIcon />
</TextButton>
<TextButton onClick={() => onActionButtonClick(FormMode.DELETE)}>
<TrashIcon />
</TextButton>
{isAdmin && (
<TextButton onClick={() => onActionButtonClick(FormMode.DELETE)}>
<TrashIcon />
</TextButton>
)}
</div>
);
}
Expand Down Expand Up @@ -92,6 +100,7 @@ export default function ProductForm({
}: ProductFormProps) {
const { t } = useTranslation();
const formMethods = useForm<ProductFormFields>({ mode: 'onBlur' });
const isAdmin = useSelector(isAdminSelector);

const saveProduct = useSaveProduct({ formMode: mode, productFormType });

Expand All @@ -101,39 +110,68 @@ export default function ProductForm({
})();
};

const { onRemove, cancelRemoval, isRemoveModalOpen, isCannotRemoveModalOpen, productName } =
useRemoveProduct({
formMode: mode,
productFormType,
productId,
onRemovalSuccess: onCancel,
onRemovalCancel: () => onActionButtonClick(FormMode.READ_ONLY),
});

const FormContent = productFormType ? productFormMap[productFormType] : null;

return (
<Drawer
isOpen={isFormOpen && !!productFormType && !!mode}
onClose={onCancel}
title={renderDrawerTitle(mode, onActionButtonClick, t)}
addBackdrop={false}
desktopVariant={DesktopDrawerVariants.SIDE_DRAWER}
fullHeight={true}
classes={{
desktopSideDrawerContainer: styles.sideDrawerContainer,
drawerHeader: styles.drawerHeader,
}}
>
<div className={styles.formWrapper}>
{FormContent && (
<FormProvider {...formMethods}>
<FormContent mode={mode} productId={productId} />
</FormProvider>
)}
{mode !== FormMode.READ_ONLY && (
<InFormButtons
className={styles.inFormButtons}
statusText={t('common:EDITING')}
confirmText={t('ADD_PRODUCT.SAVE_PRODUCT')}
onCancel={onCancel}
informationalText={mode === FormMode.EDIT ? t('ADD_PRODUCT.BUTTON_WARNING') : undefined}
isDisabled={!formMethods.formState.isValid}
onConfirm={onSave}
/>
)}
</div>
</Drawer>
<>
<Drawer
isOpen={
Copy link
Collaborator Author

@kathyavini kathyavini Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As always wrapping the return in a parent fragment has annoyingly made the whole return into a diff, but the actual changes within <Drawer /> are the two extra conditionals into isOpen, and the isAdmin argument into renderDrawerTitle

isFormOpen &&
!!productFormType &&
!!mode &&
!isRemoveModalOpen &&
!isCannotRemoveModalOpen
}
onClose={onCancel}
title={renderDrawerTitle(mode, onActionButtonClick, t, isAdmin)}
addBackdrop={false}
desktopVariant={DesktopDrawerVariants.SIDE_DRAWER}
fullHeight={true}
classes={{
desktopSideDrawerContainer: styles.sideDrawerContainer,
drawerHeader: styles.drawerHeader,
}}
>
<div className={styles.formWrapper}>
{FormContent && (
<FormProvider {...formMethods}>
<FormContent mode={mode} productId={productId} />
</FormProvider>
)}
{mode !== FormMode.READ_ONLY && (
<InFormButtons
className={styles.inFormButtons}
statusText={t('common:EDITING')}
confirmText={t('ADD_PRODUCT.SAVE_PRODUCT')}
onCancel={onCancel}
informationalText={
mode === FormMode.EDIT ? t('ADD_PRODUCT.BUTTON_WARNING') : undefined
}
isDisabled={!formMethods.formState.isValid}
onConfirm={onSave}
/>
)}
</div>
</Drawer>
{isRemoveModalOpen && (
<RemoveProductConfirmationModal
dismissModal={cancelRemoval}
handleRemove={onRemove}
productName={productName}
/>
)}
{isCannotRemoveModalOpen && (
<UnableToRemoveProductModal dismissModal={cancelRemoval} productName={productName} />
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { getProducts } from '../../Task/saga';
import { isProductUsedInPlannedTasksSelector, productSelector } from '../../productSlice';
import { enqueueErrorSnackbar, enqueueSuccessSnackbar } from '../../Snackbar/snackbarSlice';
import { useDeleteSoilAmendmentProductMutation } from '../../../store/api/apiSlice';
import { TASK_TYPES } from '../../Task/constants';
import type { Product } from '../../../store/api/types';
import { FormMode } from '..';

interface useRemoveProductProps {
productFormType: Product['type'] | null;
formMode: FormMode | null;
productId?: Product['product_id'];
onRemovalSuccess: () => void;
onRemovalCancel: () => void;
}

const useRemoveProduct = ({
productFormType,
formMode,
productId,
onRemovalSuccess,
onRemovalCancel,
}: useRemoveProductProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const [isRemoveModalOpen, setisRemoveModalOpen] = useState(false);
const [isCannotRemoveModalOpen, setisCannotRemoveModalOpen] = useState(false);

const product = useSelector(productSelector(productId));
const productName = product?.name;

const isProductInUse = useSelector(isProductUsedInPlannedTasksSelector(productId));

const [deleteSoilAmendmentProduct] = useDeleteSoilAmendmentProductMutation();

useEffect(() => {
if (formMode === FormMode.DELETE && productId) {
if (isProductInUse) {
setisCannotRemoveModalOpen(true);
} else {
setisRemoveModalOpen(true);
}
}
}, [formMode, productId, isProductInUse]);

const onRemove = async () => {
if (!formMode || !productFormType || !productId) {
return;
}

let apiCall = null;

if (productFormType === TASK_TYPES.SOIL_AMENDMENT) {
apiCall = deleteSoilAmendmentProduct;
} else {
throw new Error(`Unsupported product type: ${productFormType}`);
}

try {
await apiCall(productId).unwrap();
} catch (e) {
console.error(e);
dispatch(enqueueErrorSnackbar(t('message:PRODUCT.ERROR.REMOVE')));
setisRemoveModalOpen(false);
return;
}

const onProductsFetched = () => {
dispatch(enqueueSuccessSnackbar(t('message:PRODUCT.SUCCESS.REMOVE')));
setisRemoveModalOpen(false);
onRemovalSuccess();
};

dispatch(getProducts({ callback: onProductsFetched }));
};

return {
isRemoveModalOpen,
isCannotRemoveModalOpen,
onRemove,
cancelRemoval: () => {
setisRemoveModalOpen(false);
setisCannotRemoveModalOpen(false);
onRemovalCancel();
},
productName,
};
};

export default useRemoveProduct;
Loading