diff --git a/packages/webapp/public/locales/en/common.json b/packages/webapp/public/locales/en/common.json index a6dd4879cf..c0f2fc2963 100644 --- a/packages/webapp/public/locales/en/common.json +++ b/packages/webapp/public/locales/en/common.json @@ -39,6 +39,7 @@ "EDITING": "Editing...", "ENTER_VALUE": "Enter value", "EXPORT": "Export", + "FETCHING_YOUR_DATA": "Sit back, we’re fetching your {{dataName}} data", "FINISH": "Finish", "FROM": "from", "GO_BACK": "Go back", diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json index 9378779763..8c45b6925a 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -1782,6 +1782,7 @@ "DAYS_AGO": "{{time}} day(s) ago", "DEPTH": "Depth", "DETAIL": { + "ADD_SENSORS": "Add sensors", "BRAND": "Brand", "BRAND_TOOLTIP": "Brands that LiteFarm can integrate with are shown below. If you would no longer like to use this sensor brand, try retiring this sensor instead.", "DEPTH": "Depth", diff --git a/packages/webapp/src/App.jsx b/packages/webapp/src/App.jsx index 425bb01112..99a3375360 100644 --- a/packages/webapp/src/App.jsx +++ b/packages/webapp/src/App.jsx @@ -24,12 +24,12 @@ import { NotistackSnackbar } from './containers/Snackbar/NotistackSnackbar'; import { OfflineDetector } from './containers/hooks/useOfflineDetector/OfflineDetector'; import styles from './styles.module.scss'; import Routes from './routes'; -import { ANIMALS_URL } from './util/siteMapConstants'; +import { ANIMALS_URL, MAP_URL } from './util/siteMapConstants'; function App() { const [isCompactSideMenu, setIsCompactSideMenu] = useState(false); const [isFeedbackSurveyOpen, setFeedbackSurveyOpen] = useState(false); - const FULL_WIDTH_ROUTES = ['/map', ANIMALS_URL]; + const FULL_WIDTH_ROUTES = [MAP_URL, ANIMALS_URL]; const isFullWidth = FULL_WIDTH_ROUTES.some((path) => matchPath(history.location.pathname, path)); return ( diff --git a/packages/webapp/src/components/Form/ContextForm/HeaderWithBackAndClose.tsx b/packages/webapp/src/components/Form/ContextForm/HeaderWithBackAndClose.tsx new file mode 100644 index 0000000000..cfef89dfc5 --- /dev/null +++ b/packages/webapp/src/components/Form/ContextForm/HeaderWithBackAndClose.tsx @@ -0,0 +1,50 @@ +/* + * 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 { ReactNode } from 'react'; +import Icon from '../../Icons'; +import TextButton from '../Button/TextButton'; +import { ReactComponent as XIcon } from '../../../assets/images/x-icon.svg'; +import styles from './styles.module.scss'; + +interface HeaderWithBackAndCloseProps { + title: ReactNode; + onCancel?: () => void; + onGoBack?: () => void; +} + +const HeaderWithBackAndClose = ({ title, onCancel, onGoBack }: HeaderWithBackAndCloseProps) => { + return ( +
+
+
+ {onGoBack && ( + + + + )} +
{title}
+
+ {onCancel && ( + + + + )} +
+
+ ); +}; + +export default HeaderWithBackAndClose; diff --git a/packages/webapp/src/components/Form/ContextForm/Loading.tsx b/packages/webapp/src/components/Form/ContextForm/Loading.tsx new file mode 100644 index 0000000000..e1eb3e95af --- /dev/null +++ b/packages/webapp/src/components/Form/ContextForm/Loading.tsx @@ -0,0 +1,38 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import Spinner from '../../Spinner'; +import styles from './styles.module.scss'; + +interface LoadingProps { + dataName?: string; +} + +const Loading = ({ dataName = '' }: LoadingProps) => { + const { t } = useTranslation(['translation', 'common']); + + return ( +
+
+ +
+
{t('common:LOADING')}
+
{t('common:FETCHING_YOUR_DATA', { dataName })}
+
+ ); +}; + +export default Loading; diff --git a/packages/webapp/src/components/Form/ContextForm/WithStepperProgressBar.tsx b/packages/webapp/src/components/Form/ContextForm/WithStepperProgressBar.tsx index 14aeefc977..34f4088362 100644 --- a/packages/webapp/src/components/Form/ContextForm/WithStepperProgressBar.tsx +++ b/packages/webapp/src/components/Form/ContextForm/WithStepperProgressBar.tsx @@ -27,12 +27,18 @@ import FloatingContainer from '../../FloatingContainer'; import FormNavigationButtons from '../FormNavigationButtons'; import FixedHeaderContainer from '../../Animals/FixedHeaderContainer'; import CancelFlowModal from '../../Modals/CancelFlowModal'; +import Loading from './Loading'; import styles from './styles.module.scss'; interface WithStepperProgressBarProps { children: ReactNode; history: History; - steps: { formContent: ReactNode; title: string }[]; + steps: { + formContent: ReactNode; + title: string; + onContinueAction?: (values: any) => Promise; + dataName?: string; + }[]; activeStepIndex: number; cancelModalTitle: string; isCompactSideMenu: boolean; @@ -59,6 +65,7 @@ interface WithStepperProgressBarProps { setIsEditing?: React.Dispatch>; showCancelFlow?: boolean; setShowCancelFlow?: React.Dispatch>; + headerComponent?: ((props: HeaderProps) => JSX.Element) | null; } export const WithStepperProgressBar = ({ @@ -84,12 +91,14 @@ export const WithStepperProgressBar = ({ setIsEditing, showCancelFlow, setShowCancelFlow, + headerComponent = StepperProgressBar, }: WithStepperProgressBarProps) => { const [transition, setTransition] = useState<{ unblock?: () => void; retry?: () => void }>({ unblock: undefined, retry: undefined, }); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const isSummaryPage = hasSummaryWithinForm && activeStepIndex === steps.length - 1; const isSingleStep = steps.length === 1; @@ -107,6 +116,13 @@ export const WithStepperProgressBar = ({ return () => unblock(); }, [isSummaryPage, isDirty, history]); + useEffect(() => { + // Reset loading state whenever the step changes + if (isLoading) { + setIsLoading(false); + } + }, [activeStepIndex]); + const isFinalStep = (!hasSummaryWithinForm && activeStepIndex === steps.length - 1) || (hasSummaryWithinForm && activeStepIndex === steps.length - 2); @@ -123,6 +139,20 @@ export const WithStepperProgressBar = ({ }; const onContinue = async () => { + const { onContinueAction } = steps[activeStepIndex]; + + if (onContinueAction) { + setIsLoading(true); + try { + // Execute the custom action for the current step before proceeding to the next one + await onContinueAction(getValues()); + } catch (error) { + console.error(error); + setIsLoading(false); + return; + } + } + if (isFinalStep) { setIsSaving(true); await handleSubmit((data: FieldValues) => onSave(data, onSuccess, setFormResultData))(); @@ -149,6 +179,10 @@ export const WithStepperProgressBar = ({ setShowCancelFlow?.(false); }; + if (isLoading) { + return ; + } + return ( title)} activeStep={activeStepIndex} + onGoBack={onGoBack} + onCancel={onCancel} + headerComponent={headerComponent} >
{children}
{shouldShowFormNavigationButtons && ( @@ -181,14 +218,21 @@ export const WithStepperProgressBar = ({ ); }; -type StepperProgressBarWrapperProps = StepperProgressBarProps & { +type HeaderProps = StepperProgressBarProps & { + onGoBack: () => void; + onCancel: () => void; +}; + +type StepperProgressBarWrapperProps = HeaderProps & { children: ReactNode; isSingleStep: boolean; + headerComponent: ((props: HeaderProps) => JSX.Element) | null; }; const StepperProgressBarWrapper = ({ children, isSingleStep, + headerComponent, ...stepperProgressBarProps }: StepperProgressBarWrapperProps) => { if (isSingleStep) { @@ -196,7 +240,7 @@ const StepperProgressBarWrapper = ({ } return ( - }> + {children} ); diff --git a/packages/webapp/src/components/Form/ContextForm/styles.module.scss b/packages/webapp/src/components/Form/ContextForm/styles.module.scss index 15d2f2bfef..0acf48f762 100644 --- a/packages/webapp/src/components/Form/ContextForm/styles.module.scss +++ b/packages/webapp/src/components/Form/ContextForm/styles.module.scss @@ -22,3 +22,83 @@ padding-bottom: 104px; // button height 72px + button top 16px + button bottom 16px } } + +.loadingScreen { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.loadingText { + color: #000; + font-size: 16px; + font-weight: 700; + line-height: 24px; + margin-bottom: 16px; +} + +.loadingMessage { + color: #000; + font-size: 16px; + line-height: 24px; +} + +/*---------------------------------------- + HeaderWithBackAndClose +----------------------------------------*/ +.headerWrapper { + width: 100%; + border-bottom: 1px solid var(--Colors-Neutral-Neutral-50); + box-shadow: 0px 1px 0px 0px rgba(0, 0, 0, 0.05); + + .container { + max-width: 1024px; + margin: 0 auto; + display: flex; + justify-content: space-between; + padding: 16px 8px 12px 8px; + } + + .leftContainer { + display: flex; + align-items: center; + gap: 8px; + } + + .backButton { + padding: 0; + min-width: 24px; + min-height: 24px; + background-color: transparent; + + svg { + width: 24px; + height: 24px; + } + + path { + stroke: var(--Colors-Neutral-Neutral-600); + stroke-width: 2px; + } + } + + .textTitle { + font-size: 16px; + letter-spacing: -0.352px; + } + + .closeButtonContainer { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + } + + .closeButton { + width: 12px; + height: 12px; + } +} diff --git a/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/PostSensor.tsx b/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/PostSensor.tsx new file mode 100644 index 0000000000..3618dd8258 --- /dev/null +++ b/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/PostSensor.tsx @@ -0,0 +1,82 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { History } from 'history'; +import { ContextForm, Variant } from '../../../../../components/Form/ContextForm'; +import PageTitle from '../../../../../components/PageTitle/v2'; +import { enqueueErrorSnackbar } from '../../../../Snackbar/snackbarSlice'; +import styles from './styles.module.scss'; + +interface PostSensorProps { + history: History; +} + +const PostSensor = ({ history }: PostSensorProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const linkOrganizationId = async () => { + // TODO: POST /farm_addon + // When failed: snackbar + + // Simulating the API call + return new Promise((resolve, reject) => { + setTimeout(() => { + // Successful + resolve(); + + // Failed + // reject(dispatch(enqueueErrorSnackbar('TODO: Error message'))); + }, 1500); + }); + }; + + const onSave = async (data: any, onSuccess: () => void) => { + // TODO: GET devices with useLazyQuery. + // Once the data is returned, call onSuccess to navigate to the next view. + + onSuccess(); + }; + + const getFormSteps = () => [ + { + FormContent: () =>
Partner selection view
, + onContinueAction: linkOrganizationId, + }, + { FormContent: () =>
ESCI devices view
}, + ]; + + const defaultFormValues = { + partner: { integrating_partner_id: 1, organization_uuid: '' }, + }; + + return ( +
+ +
+ ); +}; + +export default PostSensor; diff --git a/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/styles.module.scss b/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/styles.module.scss new file mode 100644 index 0000000000..aa95ee5cc4 --- /dev/null +++ b/packages/webapp/src/containers/LocationDetails/PointDetails/SensorDetail/v2/styles.module.scss @@ -0,0 +1,20 @@ +/* + * 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 . + */ + +.formWrapper { + height: 100%; + background-color: var(--White); + padding: 24px; +} diff --git a/packages/webapp/src/routes/index.jsx b/packages/webapp/src/routes/index.jsx index 0e590b83ae..7df29c0c11 100644 --- a/packages/webapp/src/routes/index.jsx +++ b/packages/webapp/src/routes/index.jsx @@ -131,6 +131,9 @@ const PostWatercourseForm = React.lazy(() => import('../containers/LocationDetails/LineDetails/WatercourseDetailForm/PostWatercourse'), ); const WatercourseDetails = React.lazy(() => import('./WatercourseDetailsRoutes')); +const PostSensorForm = React.lazy(() => + import('../containers/LocationDetails/PointDetails/SensorDetail/v2/PostSensor'), +); const SensorDetails = React.lazy(() => import('./SensorDetailsRoutes')); const CropCatalogue = React.lazy(() => import('../containers/CropCatalogue')); @@ -533,6 +536,7 @@ const Routes = ({ isCompactSideMenu, isFeedbackSurveyOpen, setFeedbackSurveyOpen + @@ -817,6 +821,7 @@ const Routes = ({ isCompactSideMenu, isFeedbackSurveyOpen, setFeedbackSurveyOpen + diff --git a/packages/webapp/src/stories/ContextForm/ContextForm.stories.tsx b/packages/webapp/src/stories/ContextForm/ContextForm.stories.tsx index 783f59d200..34739f03c0 100644 --- a/packages/webapp/src/stories/ContextForm/ContextForm.stories.tsx +++ b/packages/webapp/src/stories/ContextForm/ContextForm.stories.tsx @@ -16,6 +16,8 @@ import { Meta, StoryObj } from '@storybook/react'; import { componentDecorators } from '../Pages/config/Decorators'; import { ContextForm, Variant } from '../../components/Form/ContextForm'; +import MeatballsMenu from '../../components/Menu/MeatballsMenu'; +import PageTitleHeader from '../../components/PageTitle/v2'; // https://storybook.js.org/docs/writing-stories/typescript const meta: Meta = { @@ -97,3 +99,77 @@ export const StepperFormWithSummaryPage: Story = { ], }, }; + +const asyncFunc = async (status: 'success' | 'fail') => { + console.log(`Simulating ${status === 'success' ? 'successful' : 'failed'} API call...`); + + return new Promise((resolve, reject) => { + setTimeout(() => { + if (status === 'success') { + resolve(); + } else { + reject(new Error('ERROR')); + } + }, 1500); + }); +}; + +export const StepperFormWithCustomActionOnContinue: Story = { + args: { + ...stepperFormCommonProps, + hasSummaryWithinForm: true, + getSteps: () => [ + { + title: 'Page 1', + FormContent: () =>
Page 1
, + onContinueAction: () => asyncFunc('success'), + dataName: 'sensor', + }, + { + title: 'Page 2', + FormContent: () =>
Page 2
, + onContinueAction: () => asyncFunc('fail'), + }, + { + title: 'Done', + FormContent: () =>
Summary
, + }, + ], + }, +}; + +export const StepperFormWithPageTitleHeader: Story = { + args: { + ...stepperFormCommonProps, + variant: Variant.STEPPER_PROGRESS_BAR, + stepperProgressBarTitle: 'Page title header', + headerComponent: PageTitleHeader, + }, +}; + +export const StepperFormWithCustomHeader: Story = { + args: { + ...stepperFormCommonProps, + variant: Variant.STEPPER_PROGRESS_BAR, + headerComponent: () => ( +
+ Custom Header + console.log('Menu 1') }, + { label: 'Menu 2', onClick: () => console.log('Menu 2') }, + ]} + disabled={false} + /> +
+ ), + }, +}; + +export const StepperFormWithoutHeader: Story = { + args: { + ...stepperFormCommonProps, + variant: Variant.STEPPER_PROGRESS_BAR, + headerComponent: null, + }, +}; diff --git a/packages/webapp/src/util/siteMapConstants.ts b/packages/webapp/src/util/siteMapConstants.ts index b971249249..20dd5d424e 100644 --- a/packages/webapp/src/util/siteMapConstants.ts +++ b/packages/webapp/src/util/siteMapConstants.ts @@ -88,3 +88,7 @@ export const createCompleteHarvestQuantityTaskUrl = (id: string | number): strin export const createCompleteTaskUrl = (id: string | number, hasAnimals: boolean): string => { return hasAnimals ? `/tasks/${id}/before_complete` : `/tasks/${id}/complete`; }; + +// Maps +export const MAP_URL = '/map'; +export const POST_SENSOR_URL = '/create_location/sensor';