Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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, { ModalType } 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,61 @@ export default function ProductForm({
})();
};

const { onRemove, cancelRemoval, modalType, 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={isFormOpen && !!productFormType && !!mode && modalType === ModalType.NONE}
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>
{modalType === ModalType.CONFIRM && (
<RemoveProductConfirmationModal
dismissModal={cancelRemoval}
handleRemove={onRemove}
productName={productName}
/>
)}
{modalType === ModalType.CANNOT_REMOVE && (
<UnableToRemoveProductModal dismissModal={cancelRemoval} productName={productName} />
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* 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;
}

export enum ModalType {
NONE = 'none',
CONFIRM = 'confirm',
CANNOT_REMOVE = 'cannotRemove',
}

const useRemoveProduct = ({
productFormType,
formMode,
productId,
onRemovalSuccess,
onRemovalCancel,
}: useRemoveProductProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();

const [modalType, setModalType] = useState<ModalType>(ModalType.NONE);

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) {
setModalType(ModalType.CANNOT_REMOVE);
} else {
setModalType(ModalType.CONFIRM);
}
}
}, [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();

dispatch(enqueueSuccessSnackbar(t('message:PRODUCT.SUCCESS.REMOVE')));
setModalType(ModalType.NONE);
onRemovalSuccess();

dispatch(getProducts());
} catch (e) {
console.error(e);
dispatch(enqueueErrorSnackbar(t('message:PRODUCT.ERROR.REMOVE')));
setModalType(ModalType.NONE);
return;
}
};

return {
modalType,
onRemove,
cancelRemoval: () => {
setModalType(ModalType.NONE);
onRemovalCancel();
},
productName,
};
};

export default useRemoveProduct;
Loading