From 53e5406f166a8e4f5e3a4bd60801125495a06adc Mon Sep 17 00:00:00 2001 From: Ochieng Paul Date: Sun, 1 Dec 2024 23:45:20 +0300 Subject: [PATCH 1/2] updates --- .../common/components/Button/TabButtons.jsx | 50 ++++- .../components/Charts/components/index.jsx | 17 +- .../dataDownload/components/LocationCard.jsx | 41 +++- .../dataDownload/modules/MoreInsights.jsx | 204 ++++++++++++++---- 4 files changed, 252 insertions(+), 60 deletions(-) diff --git a/platform/src/common/components/Button/TabButtons.jsx b/platform/src/common/components/Button/TabButtons.jsx index 82a9b540ae..e36d09d0da 100644 --- a/platform/src/common/components/Button/TabButtons.jsx +++ b/platform/src/common/components/Button/TabButtons.jsx @@ -1,7 +1,17 @@ import React from 'react'; import ChevronDownIcon from '@/icons/Common/chevron_downIcon'; import PropTypes from 'prop-types'; +import Spinner from '../Spinner'; +/** + * TabButtons Component + * + * A versatile button component that can display icons, text, and handle dropdowns. + * It also supports loading and disabled states. + * + * @param {object} props - Component props + * @returns {JSX.Element} Rendered component + */ const TabButtons = ({ type = 'button', Icon, @@ -12,6 +22,8 @@ const TabButtons = ({ id, tabRef, isField = true, + isLoading = false, + disabled = false, }) => { return ( ); @@ -40,14 +69,19 @@ const TabButtons = ({ TabButtons.propTypes = { type: PropTypes.string, - Icon: PropTypes.func, + Icon: PropTypes.oneOfType([PropTypes.func, PropTypes.element]), btnText: PropTypes.string, isField: PropTypes.bool, btnStyle: PropTypes.string, dropdown: PropTypes.bool, onClick: PropTypes.func, id: PropTypes.string, - tabRef: PropTypes.object, + tabRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.any }), + ]), + isLoading: PropTypes.bool, + disabled: PropTypes.bool, }; export default TabButtons; diff --git a/platform/src/common/components/Charts/components/index.jsx b/platform/src/common/components/Charts/components/index.jsx index 74b9917831..090caf4325 100644 --- a/platform/src/common/components/Charts/components/index.jsx +++ b/platform/src/common/components/Charts/components/index.jsx @@ -250,16 +250,13 @@ const renderCustomizedLegend = ({ payload }) => { {/* Only truncate and add tooltip if shouldTruncate is true */} - -
- {entry.value} -
-
+ {shouldTruncate ? ( + +
{entry.value}
+
+ ) : ( +
{entry.value}
+ )} ))} diff --git a/platform/src/common/components/Modal/dataDownload/components/LocationCard.jsx b/platform/src/common/components/Modal/dataDownload/components/LocationCard.jsx index 4e0bc94ae9..603bc149f5 100644 --- a/platform/src/common/components/Modal/dataDownload/components/LocationCard.jsx +++ b/platform/src/common/components/Modal/dataDownload/components/LocationCard.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; import LocationIcon from '@/icons/Analytics/LocationIcon'; // Helper function to handle truncation logic @@ -11,7 +12,13 @@ const truncateName = (name, maxLength = 13) => { * LocationCard Component * Displays information about a location with a checkbox to toggle selection. */ -const LocationCard = ({ site, onToggle, isSelected, isLoading }) => { +const LocationCard = ({ + site, + onToggle, + isSelected, + isLoading, + disableToggle, +}) => { // Display loading skeleton while loading if (isLoading) { return ( @@ -29,13 +36,18 @@ const LocationCard = ({ site, onToggle, isSelected, isLoading }) => { const { name, search_name, country } = site; // Determine display name (search_name or fallback to name) - const displayName = truncateName(name || search_name?.split(',')[0] || ''); + const displayName = truncateName( + name || (search_name && search_name.split(',')[0]) || '', + ); return (
{ checked={isSelected} onChange={(e) => { e.stopPropagation(); - onToggle(site); + if (!disableToggle) { + onToggle(site); + } }} className="w-4 h-4 text-blue-600 bg-white cursor-pointer border-blue-300 rounded focus:ring-blue-500" aria-label={`Select ${displayName}`} + disabled={disableToggle} />
); }; +LocationCard.propTypes = { + site: PropTypes.shape({ + _id: PropTypes.string.isRequired, + name: PropTypes.string, + search_name: PropTypes.string, + country: PropTypes.string, + }).isRequired, + onToggle: PropTypes.func.isRequired, + isSelected: PropTypes.bool.isRequired, + isLoading: PropTypes.bool, + disableToggle: PropTypes.bool, // New prop to disable toggle when needed +}; + +LocationCard.defaultProps = { + isLoading: false, + disableToggle: false, +}; + export default LocationCard; diff --git a/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx b/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx index a9dae73efd..4302978e77 100644 --- a/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx +++ b/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx @@ -1,17 +1,20 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; -// import DownloadIcon from '@/icons/Analytics/downloadIcon'; +import DownloadIcon from '@/icons/Analytics/downloadIcon'; import MoreInsightsChart from '@/components/Charts/MoreInsightsChart'; import CustomCalendar from '@/components/Calendar/CustomCalendar'; import CheckIcon from '@/icons/tickIcon'; -// import TabButtons from '@/components/Button/TabButtons'; +import TabButtons from '@/components/Button/TabButtons'; import CustomDropdown from '@/components/Dropdowns/CustomDropdown'; import { TIME_OPTIONS, CHART_TYPE } from '@/lib/constants'; -// import AirQualityCard from '../components/AirQualityCard'; +import { exportDataApi } from '@/core/apis/Analytics'; +import AirQualityCard from '../components/AirQualityCard'; import LocationCard from '../components/LocationCard'; import LocationIcon from '@/icons/Analytics/LocationIcon'; // import { setOpenModal, setModalType } from '@/lib/store/services/downloadModal'; import { subDays } from 'date-fns'; +import { saveAs } from 'file-saver'; +import CustomToast from '../../../Toast/CustomToast'; import useFetchAnalyticsData from '@/core/utils/useFetchAnalyticsData'; import formatDateRangeToISO from '@/core/utils/formatDateRangeToISO'; import SkeletonLoader from '@/components/Charts/components/SkeletonLoader'; @@ -33,16 +36,37 @@ const InSightsHeader = () => ( */ const MoreInsights = () => { // const dispatch = useDispatch(); - const { data: modalData } = useSelector((state) => state.modal.modalType); + + /** + * Selectors to retrieve data from Redux store. + * Assumption: state.modal.modalType.data contains the list of all available sites. + */ + const modalData = useSelector((state) => state.modal.modalType?.data); const chartData = useSelector((state) => state.chart); - // Ensure modalData is always an array for consistency - const selectedSites = useMemo(() => { - if (!modalData) return []; - return Array.isArray(modalData) ? modalData : [modalData]; + // Local state for download functionality + const [downloadLoading, setDownloadLoading] = useState(false); + const [downloadError, setDownloadError] = useState(null); + + /** + * Ensure `allSites` is an array. + * If `modalData` is undefined, null, or not an array, default to an empty array. + */ + const allSites = useMemo(() => { + if (Array.isArray(modalData)) return modalData; + if (modalData) return [modalData]; + return []; }, [modalData]); - // Extract site IDs from selected sites for API requests + /** + * Local state to manage selected sites. + * Initializes with all sites selected by default. + */ + const [selectedSites, setSelectedSites] = useState(() => [...allSites]); + + /** + * Extract site IDs from selected sites for API requests. + */ const selectedSiteIds = useMemo( () => selectedSites.map((site) => site._id), [selectedSites], @@ -52,7 +76,9 @@ const MoreInsights = () => { const [frequency, setFrequency] = useState('daily'); const [chartType, setChartType] = useState('line'); - // Initialize date range to last 7 days + /** + * Initialize date range to last 7 days. + */ const initialDateRange = useMemo(() => { const { startDateISO, endDateISO } = formatDateRangeToISO( subDays(new Date(), 7), @@ -67,7 +93,10 @@ const MoreInsights = () => { const [dateRange, setDateRange] = useState(initialDateRange); - // Fetch analytics data using custom hook + /** + * Fetch analytics data using custom hook. + * Dependencies: selectedSiteIds, dateRange, chartType, frequency, pollutant, organisationName + */ const { allSiteData, chartLoading, error, refetch } = useFetchAnalyticsData({ selectedSiteIds, dateRange, @@ -88,7 +117,50 @@ const MoreInsights = () => { }, [refetch]); /** - * Effect to manage SkeletonLoader visibility with delay + * Handles toggling of site selection. + * Adds or removes a site from the selectedSites state. + * + * @param {string} siteId - The ID of the site to toggle. + */ + const toggleSiteSelection = useCallback( + (siteId) => { + setSelectedSites((prevSelected) => { + const isSelected = prevSelected.some((site) => site._id === siteId); + if (isSelected) { + return prevSelected.filter((site) => site._id !== siteId); + } else { + const siteToAdd = allSites.find((site) => site._id === siteId); + if (siteToAdd) { + return [...prevSelected, siteToAdd]; + } + return prevSelected; + } + }); + }, + [allSites], + ); + + /** + * Effect to refetch data when selectedSites changes. + */ + useEffect(() => { + handleParameterChange(); + }, [selectedSites, handleParameterChange]); + + /** + * Hide download error after 5 seconds. + */ + useEffect(() => { + if (downloadError) { + const timer = setTimeout(() => { + setDownloadError(null); + }, 5000); + return () => clearTimeout(timer); + } + }, [downloadError]); + + /** + * Effect to manage SkeletonLoader visibility with delay. */ useEffect(() => { let timer; @@ -97,7 +169,7 @@ const MoreInsights = () => { } else { timer = setTimeout(() => { setShowSkeleton(false); - }, 8000); + }, 4000); } return () => { @@ -106,31 +178,84 @@ const MoreInsights = () => { }, [chartLoading]); /** - * Generates the content for the selected sites in the sidebar. - * Displays selected sites or a message when no sites are selected. + * Generates the content for all sites in the sidebar. + * Displays sites with their selection status. */ - const selectedSitesContent = useMemo(() => { - if (selectedSites.length === 0) { + const allSitesContent = useMemo(() => { + if (!Array.isArray(allSites) || allSites.length === 0) { return (
- No locations selected + No locations available
); } - return selectedSites.map((site) => ( + return allSites.map((site) => ( {}} - isSelected={true} + onToggle={() => toggleSiteSelection(site._id)} + isSelected={selectedSites.some((s) => s._id === site._id)} isLoading={false} + disableToggle={selectedSites.length === 1} /> )); - }, [selectedSites]); + }, [allSites, selectedSites, toggleSiteSelection]); + + /** + * Handles data download with default CSV format. + */ + const handleDataDownload = async () => { + setDownloadLoading(true); + try { + const { startDate, endDate } = dateRange; + + // Define MIME type and file name for CSV + const mimeType = 'text/csv;charset=utf-8;'; + const fileName = `analytics_data_${new Date().toISOString()}.csv`; + + // Prepare API request data with CSV as the default format + const apiData = { + startDateTime: startDate, + endDateTime: endDate, + sites: selectedSiteIds, + network: chartData.organisationName, // Ensure consistent spelling + pollutants: [chartData.pollutionType], + frequency: frequency, + datatype: 'raw', + downloadType: 'csv', + outputFormat: 'csv', // Set default format to CSV + minimum: true, + }; + const response = await exportDataApi(apiData); + + // Validate response data + if (typeof response.data !== 'string') { + throw new Error('Invalid CSV data format.'); + } + + // Create a Blob from the CSV data + const blob = new Blob([response.data], { type: mimeType }); + + // Trigger file download + saveAs(blob, fileName); + + setDownloadLoading(false); + + // Show success toast + CustomToast(); + } catch (error) { + console.error(error); + // Set a user-friendly error message + setDownloadError( + 'There was an error downloading the data. Please try again later.', + ); + setDownloadLoading(false); + } + }; /** * Handles opening the modal with the specified type, ids, and data. @@ -146,9 +271,9 @@ const MoreInsights = () => { return ( <> - {/* -------------------- Sidebar for Selected Sites -------------------- */} + {/* -------------------- Sidebar for Sites -------------------- */}
- {selectedSitesContent} + {allSitesContent} {/* Add Location Button */} {/* {!chartLoading && (
+ {/* -------------------- Download Error -------------------- */} + {downloadError && ( +
+

{downloadError}

+
+ )} + {/* -------------------- Chart Display -------------------- */}
{error ? ( @@ -286,12 +414,12 @@ const MoreInsights = () => {
{/* -------------------- Air Quality Card -------------------- */} - {/* */} + /> From d760033b32868923006f686dcd33d8404aec577b Mon Sep 17 00:00:00 2001 From: Ochieng Paul Date: Mon, 2 Dec 2024 00:00:16 +0300 Subject: [PATCH 2/2] updates --- .../components/Modal/dataDownload/modules/MoreInsights.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx b/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx index 4302978e77..31a7678081 100644 --- a/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx +++ b/platform/src/common/components/Modal/dataDownload/modules/MoreInsights.jsx @@ -222,12 +222,12 @@ const MoreInsights = () => { startDateTime: startDate, endDateTime: endDate, sites: selectedSiteIds, - network: chartData.organisationName, // Ensure consistent spelling + network: chartData.organizationName, pollutants: [chartData.pollutionType], frequency: frequency, datatype: 'raw', downloadType: 'csv', - outputFormat: 'csv', // Set default format to CSV + outputFormat: 'airqo-standard', minimum: true, }; const response = await exportDataApi(apiData);