diff --git a/DashAI/front/src/App.jsx b/DashAI/front/src/App.jsx index f11648abd..43915a4c6 100644 --- a/DashAI/front/src/App.jsx +++ b/DashAI/front/src/App.jsx @@ -29,6 +29,8 @@ import RAGPromptsPage from "./pages/generative/RAG/RAGPromptsPage"; import RAGSessionPage from "./pages/generative/RAGSession/RAGSessionPage"; import SessionRouter from "./pages/generative/SessionRouter"; import { DatasetsAndNotebooksProvider } from "./components/custom/contexts/DatasetsAndNotebooksContext"; +import { DatasetsProvider } from "./contexts/DatasetsContext"; +import { ModelsProvider } from "./components/models/ModelsContext"; function DataSectionLayout() { return ( @@ -44,83 +46,103 @@ function App() { - - - } /> - } /> - }> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } - /> - } /> - - } /> - } /> - } /> - } - /> - } - /> - } /> - } - /> - - - - } - /> - - - - } - /> - } /> - } - /> - } - /> - } /> - } /> - } - /> - - } /> - - } /> - } /> - - - - + + + + + + } /> + } /> + }> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + + } /> + } + /> + } + /> + } + /> + } + /> + } /> + + } + /> + + + + } + /> + + + + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + + } /> + + } /> + } /> + + + + + + + ); diff --git a/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx b/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx index 3201fe7e8..fe9ef95ff 100644 --- a/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx +++ b/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx @@ -1,8 +1,7 @@ import { createContext, useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useDatasets } from "../../../hooks/datasets/useDatasets"; -import { useFolders } from "../../../hooks/datasets/useFolders"; +import { useSharedDatasets } from "../../../contexts/DatasetsContext"; import { useNotebooks } from "../../../hooks/datasets/useNotebooks"; import { useDownloads } from "../../../hooks/datasets/useDownloads"; @@ -34,29 +33,14 @@ export const DatasetsAndNotebooksProvider = ({ children }) => { addDatasetOptimistically, replaceDatasets, startDatasetPolling, - } = useDatasets({ t }); - - const { folders, fetchFolders, createFolder, renameFolder, - deleteFolderById: deleteFolderByIdRaw, - } = useFolders({ t }); - - // Deleting a folder moves its datasets to "no folder" server-side - // (folder_id set to null via the FK's ON DELETE SET NULL), but the local - // `datasets` state still holds the old folder_id until this clears it — - // otherwise those datasets vanish from the list until a full refetch. - const deleteFolderById = async (id) => { - const success = await deleteFolderByIdRaw(id); - if (success) { - replaceDatasets((prev) => - prev.map((d) => (d.folder_id === id ? { ...d, folder_id: null } : d)), - ); - } - return success; - }; + deleteFolderById, + openFolderIds, + setOpenFolderIds, + } = useSharedDatasets(); const { downloads, @@ -158,6 +142,8 @@ export const DatasetsAndNotebooksProvider = ({ children }) => { setScrollToColumn, uploadDataloader, setUploadDataloader, + openFolderIds, + setOpenFolderIds, }; return ( diff --git a/DashAI/front/src/components/generative/GenerativeContext.jsx b/DashAI/front/src/components/generative/GenerativeContext.jsx index 21cc4536e..b280c3f49 100644 --- a/DashAI/front/src/components/generative/GenerativeContext.jsx +++ b/DashAI/front/src/components/generative/GenerativeContext.jsx @@ -28,6 +28,7 @@ export function GenerativeProvider({ children }) { editSession, } = useSessions({ t }); const [stepIndex, setStepIndex] = useState(0); + const [openSections, setOpenSections] = useState({}); useEffect(() => { fetchSessions(); @@ -54,6 +55,8 @@ export function GenerativeProvider({ children }) { fetchTasks, stepIndex, setStepIndex, + openSections, + setOpenSections, deleteSessionById, deleteSessionsByIds, editSession, diff --git a/DashAI/front/src/components/generative/SessionBar.jsx b/DashAI/front/src/components/generative/SessionBar.jsx index 9aebd1480..a68745c30 100644 --- a/DashAI/front/src/components/generative/SessionBar.jsx +++ b/DashAI/front/src/components/generative/SessionBar.jsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import FolderIcon from "@mui/icons-material/Folder"; import ViewModuleIcon from "@mui/icons-material/ViewModule"; import SearchBar from "../threeSectionLayout/SearchBar"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import InfoSessionModal from "./InfoSessionModal"; import GroupedCollapsibleList from "../threeSectionLayout/GroupedCollapsibleList"; import Footer from "../threeSectionLayout/Footer"; @@ -31,6 +31,8 @@ export default function SessionBar({ deleteSessionById, deleteSessionsByIds, editSession, + openSections, + setOpenSections, } = useGenerative(); const sessions = sessionsProp ?? sessionsCtx ?? []; @@ -38,7 +40,6 @@ export default function SessionBar({ const [searchQuery, setSearchQuery] = useState(""); const [filteredSessions, setFilteredSessions] = useState(sessions); const [selectedInfoSession, setSelectedInfoSession] = useState(null); - const [openSections, setOpenSections] = useState({}); const { t } = useTranslation(["generative", "common"]); const SEARCH_THRESHOLD = 10; @@ -48,11 +49,14 @@ export default function SessionBar({ }, [sessions.length]); // Create a map of task_name to display_name for quick lookup - const taskDisplayNameMap = - tasks?.reduce((map, task) => { - map[task.name] = task.display_name; - return map; - }, {}) || {}; + const taskDisplayNameMap = useMemo( + () => + tasks?.reduce((map, task) => { + map[task.name] = task.display_name; + return map; + }, {}) || {}, + [tasks], + ); useEffect(() => { // Initialize all sections as closed based on unique task display names @@ -158,28 +162,32 @@ export default function SessionBar({ return ok; }; - // Group sessions by task display_name - const groupedSessions = filteredSessions?.reduce((groups, session) => { - // Get the display name from the task using the session's task_name - const displayName = - taskDisplayNameMap[session.task_name] || t("common:other"); + // Group sessions by task display_name, sorted to maintain a consistent order. + // Memoized so the object identity stays stable across renders that don't + // change the underlying data — GroupedCollapsibleList uses `groups` as an + // effect dependency, so a fresh literal every render would re-run it. + const sortedGroupedSessions = useMemo(() => { + const groupedSessions = filteredSessions?.reduce((groups, session) => { + // Get the display name from the task using the session's task_name + const displayName = + taskDisplayNameMap[session.task_name] || t("common:other"); - if (!groups[displayName]) { - groups[displayName] = []; - } - groups[displayName].push(session); - return groups; - }, {}); + if (!groups[displayName]) { + groups[displayName] = []; + } + groups[displayName].push(session); + return groups; + }, {}); - // Sort grouped sessions to maintain consistent order - const sortedGroupedSessions = groupedSessions - ? Object.keys(groupedSessions) - .sort() - .reduce((sorted, key) => { - sorted[key] = groupedSessions[key]; - return sorted; - }, {}) - : {}; + return groupedSessions + ? Object.keys(groupedSessions) + .sort() + .reduce((sorted, key) => { + sorted[key] = groupedSessions[key]; + return sorted; + }, {}) + : {}; + }, [filteredSessions, taskDisplayNameMap, t]); return ( @@ -231,7 +239,8 @@ export default function SessionBar({ onItemInfo={handleSessionInfo} title={t("common:generative")} Icon={FolderIcon} - initialOpenGroups={openSections} + openGroups={openSections} + onOpenGroupsChange={setOpenSections} getItemDescription={(session) => session.model_name} getDeleteConfirmationContent={getSessionDeleteConfirmationContent} onBulkDelete={handleBulkSessionDelete} diff --git a/DashAI/front/src/components/models/ModelsContext.jsx b/DashAI/front/src/components/models/ModelsContext.jsx index fa46dd498..3c0578a7d 100644 --- a/DashAI/front/src/components/models/ModelsContext.jsx +++ b/DashAI/front/src/components/models/ModelsContext.jsx @@ -7,8 +7,7 @@ import { useMemo, } from "react"; import { useTranslation } from "react-i18next"; -import { useDatasets } from "../../hooks/datasets/useDatasets"; -import { useFolders } from "../../hooks/datasets/useFolders"; +import { useSharedDatasets } from "../../contexts/DatasetsContext"; import { useSessions } from "../../hooks/models/useSessions"; import { useModelComponents } from "../../hooks/models/useModelComponents"; const ModelsContext = createContext(null); @@ -39,31 +38,14 @@ export function ModelsProvider({ children }) { replaceDatasets, startDatasetPolling, moveDatasetToFolder, - datasetRowCount, - setDatasetRowCount, - } = useDatasets({ t }); - - const { folders, fetchFolders, createFolder, renameFolder, - deleteFolderById: deleteFolderByIdRaw, - } = useFolders({ t }); - - // Deleting a folder moves its datasets to "no folder" server-side - // (folder_id set to null via the FK's ON DELETE SET NULL), but the local - // `datasets` state still holds the old folder_id until this clears it — - // otherwise those datasets vanish from the list until a full refetch. - const deleteFolderById = async (id) => { - const success = await deleteFolderByIdRaw(id); - if (success) { - replaceDatasets((prev) => - prev.map((d) => (d.folder_id === id ? { ...d, folder_id: null } : d)), - ); - } - return success; - }; + deleteFolderById, + openFolderIds, + setOpenFolderIds, + } = useSharedDatasets(); const { tasks, @@ -116,6 +98,8 @@ export function ModelsProvider({ children }) { const [runDetailTab, setRunDetailTab] = useState(null); const [explainerRefreshTrigger, setExplainerRefreshTrigger] = useState(0); const [explainerToCreate, setExplainerToCreate] = useState(null); + const [openSections, setOpenSections] = useState({}); + const [datasetRowCount, setDatasetRowCount] = useState(null); const [selectedStatisticalTest, setSelectedStatisticalTest] = useState(null); const [statisticalTestsModalOpen, setStatisticalTestsModalOpen] = useState(false); @@ -156,7 +140,8 @@ export function ModelsProvider({ children }) { }, []); useEffect(() => { - fetchDatasets(); + // Datasets are fetched by the shared DatasetsProvider on its own mount; + // fetching them here too would duplicate GET /datasets on every app boot. fetchSessions(); }, []); @@ -250,6 +235,10 @@ export function ModelsProvider({ children }) { explainerToCreate, openExplainerCreator, closeExplainerCreator, + openSections, + setOpenSections, + openFolderIds, + setOpenFolderIds, selectedStatisticalTest, statisticalTestsModalOpen, openStatisticalTest, @@ -319,6 +308,8 @@ export function ModelsProvider({ children }) { explainerToCreate, openExplainerCreator, closeExplainerCreator, + openSections, + openFolderIds, selectedStatisticalTest, statisticalTestsModalOpen, openStatisticalTest, diff --git a/DashAI/front/src/components/models/ModelsLeftBar.jsx b/DashAI/front/src/components/models/ModelsLeftBar.jsx index 683934593..65a0fdf76 100644 --- a/DashAI/front/src/components/models/ModelsLeftBar.jsx +++ b/DashAI/front/src/components/models/ModelsLeftBar.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { Box, Divider, Typography } from "@mui/material"; import { useTheme } from "@mui/material/styles"; @@ -39,6 +39,10 @@ export default function ModelsLeftBar({ onToggle }) { renameFolder, deleteFolderById, moveDatasetToFolder, + openSections, + setOpenSections, + openFolderIds, + setOpenFolderIds, } = useModels(); const navigate = useNavigate(); @@ -46,7 +50,6 @@ export default function ModelsLeftBar({ onToggle }) { const [searchQuery, setSearchQuery] = useState(""); const [filteredDatasets, setFilteredDatasets] = useState(datasets); const [filteredSessions, setFilteredSessions] = useState(sessions); - const [openSections, setOpenSections] = useState({}); const [selectedInfoSession, setSelectedInfoSession] = useState(null); const { t } = useTranslation(["models", "datasets", "common"]); @@ -73,22 +76,23 @@ export default function ModelsLeftBar({ onToggle }) { ); useEffect(() => { - // Initialize all task sections as closed + // Sync the section map with the current task display names const displayNames = [ ...new Set( sessions.map((session) => getTaskDisplayName(session.task_name)), ), ]; - const initialOpenState = {}; - displayNames.forEach((displayName) => { - initialOpenState[displayName] = false; - }); setOpenSections((prev) => { // Only update if display names have changed const prevKeys = Object.keys(prev).sort().join(","); - const newKeys = Object.keys(initialOpenState).sort().join(","); + const newKeys = displayNames.slice().sort().join(","); if (prevKeys === newKeys) return prev; - return initialOpenState; + // Preserve existing open/close state; initialize new keys as closed + const merged = {}; + displayNames.forEach((displayName) => { + merged[displayName] = displayName in prev ? prev[displayName] : false; + }); + return merged; }); }, [sessions, tasks]); @@ -190,25 +194,29 @@ export default function ModelsLeftBar({ onToggle }) { return session.description || ""; }; - // Group sessions by task - const groupedSessions = filteredSessions?.reduce((groups, session) => { - const displayName = getTaskDisplayName(session.task_name); - if (!groups[displayName]) { - groups[displayName] = []; - } - groups[displayName].push(session); - return groups; - }, {}); - - // Sort grouped sessions to maintain consistent order - const sortedGroupedSessions = groupedSessions - ? Object.keys(groupedSessions) - .sort() - .reduce((sorted, key) => { - sorted[key] = groupedSessions[key]; - return sorted; - }, {}) - : {}; + // Group sessions by task, sorted to maintain a consistent order. + // Memoized so the object identity stays stable across renders that don't + // change the underlying data — GroupedCollapsibleList uses `groups` as an + // effect dependency, so a fresh literal every render would re-run it. + const sortedGroupedSessions = useMemo(() => { + const groupedSessions = filteredSessions?.reduce((groups, session) => { + const displayName = getTaskDisplayName(session.task_name); + if (!groups[displayName]) { + groups[displayName] = []; + } + groups[displayName].push(session); + return groups; + }, {}); + + return groupedSessions + ? Object.keys(groupedSessions) + .sort() + .reduce((sorted, key) => { + sorted[key] = groupedSessions[key]; + return sorted; + }, {}) + : {}; + }, [filteredSessions, getTaskDisplayName]); const onDatasetClick = (datasetId) => { navigate(`/app/models/datasets/${datasetId}`); @@ -329,6 +337,8 @@ export default function ModelsLeftBar({ onToggle }) { { + const next = typeof updater === "function" ? updater(open) : updater; + if (isControlled) { + onToggleOpen?.(next); + } else { + setInternalOpen(next); + } + }; const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(folderName); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -120,10 +132,6 @@ function FolderSection({ } }, [isRenaming]); - useEffect(() => { - if (selectionMode) setOpen(true); - }, [selectionMode]); - const handleRenameKeyDown = async (e) => { if (e.key === "Enter") { if (renameValue.trim() && renameValue.trim() !== folderName) { @@ -384,6 +392,8 @@ export default function DatasetFolderList({ getDeleteConfirmationContent, getDeleteConfirmationWarning, title, + openFolderIds = {}, + setOpenFolderIds, }) { const theme = useTheme(); const [activeId, setActiveId] = useState(null); @@ -681,7 +691,13 @@ export default function DatasetFolderList({ folderName={folder.name} items={folderDatasets} isOver={overId === String(folder.id)} - defaultOpen={true} + open={selectionMode || (openFolderIds[folder.id] ?? true)} + onToggleOpen={(next) => + setOpenFolderIds?.((prev) => ({ + ...prev, + [folder.id]: next, + })) + } selectedItemId={selectedItemId} onItemClick={onItemClick} onItemDelete={onItemDelete} diff --git a/DashAI/front/src/components/threeSectionLayout/GroupedCollapsibleList.jsx b/DashAI/front/src/components/threeSectionLayout/GroupedCollapsibleList.jsx index e0c5fae80..8197d062b 100644 --- a/DashAI/front/src/components/threeSectionLayout/GroupedCollapsibleList.jsx +++ b/DashAI/front/src/components/threeSectionLayout/GroupedCollapsibleList.jsx @@ -31,6 +31,8 @@ export default function GroupedCollapsibleList({ getDeleteConfirmationContent, getDeleteConfirmationWarning, initialOpenGroups = {}, + openGroups: controlledOpenGroups, + onOpenGroupsChange, onBulkDelete, selectItemsTooltip = t( "common:selectItemsToDelete", @@ -45,7 +47,30 @@ export default function GroupedCollapsibleList({ bulkDeleteConfirmationWarning, }) { const theme = useTheme(); - const [openGroups, setOpenGroups] = useState(initialOpenGroups); + const isControlled = controlledOpenGroups !== undefined; + const [internalOpenGroups, setInternalOpenGroups] = + useState(initialOpenGroups); + const openGroups = isControlled ? controlledOpenGroups : internalOpenGroups; + const setOpenGroups = (updater) => { + const next = typeof updater === "function" ? updater(openGroups) : updater; + // Bail out on no-op updates. These are flat {groupName: boolean} maps, so a + // shallow key/value comparison is enough. Without this, a "set group X open" + // call for an already-open group would still notify the controlled parent, + // which re-renders the caller, which rebuilds `groups`, which re-triggers + // the auto-open effect below -> infinite render loop. + const isSame = + next === openGroups || + (!!next && + !!openGroups && + Object.keys(next).length === Object.keys(openGroups).length && + Object.keys(next).every((key) => next[key] === openGroups[key])); + if (isSame) return; + if (isControlled) { + onOpenGroupsChange?.(next); + } else { + setInternalOpenGroups(next); + } + }; const selectedItemRef = useRef(null); const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState(() => new Set()); diff --git a/DashAI/front/src/contexts/DatasetsContext.jsx b/DashAI/front/src/contexts/DatasetsContext.jsx new file mode 100644 index 000000000..164b5c2bb --- /dev/null +++ b/DashAI/front/src/contexts/DatasetsContext.jsx @@ -0,0 +1,126 @@ +import { + createContext, + useContext, + useCallback, + useMemo, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useDatasets } from "../hooks/datasets/useDatasets"; +import { useFolders } from "../hooks/datasets/useFolders"; + +const DatasetsContext = createContext(null); + +export const useSharedDatasets = () => useContext(DatasetsContext); + +export function DatasetsProvider({ children }) { + const { t } = useTranslation(["datasets", "common"]); + + const { + datasets, + createDataset, + selectedDatasetId, + fetchDatasets, + selectDataset, + clearSelectedDataset, + deleteDataset, + deleteDatasetById, + deleteDatasetsByIds, + editDataset, + addDatasetOptimistically, + replaceDatasets, + startDatasetPolling, + moveDatasetToFolder, + } = useDatasets({ t }); + + const { + folders, + fetchFolders, + createFolder, + renameFolder, + deleteFolderById: deleteFolderByIdRaw, + } = useFolders({ t }); + + // Which named folders are open/closed in the sidebar. Shared here (not in + // ModelsContext/DatasetsAndNotebooksContext individually) so the Datasets + // and Models modules — which each render their own DatasetFolderList + // instance — show the same open/closed state instead of two independent + // copies. A folder id with no entry here defaults to open (see + // DatasetFolderList's read side). + const [openFolderIds, setOpenFolderIds] = useState({}); + + // Deleting a folder moves its datasets to "no folder" server-side + // (folder_id set to null via the FK's ON DELETE SET NULL), but the local + // `datasets` state still holds the old folder_id until this clears it — + // otherwise those datasets vanish from the list until a full refetch. + const deleteFolderById = useCallback( + async (id) => { + const success = await deleteFolderByIdRaw(id); + if (success) { + replaceDatasets((prev) => + prev.map((d) => (d.folder_id === id ? { ...d, folder_id: null } : d)), + ); + setOpenFolderIds((prev) => { + const { [id]: _removed, ...rest } = prev; + return rest; + }); + } + return success; + }, + [deleteFolderByIdRaw, replaceDatasets], + ); + + const value = useMemo( + () => ({ + datasets, + createDataset, + selectedDatasetId, + fetchDatasets, + selectDataset, + clearSelectedDataset, + deleteDataset, + deleteDatasetById, + deleteDatasetsByIds, + editDataset, + addDatasetOptimistically, + replaceDatasets, + startDatasetPolling, + moveDatasetToFolder, + folders, + fetchFolders, + createFolder, + renameFolder, + deleteFolderById, + openFolderIds, + setOpenFolderIds, + }), + [ + datasets, + createDataset, + selectedDatasetId, + fetchDatasets, + selectDataset, + clearSelectedDataset, + deleteDataset, + deleteDatasetById, + deleteDatasetsByIds, + editDataset, + addDatasetOptimistically, + replaceDatasets, + startDatasetPolling, + moveDatasetToFolder, + folders, + fetchFolders, + createFolder, + renameFolder, + deleteFolderById, + openFolderIds, + ], + ); + + return ( + + {children} + + ); +} diff --git a/DashAI/front/src/pages/generative/Generative.jsx b/DashAI/front/src/pages/generative/Generative.jsx index 3b8c0a706..83f10a4f2 100644 --- a/DashAI/front/src/pages/generative/Generative.jsx +++ b/DashAI/front/src/pages/generative/Generative.jsx @@ -1,14 +1,11 @@ import { TourProvider } from "../../components/tour/TourProvider"; import { TOUR_KEYS } from "../../constants/tours"; -import { GenerativeProvider } from "../../components/generative/GenerativeContext"; import GenerativeContent from "./GenerativeContent"; export default function Generative() { return ( - - - + ); } diff --git a/DashAI/front/src/pages/models/Models.jsx b/DashAI/front/src/pages/models/Models.jsx index 462021863..774bba43d 100644 --- a/DashAI/front/src/pages/models/Models.jsx +++ b/DashAI/front/src/pages/models/Models.jsx @@ -1,10 +1,5 @@ import ModelsContent from "./ModelsContent"; -import { ModelsProvider } from "../../components/models/ModelsContext"; export default function Models() { - return ( - - - - ); + return ; } diff --git a/tests/back/api/test_model_session_api.py b/tests/back/api/test_model_session_api.py index d9efcadfb..23624d537 100644 --- a/tests/back/api/test_model_session_api.py +++ b/tests/back/api/test_model_session_api.py @@ -16,7 +16,7 @@ "train_metrics": [], "validation_metrics": [], "test_metrics": [], - "evaluation_strategy": "holdout", + "evaluation_strategy": "HoldoutEvaluationStrategy", "splits": json.dumps( { "train": 0.5,