diff --git a/src/platform/src/common/components/Calendar/Calendar.jsx b/src/platform/src/common/components/Calendar/Calendar.jsx index 093a48caa6..c4e9dc63fc 100644 --- a/src/platform/src/common/components/Calendar/Calendar.jsx +++ b/src/platform/src/common/components/Calendar/Calendar.jsx @@ -196,7 +196,7 @@ const Calendar = ({ const CalendarSectionComponent = ({ month, onNextMonth, onPrevMonth }) => (
{ +const DatePicker = ({ + customPopperStyle = {}, + alignment = 'left', + onChange, +}) => { const [isOpen, setIsOpen] = useState(false); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const [selectedDate, setSelectedDate] = useState({ start: null, end: null }); const popperRef = useRef(null); + // Configure react-popper const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: alignment === 'right' ? 'bottom-end' : 'bottom-start', modifiers: [ @@ -38,41 +45,64 @@ const DatePicker = ({ customPopperStyle, alignment, onChange }) => { }, { name: 'computeStyles', options: { adaptive: false } }, { name: 'eventListeners', options: { scroll: true, resize: true } }, - { name: 'arrow', options: { padding: 8 } }, + { + name: 'arrow', + options: { + element: arrowElement, // attach arrow element + padding: 8, + }, + }, ], }); - const handleToggle = () => { + /** + * Toggles the calendar's open/close state. + */ + const toggleOpen = useCallback(() => { setIsOpen((prev) => !prev); - }; + }, []); - const handleValueChange = (newValue) => { - setSelectedDate(newValue); - onChange(newValue); - }; + /** + * Called whenever the user selects a date range in the Calendar. + */ + const handleValueChange = useCallback( + (newValue) => { + setSelectedDate(newValue); + onChange?.(newValue); + }, + [onChange], + ); - const handleClickOutside = (event) => { - if ( - popperRef.current && - !popperRef.current.contains(event.target) && - !referenceElement.contains(event.target) - ) { - setIsOpen(false); - } - }; + /** + * Closes the popper when clicking outside of it. + */ + const handleClickOutside = useCallback( + (event) => { + if ( + popperRef.current && + !popperRef.current.contains(event.target) && + referenceElement && + !referenceElement.contains(event.target) + ) { + setIsOpen(false); + } + }, + [referenceElement], + ); + // Attach/detach outside click handler useEffect(() => { if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } else { document.removeEventListener('mousedown', handleClickOutside); } - return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isOpen, referenceElement]); + }, [isOpen, handleClickOutside]); + // Format the selected date range for display const formattedStartDate = selectedDate.start ? format(selectedDate.start, 'MMM d, yyyy') : ''; @@ -86,12 +116,13 @@ const DatePicker = ({ customPopperStyle, alignment, onChange }) => { return (
+ {/* The button that toggles the calendar */} } btnText={btnText} tabButtonClass="w-full" dropdown - onClick={handleToggle} + onClick={toggleOpen} id="datePicker" type="button" btnStyle="w-full bg-white border-gray-750 px-4 py-2" @@ -99,6 +130,8 @@ const DatePicker = ({ customPopperStyle, alignment, onChange }) => { aria-haspopup="dialog" aria-expanded={isOpen} /> + + {/* Transition for the popper (calendar container) */} { leave="ease-in duration-200" leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" - className="absolute z-50" >
{ @@ -116,7 +148,24 @@ const DatePicker = ({ customPopperStyle, alignment, onChange }) => { }} style={{ ...styles.popper, ...customPopperStyle }} {...attributes.popper} + className="z-50" > + {/* The arrow element for popper */} +
+ {/* Calendar container with reduced height */} + { if (typeof name !== 'string' || !name) return name; @@ -13,11 +13,6 @@ const formatName = (name, textFormat = 'lowercase') => { return textFormat === 'uppercase' ? formatted.toUpperCase() : formatted; }; -/** - * Defines the rules for formatting the field value based on the field id. - * Removes hyphens and formats in uppercase for display - * Retains hyphens in the stored value - */ const FIELD_FORMAT_RULES = { organization: { display: (value) => { @@ -50,7 +45,7 @@ const formatFieldValue = (value, fieldId, textFormat, display = false) => { /** * CustomFields Component - * Renders different types of input fields based on props. + * Renders different types of input fields based on the props. */ const CustomFields = ({ field = false, @@ -65,13 +60,10 @@ const CustomFields = ({ defaultOption, textFormat = 'lowercase', }) => { - const [selectedOption, setSelectedOption] = useState( - defaultOption || (options.length > 0 ? options[0] : { id: '', name: '' }), - ); + const initialOption = + defaultOption || (options.length > 0 ? options[0] : { id: '', name: '' }); + const [selectedOption, setSelectedOption] = useState(initialOption); - /** - * Handles the selection of an option. - */ const handleSelect = useCallback( (option) => { const formattedOption = { @@ -85,26 +77,23 @@ const CustomFields = ({ ); return ( -
- - +
+ {field ? ( handleSelect({ ...selectedOption, name: e.target.value }) } - type="text" - name={id} disabled={!edit} /> ) : useCalendar ? ( { - handleSelect({ name: date }); - }} + onChange={(date) => handleSelect({ name: date })} /> ) : ( {formatName(option.name, textFormat)} - {selectedOption.id === option.id && ( - - )} + {selectedOption.id === option.id && } ))} diff --git a/src/platform/src/common/components/Modal/dataDownload/components/DataTable.jsx b/src/platform/src/common/components/Modal/dataDownload/components/DataTable.jsx index 9d4e11c9ff..5acca5ae85 100644 --- a/src/platform/src/common/components/Modal/dataDownload/components/DataTable.jsx +++ b/src/platform/src/common/components/Modal/dataDownload/components/DataTable.jsx @@ -2,83 +2,57 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { MdIndeterminateCheckBox } from 'react-icons/md'; import ShortLeftArrow from '@/icons/Analytics/shortLeftArrow'; import ShortRightArrow from '@/icons/Analytics/shortRightArrow'; -import Button from '../../../Button'; -import LocationIcon from '@/icons/Analytics/LocationIcon'; -import TopBarSearch from '../../../search/TopBarSearch'; import TableLoadingSkeleton from './TableLoadingSkeleton'; +import TopBarSearch from '../../../search/TopBarSearch'; /** - * TableRow Component - * Renders a single row in the data table. - * Wrapped with React.memo to prevent unnecessary re-renders. - */ -const TableRowComponent = ({ item, isSelected, onToggleSite, index }) => ( - - -
- onToggleSite(item)} - /> - -
- - - - - - {item?.name - ? item.name.split(',')[0].length > 25 - ? `${item.name.split(',')[0].substring(0, 25)}...` - : item.name.split(',')[0] - : 'Unknown Location'} - - {item.city || 'N/A'} - {item.country || 'N/A'} - {item.data_provider || 'N/A'} - -); - -const TableRow = React.memo(TableRowComponent); -TableRow.displayName = 'TableRow'; - -/** - * DataTable Component - * Renders a table with data, supports selection, pagination, and search. + * DataTable Props: + * - data (Array): The raw data items. + * - columns (Array): Default column definitions. + * Each column can have: + * - key (string): The property name in the data item. + * - label (string): Column header text. + * - render (function): (item, index) => JSX.Element. If provided, used to render the cell. + * - columnsByFilter (Object): An object mapping filter keys to column definitions. + * - filters (Array): Filter definitions. + * - onFilter (Function): (allData, activeFilter) => filteredData. + * - loading (Boolean): Whether to show a loading skeleton. + * - error (Boolean): If true, shows an error fallback. + * - itemsPerPage (Number): Defaults to 6. + * - selectedRows (Array): Currently selected items. + * - setSelectedRows (Function): Sets the selectedRows array. + * - clearSelectionTrigger (any): Changing this resets selectedRows. + * - onToggleRow (Function): Called when a checkbox is toggled. + * - searchKeys (Array): Keys used by Fuse.js for searching. Defaults to ['name']. */ -const DataTable = ({ +function DataTable({ data = [], - selectedSites = [], - setSelectedSites, - itemsPerPage = 7, - clearSites, - selectedSiteIds = [], + columns = [], + columnsByFilter, + filters = [], + onFilter = null, loading = false, - onToggleSite, -}) => { + error = false, + itemsPerPage = 6, + selectedRows = [], + setSelectedRows = () => {}, + clearSelectionTrigger, + onToggleRow, + searchKeys = ['name'], +}) { const [currentPage, setCurrentPage] = useState(1); const [selectAll, setSelectAll] = useState(false); const [indeterminate, setIndeterminate] = useState(false); - const [activeButton, setActiveButton] = useState('all'); + const [activeFilter, setActiveFilter] = useState(filters[0] || null); const [searchResults, setSearchResults] = useState([]); /** - * Remove duplicates based on '_id' using a Set for efficient filtering. + * Deduplicate data by _id (if present) */ const uniqueData = useMemo(() => { + if (!Array.isArray(data)) return []; const seen = new Set(); - return data?.filter((item) => { + return data.filter((item) => { if (!item._id || seen.has(item._id)) return false; seen.add(item._id); return true; @@ -86,61 +60,48 @@ const DataTable = ({ }, [data]); /** - * Reset to first page when uniqueData, activeButton, or searchResults change. - */ - useEffect(() => { - setCurrentPage(1); - }, [uniqueData, activeButton, searchResults]); - - /** - * Update selected sites in parent component whenever selectedSites changes. + * Clear selections if trigger changes */ useEffect(() => { - if (typeof setSelectedSites === 'function') { - setSelectedSites(selectedSites); - } - }, [selectedSites, setSelectedSites]); - - /** - * Clear selections when 'clearSites' prop changes. - */ - useEffect(() => { - setSelectedSites([]); + setSelectedRows([]); setSelectAll(false); setIndeterminate(false); - }, [clearSites, setSelectedSites]); + }, [clearSelectionTrigger, setSelectedRows]); /** - * Filter data based on active tab ('all' or 'favorites') and search results. + * Apply filters if provided, then override with search results */ const filteredData = useMemo(() => { - let filtered = - activeButton === 'favorites' - ? uniqueData?.filter((item) => - selectedSiteIds.includes(String(item._id)), - ) - : uniqueData; - + let result = [...uniqueData]; + if (onFilter && activeFilter) { + result = onFilter(uniqueData, activeFilter); + } if (searchResults.length > 0) { - filtered = searchResults.map((result) => result.item); + result = searchResults.map((r) => r.item); } - - return filtered; - }, [activeButton, uniqueData, selectedSiteIds, searchResults]); + return result; + }, [uniqueData, onFilter, activeFilter, searchResults]); /** - * Calculate total number of pages based on filtered data and items per page. + * Determine effective columns: + * If a columnsByFilter mapping is provided and contains the active filter key, + * use that; otherwise, fall back to the default columns. */ - const totalPages = useMemo( - () => Math.ceil(filteredData?.length / itemsPerPage), - [filteredData, itemsPerPage], - ); + const effectiveColumns = useMemo(() => { + if (columnsByFilter && activeFilter && columnsByFilter[activeFilter.key]) { + return columnsByFilter[activeFilter.key] || []; + } + return columns || []; + }, [columnsByFilter, activeFilter, columns]); /** - * Handle page navigation. - * @param {number} page - The page number to navigate to. + * Pagination calculations */ - const handleClick = useCallback( + const totalPages = useMemo(() => { + return Math.ceil(filteredData.length / itemsPerPage) || 1; + }, [filteredData, itemsPerPage]); + + const handlePageChange = useCallback( (page) => { if (page >= 1 && page <= totalPages) { setCurrentPage(page); @@ -149,230 +110,274 @@ const DataTable = ({ [totalPages], ); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentPageData = useMemo(() => { + return filteredData.slice(startIndex, endIndex); + }, [filteredData, startIndex, endIndex]); + + /** + * Row selection logic + */ + const defaultOnToggleRow = (item) => { + const isSelected = selectedRows.some((row) => row._id === item._id); + if (isSelected) { + setSelectedRows(selectedRows.filter((row) => row._id !== item._id)); + } else { + setSelectedRows([...selectedRows, item]); + } + }; + + const handleRowToggle = useCallback( + (item) => { + if (onToggleRow) { + onToggleRow(item); + } else { + defaultOnToggleRow(item); + } + }, + [onToggleRow, selectedRows], + ); + /** - * Handle "Select All" checkbox change. - * Selects or deselects all items based on current selection state. + * Select all / Deselect all (for current page) */ const handleSelectAllChange = useCallback(() => { if (selectAll || indeterminate) { - setSelectedSites([]); + setSelectedRows([]); setSelectAll(false); setIndeterminate(false); } else { - setSelectedSites(filteredData); + setSelectedRows(currentPageData); setSelectAll(true); setIndeterminate(false); } - }, [selectAll, indeterminate, setSelectedSites, filteredData]); + }, [selectAll, indeterminate, setSelectedRows, currentPageData]); /** - * Update "Select All" and "Indeterminate" states based on selectedSites. + * Update selectAll & indeterminate states */ useEffect(() => { - if ( - selectedSites.length === filteredData?.length && - filteredData?.length > 0 - ) { + if (!currentPageData.length) { + setSelectAll(false); + setIndeterminate(false); + return; + } + if (selectedRows.length === currentPageData.length) { setSelectAll(true); setIndeterminate(false); - } else if (selectedSites.length > 0) { + } else if (selectedRows.length > 0) { setSelectAll(false); setIndeterminate(true); } else { setSelectAll(false); setIndeterminate(false); } - }, [selectedSites, filteredData]); + }, [selectedRows, currentPageData]); /** - * Handle search results from TopBarSearch component. - * @param {Array} results - The search results. + * Search handling */ const handleSearch = useCallback((results) => { setSearchResults(results); setCurrentPage(1); }, []); - /** - * Clear search results. - */ const handleClearSearch = useCallback(() => { setSearchResults([]); }, []); /** - * Render table rows based on current page and filtered data. + * Error fallback */ - const renderTableRows = useMemo(() => { - // Ensure filteredData is defined and is an array - if (!Array.isArray(filteredData)) { - return null; // Or you can return a placeholder like an empty array `[]` or loading state - } - - // Calculate start and end index based on pagination - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - - // Handle edge case if filteredData is empty - if (filteredData.length === 0) { - return ( - - - No data available - - - ); - } + if (error) { + return ( +
+

Something went wrong. Please try again later.

+
+ ); + } - return filteredData - .slice(startIndex, endIndex) - .map((item, index) => ( - site._id === item._id)} - onToggleSite={onToggleSite} - index={startIndex + index} - /> - )); - }, [filteredData, currentPage, itemsPerPage, selectedSites, onToggleSite]); + /** + * Loading skeleton + */ + if (loading) { + return ; + } return ( -
- {/* Header with Filters and Search */} -
+
+ {/* Filters + Search */} +
{/* Filter Buttons */} -
- - +
+ {filters.map((filterDef) => { + const isActive = activeFilter?.key === filterDef.key; + return ( + + ); + })}
{/* Search Bar */} - +
+ +
- {/* Table or Loading Skeleton */} - {loading ? ( - - ) : ( -
- {filteredData && filteredData.length === 0 ? ( -
- No data available. -
- ) : ( - - {/* Table Header */} - - - {/* Select All Checkbox */} - + ); + })} + + ); + })} + +
-
- {indeterminate ? ( - - ) : ( + {/* Table */} +
+ {currentPageData.length === 0 ? ( +
+ No data available. +
+ ) : ( + + + + {/* Checkbox column */} + + {/* Render column headers dynamically using effectiveColumns */} + {effectiveColumns.map((col) => ( + + ))} + + + + {currentPageData.map((item, idx) => { + const isSelected = selectedRows.some( + (row) => row._id === item._id, + ); + return ( + + {/* Checkbox column */} + - - - - - - {/* Table Body */} - {renderTableRows} -
+
+ {indeterminate ? ( + + ) : ( + + )} +
+
+ {col.label} +
+
handleRowToggle(item)} /> - )} - -
- - {/* Table Columns */} -
- Location - - City - - Country - - Owner -
- )} -
- )} +
+ + {/* Render each cell based on effectiveColumns */} + {effectiveColumns.map((col, colIdx) => { + let cellContent; + if (col.render && typeof col.render === 'function') { + try { + // Pass item and index to the render function for custom formatting. + cellContent = col.render(item, idx); + } catch (err) { + console.error( + `Error rendering column ${col.key}:`, + err, + ); + cellContent = 'Error'; + } + } else { + cellContent = + item[col.key] !== undefined && item[col.key] !== null + ? item[col.key] + : 'N/A'; + } + return ( +
+ {cellContent} +
+ )} +
- {/* Pagination Controls */} + {/* Pagination */} {totalPages > 1 && ( -
- {/* Previous Page Button */} - +
)}
); -}; +} export default DataTable; diff --git a/src/platform/src/common/components/Modal/dataDownload/modules/AddLocations.jsx b/src/platform/src/common/components/Modal/dataDownload/modules/AddLocations.jsx index 122e45bf5b..8c05924bd2 100644 --- a/src/platform/src/common/components/Modal/dataDownload/modules/AddLocations.jsx +++ b/src/platform/src/common/components/Modal/dataDownload/modules/AddLocations.jsx @@ -12,7 +12,6 @@ import { useGetActiveGroup } from '@/core/hooks/useGetActiveGroupId'; /** * Header component for the Add Location modal. - * Includes a back button that opens another modal. */ const AddLocationHeader = () => { return ( @@ -25,10 +24,6 @@ const AddLocationHeader = () => { ); }; -/** - * Main component for adding locations. - * Allows users to select sites and updates their preferences accordingly. - */ const AddLocations = ({ onClose }) => { const dispatch = useDispatch(); @@ -46,19 +41,19 @@ const AddLocations = ({ onClose }) => { const { id: activeGroupId, title: groupTitle } = useGetActiveGroup(); - // Fetch sites summary data using custom hook + // Fetch sites summary data from Redux store const { sitesSummaryData, loading, error: fetchError, } = useSelector((state) => state.sites); - // filter out sites that are online online_status=online from sitesSummaryData use memo + // Filter out only the online sites const filteredSites = useMemo(() => { return (sitesSummaryData || []).filter((site) => site.isOnline === true); }, [sitesSummaryData]); - // Retrieve user ID from localStorage and memoize it + // Retrieve user ID from localStorage const userID = useMemo(() => { const storedUser = localStorage.getItem('loggedUser'); if (!storedUser) { @@ -74,9 +69,7 @@ const AddLocations = ({ onClose }) => { } }, []); - /** - * Fetch sites summary whenever the selected organization changes. - */ + // Fetch sites summary whenever groupTitle changes useEffect(() => { if (groupTitle) { dispatch(fetchSitesSummary({ group: groupTitle })); @@ -90,18 +83,22 @@ const AddLocations = ({ onClose }) => { }, [preferencesData]); /** - * Populate selectedSites based on selectedSiteIds and fetched filteredSites. - * Also initializes sidebarSites with the initially selected sites. + * Initialize selectedSites and sidebarSites only once, if the user + * currently has no local selection but the preferences have sites. */ useEffect(() => { - if (filteredSites && selectedSiteIds.length) { + if ( + selectedSites.length === 0 && + selectedSiteIds.length > 0 && + filteredSites.length > 0 + ) { const initialSelectedSites = filteredSites.filter((site) => selectedSiteIds.includes(site._id), ); setSelectedSites(initialSelectedSites); setSidebarSites(initialSelectedSites); } - }, [filteredSites, selectedSiteIds]); + }, [selectedSites, selectedSiteIds, filteredSites]); /** * Clears all selected sites. @@ -109,76 +106,127 @@ const AddLocations = ({ onClose }) => { const handleClearSelection = useCallback(() => { setClearSelected(true); setSelectedSites([]); - // Reset clearSelected flag in the next tick setTimeout(() => setClearSelected(false), 0); }, []); /** * Toggles the selection of a site. - * Adds to selectedSites and sidebarSites if selected. - * Removes from selectedSites but retains in sidebarSites if unselected. */ - const handleToggleSite = useCallback( - (site) => { - setSelectedSites((prev) => { - const isSelected = prev.some((s) => s._id === site._id); - if (isSelected) { - // Remove from selectedSites - return prev.filter((s) => s._id !== site._id); - } else { - // Add to selectedSites - return [...prev, site]; - } - }); + const handleToggleSite = useCallback((site) => { + setSelectedSites((prev) => { + const isSelected = prev.some((s) => s._id === site._id); + return isSelected + ? prev.filter((s) => s._id !== site._id) + : [...prev, site]; + }); + setSidebarSites((prev) => { + const alreadyInSidebar = prev.some((s) => s._id === site._id); + return alreadyInSidebar ? prev : [...prev, site]; + }); + }, []); - setSidebarSites((prev) => { - const alreadyInSidebar = prev.some((s) => s._id === site._id); - if (!alreadyInSidebar) { - return [...prev, site]; - } - return prev; - }); + /** + * Custom filter function for DataTable. + * When the active filter is "favorites", return only sites that are currently selected. + * Otherwise (for "sites"), return all data. + */ + const handleFilter = useCallback( + (data, activeFilter) => { + if (activeFilter.key === 'favorites') { + return data.filter((site) => + selectedSites.some((s) => s._id === site._id), + ); + } + return data; }, - [setSelectedSites, setSidebarSites], + [selectedSites], ); /** - * Handles the submission of selected sites. - * Dispatches the replaceUserPreferences action with the formatted payload. + * Define filters for the DataTable. + */ + const filters = useMemo( + () => [ + { key: 'sites', label: 'Sites' }, + { key: 'favorites', label: 'Favorites' }, + ], + [], + ); + + /** + * Define column mappings for the DataTable. + */ + const columnsByFilter = useMemo( + () => ({ + sites: [ + { + key: 'location_name', + label: 'Location', + render: (item) => { + return ( +
+ + + + {item.location_name} +
+ ); + }, + }, + { key: 'city', label: 'City' }, + { key: 'country', label: 'Country' }, + { key: 'data_provider', label: 'Owner' }, + ], + favorites: [ + { + key: 'location_name', + label: 'Location', + render: (item) => { + return ( +
+ + + + {item.location_name} +
+ ); + }, + }, + { key: 'city', label: 'City' }, + { key: 'country', label: 'Country' }, + { key: 'data_provider', label: 'Owner' }, + ], + }), + [], + ); + + /** + * Handles submission of the selected sites. */ const handleSubmit = useCallback(() => { if (selectedSites.length === 0) { setError('No locations selected'); return; } - if (!userID) { setError('User not found'); return; } - - // if the locations are more than 4, show an error message if (selectedSites.length > 4) { setError('You can select up to 4 locations only'); return; } - - // Start the loading state for submission setSubmitLoading(true); - - // Prepare selected_sites by excluding grids, devices, and airqlouds + // Prepare selected_sites data (excluding grids, devices, airqlouds) const selectedSitesData = selectedSites.map((site) => { const { grids, devices, airqlouds, ...rest } = site; return rest; }); - const payload = { user_id: userID, group_id: activeGroupId, selected_sites: selectedSitesData, }; - - // Dispatch the Redux action to replace user preferences dispatch(replaceUserPreferences(payload)) .then(() => { onClose(); @@ -199,12 +247,10 @@ const AddLocations = ({ onClose }) => { .finally(() => { setSubmitLoading(false); }); - }, [selectedSites, userID, dispatch, onClose]); + }, [selectedSites, userID, dispatch, onClose, activeGroupId]); /** - * Generates the content for the sidebar. - * Displays only the sites that have been selected at least once. - * Each card reflects its current selection status. + * Generates the sidebar content for selected sites. */ const sidebarSitesContent = useMemo(() => { if (loading) { @@ -222,7 +268,6 @@ const AddLocations = ({ onClose }) => {
); } - if (sidebarSites.length === 0) { return (
@@ -233,7 +278,6 @@ const AddLocations = ({ onClose }) => {
); } - return sidebarSites.map((site) => ( { return ( <> - {/* Selected Sites Sidebar */} + {/* Sidebar for Selected Sites */}
{sidebarSitesContent}
@@ -257,12 +301,21 @@ const AddLocations = ({ onClose }) => {
{fetchError && (

diff --git a/src/platform/src/common/components/Modal/dataDownload/modules/DataDownload.jsx b/src/platform/src/common/components/Modal/dataDownload/modules/DataDownload.jsx index 9faabf3b3f..ccc6a1a889 100644 --- a/src/platform/src/common/components/Modal/dataDownload/modules/DataDownload.jsx +++ b/src/platform/src/common/components/Modal/dataDownload/modules/DataDownload.jsx @@ -6,15 +6,19 @@ import FileTypeIcon from '@/icons/Analytics/fileTypeIcon'; import FrequencyIcon from '@/icons/Analytics/frequencyIcon'; import WindIcon from '@/icons/Analytics/windIcon'; import EditIcon from '@/icons/Analytics/EditIcon'; +import LocationIcon from '@/icons/Analytics/LocationIcon'; + import DataTable from '../components/DataTable'; import CustomFields from '../components/CustomFields'; +import Footer from '../components/Footer'; + import { POLLUTANT_OPTIONS, DATA_TYPE_OPTIONS, FREQUENCY_OPTIONS, FILE_TYPE_OPTIONS, } from '../constants'; -import Footer from '../components/Footer'; + import useDataDownload from '@/core/hooks/useDataDownload'; import jsPDF from 'jspdf'; import 'jspdf-autotable'; @@ -48,10 +52,6 @@ const getMimeType = (fileType) => { return mimeTypes[fileType] || 'application/octet-stream'; }; -/** - * Main component for downloading data. - * Allows users to select parameters and download air quality data accordingly. - */ const DataDownload = ({ onClose }) => { const dispatch = useDispatch(); const { @@ -60,6 +60,7 @@ const DataDownload = ({ onClose }) => { groupList, loading: isFetchingActiveGroup, } = useGetActiveGroup(); + const preferencesData = useSelector( (state) => state.defaults.individual_preferences, ); @@ -69,23 +70,21 @@ const DataDownload = ({ onClose }) => { error: fetchError, } = useSelector((state) => state.sites); + // Local selection state for DataTable const [selectedSites, setSelectedSites] = useState([]); const [clearSelected, setClearSelected] = useState(false); + + // Form state const [formError, setFormError] = useState(''); const [downloadLoading, setDownloadLoading] = useState(false); + const [edit, setEdit] = useState(false); - // Use the hook to fetch data const fetchData = useDataDownload(); - // Active group data + // Prepare active group info const activeGroup = { id: activeGroupId, name: groupTitle }; - // Extract selected site IDs from preferencesData - const selectedSiteIds = useMemo(() => { - return preferencesData?.[0]?.selected_sites?.map((site) => site._id) || []; - }, [preferencesData]); - - // Organization options based on user groups + // Organization options const ORGANIZATION_OPTIONS = useMemo( () => groupList?.map((group) => ({ @@ -95,7 +94,7 @@ const DataDownload = ({ onClose }) => { [groupList], ); - // Form data state + // The main form data for exporting const [formData, setFormData] = useState({ title: { name: 'Untitled Report' }, organization: activeGroup || ORGANIZATION_OPTIONS[0], @@ -106,18 +105,12 @@ const DataDownload = ({ onClose }) => { fileType: FILE_TYPE_OPTIONS[0], }); - const [edit, setEdit] = useState(false); - - /** - * Initialize default organization once ORGANIZATION_OPTIONS are available. - * Defaults to "airqo" if available; otherwise, selects the first organization. - */ + // Ensure the organization is set once org options are available useEffect(() => { if (ORGANIZATION_OPTIONS.length > 0 && !formData.organization) { const airqoNetwork = ORGANIZATION_OPTIONS.find( (group) => group.name.toLowerCase() === 'airqo', ); - setFormData((prevData) => ({ ...prevData, organization: activeGroup || airqoNetwork, @@ -125,12 +118,9 @@ const DataDownload = ({ onClose }) => { } }, [ORGANIZATION_OPTIONS, formData.organization, activeGroupId, groupTitle]); - /** - * Fetch sites summary whenever the selected organization changes. - */ + // Fetch site summary for the chosen organization useEffect(() => { if (isFetchingActiveGroup) return; - if (formData.organization) { dispatch( fetchSitesSummary({ @@ -141,12 +131,13 @@ const DataDownload = ({ onClose }) => { }, [dispatch, formData.organization, isFetchingActiveGroup]); /** - * Clears all selected sites and resets form data. + * Clears selection in both the table and form. */ const handleClearSelection = useCallback(() => { setClearSelected(true); setSelectedSites([]); - // Reset form data after submission + + // Reset form data to defaults const airqoNetwork = ORGANIZATION_OPTIONS.find( (group) => group.name.toLowerCase() === 'airqo', ); @@ -159,22 +150,19 @@ const DataDownload = ({ onClose }) => { frequency: FREQUENCY_OPTIONS[0], fileType: FILE_TYPE_OPTIONS[0], }); - // Reset clearSelected flag in the next tick + setTimeout(() => setClearSelected(false), 0); }, [ORGANIZATION_OPTIONS]); /** - * Handles the selection of form options. - * @param {string} id - The ID of the form field. - * @param {object} option - The selected option. + * Update a form field (title, organization, etc.). */ const handleOptionSelect = useCallback((id, option) => { setFormData((prevData) => ({ ...prevData, [id]: option })); }, []); /** - * Toggles the selection of a site. - * @param {object} site - The site to toggle. + * Toggles the selection of a site in DataTable. */ const handleToggleSite = useCallback((site) => { setSelectedSites((prev) => { @@ -186,9 +174,7 @@ const DataDownload = ({ onClose }) => { }, []); /** - * Handles the submission of the form. - * Prepares data and calls the exportDataApi to download the data. - * @param {object} e - The form event. + * Download button handler */ const handleSubmit = useCallback( async (e) => { @@ -197,7 +183,7 @@ const DataDownload = ({ onClose }) => { setFormError(''); try { - // Validate form data + // Validate date range if ( !formData.duration || !formData.duration.name?.start || @@ -208,16 +194,14 @@ const DataDownload = ({ onClose }) => { ); } - // Parse the start and end dates const startDate = new Date(formData.duration.name.start); const endDate = new Date(formData.duration.name.end); - // Frequency-based duration limit validation - const validateDuration = (frequency, startDate, endDate) => { - const sixMonthsInMs = 6 * 30 * 24 * 60 * 60 * 1000; // 6 months in milliseconds - const oneYearInMs = 12 * 30 * 24 * 60 * 60 * 1000; // 1 year in milliseconds - const durationMs = endDate - startDate; - + // Duration constraints for hourly/daily + const validateDuration = (frequency, sDate, eDate) => { + const sixMonthsInMs = 6 * 30 * 24 * 60 * 60 * 1000; + const oneYearInMs = 12 * 30 * 24 * 60 * 60 * 1000; + const durationMs = eDate - sDate; if (frequency === 'hourly' && durationMs > sixMonthsInMs) { return 'For hourly frequency, the duration cannot exceed 6 months.'; } @@ -233,16 +217,16 @@ const DataDownload = ({ onClose }) => { startDate, endDate, ); - if (durationError) { throw new Error(durationError); } + // At least one location if (selectedSites.length === 0) { throw new Error('Please select at least one location.'); } - // Prepare data for API + // Prepare data for the API const apiData = { startDateTime: format(startDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), endDateTime: format(endDate, "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"), @@ -259,13 +243,15 @@ const DataDownload = ({ onClose }) => { minimum: true, }; + // Make API call const response = await fetchData(apiData); - // Handle file download based on file type + // Build filename and MIME const fileExtension = formData.fileType.name.toLowerCase(); const mimeType = getMimeType(fileExtension); const fileName = `${formData.title.name}.${fileExtension}`; + // Download logic if (fileExtension === 'csv') { if (typeof response !== 'string') { throw new Error('Invalid CSV data format.'); @@ -279,14 +265,13 @@ const DataDownload = ({ onClose }) => { } else if (fileExtension === 'pdf') { const pdfData = response.data || []; const doc = new jsPDF(); - if (pdfData.length === 0) { doc.text('No data available to display.', 10, 10); } else { const tableColumn = Object.keys(pdfData[0]); - const tableRows = pdfData.map((data) => + const tableRows = pdfData.map((row) => tableColumn.map((col) => - data[col] !== undefined ? data[col] : '---', + row[col] !== undefined ? row[col] : '---', ), ); doc.autoTable({ @@ -303,10 +288,8 @@ const DataDownload = ({ onClose }) => { throw new Error('Unsupported file type.'); } - // Show success toast + // Success CustomToast(); - - // Clear selections after successful download handleClearSelection(); onClose(); } catch (error) { @@ -322,6 +305,78 @@ const DataDownload = ({ onClose }) => { [formData, selectedSites, handleClearSelection, fetchData, onClose], ); + /** + * We only want two filters: "Sites" and "Favorites". + */ + const filters = useMemo( + () => [ + { key: 'sites', label: 'Sites' }, + { key: 'favorites', label: 'Favorites' }, + ], + [], + ); + + /** + * Show the same columns for both "Sites" and "Favorites". + */ + const columnsByFilter = useMemo( + () => ({ + sites: [ + { + key: 'search_name', + label: 'Location', + render: (item) => ( +

+ + + + {item.search_name || 'N/A'} +
+ ), + }, + { key: 'city', label: 'City' }, + { key: 'country', label: 'Country' }, + { key: 'data_provider', label: 'Owner' }, + ], + favorites: [ + { + key: 'search_name', + label: 'Location', + render: (item) => ( +
+ + + + {item.search_name || 'N/A'} +
+ ), + }, + { key: 'city', label: 'City' }, + { key: 'country', label: 'Country' }, + { key: 'data_provider', label: 'Owner' }, + ], + }), + [], + ); + + /** + * Custom filter callback: + * - "favorites": only show sites that appear in user preferences (selected_sites). + * - "sites": show all data. + */ + const handleFilter = useCallback( + (allData, activeFilter) => { + if (activeFilter.key === 'favorites') { + // Only show those in the preferences + const favorites = + preferencesData?.[0]?.selected_sites?.map((s) => s._id) || []; + return allData.filter((site) => favorites.includes(site._id)); + } + return allData; + }, + [preferencesData], + ); + return ( <> {/* Section 1: Form */} @@ -329,7 +384,7 @@ const DataDownload = ({ onClose }) => { className="w-auto h-auto md:w-[280px] md:h-[658px] relative bg-[#f6f6f7] space-y-3 px-5 pt-5 pb-14" onSubmit={handleSubmit} > - {/* Edit Button */} + {/* Edit Title Button */}