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';