From 10c48c0bf8613d4e5d4084f9e5167b49bdccac13 Mon Sep 17 00:00:00 2001 From: Creylay Date: Mon, 17 Aug 2026 13:08:04 -0400 Subject: [PATCH 1/2] feat: refactor context providers for datasets and models, improve state management for open sections --- DashAI/front/src/App.jsx | 132 +++++++++++------- .../contexts/DatasetsAndNotebooksContext.jsx | 28 +--- .../generative/GenerativeContext.jsx | 3 + .../src/components/generative/SessionBar.jsx | 69 +++++---- .../src/components/models/ModelsContext.jsx | 36 ++--- .../src/components/models/ModelsLeftBar.jsx | 69 +++++---- .../notebooks/DatasetNotebookLeftBar.jsx | 4 + .../threeSectionLayout/DatasetFolderList.jsx | 28 +++- .../GroupedCollapsibleList.jsx | 27 +++- DashAI/front/src/contexts/DatasetsContext.jsx | 126 +++++++++++++++++ .../front/src/pages/generative/Generative.jsx | 5 +- DashAI/front/src/pages/models/Models.jsx | 7 +- 12 files changed, 361 insertions(+), 173 deletions(-) create mode 100644 DashAI/front/src/contexts/DatasetsContext.jsx diff --git a/DashAI/front/src/App.jsx b/DashAI/front/src/App.jsx index 46abcdeb6..39ab6959b 100644 --- a/DashAI/front/src/App.jsx +++ b/DashAI/front/src/App.jsx @@ -18,6 +18,9 @@ import HubContent from "./pages/hub/HubContent"; import HubImportPage from "./pages/hub/HubImportPage"; import JobQueueWidget from "./components/jobs/JobQueueWidget"; import { DatasetsAndNotebooksProvider } from "./components/custom/contexts/DatasetsAndNotebooksContext"; +import { DatasetsProvider } from "./contexts/DatasetsContext"; +import { ModelsProvider } from "./components/models/ModelsContext"; +import { GenerativeProvider } from "./components/generative/GenerativeContext"; function DataSectionLayout() { return ( @@ -33,60 +36,81 @@ function App() { - - - } /> - } /> - }> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } - /> - } /> - - } /> - } /> - } /> - } - /> - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } - /> - - } /> - - } /> - } /> - - - - + + + + + + } /> + } /> + }> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + + } /> + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + + } /> + + } /> + } /> + + + + + + + ); diff --git a/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx b/DashAI/front/src/components/custom/contexts/DatasetsAndNotebooksContext.jsx index ef2fd053e..014b1d4e8 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, @@ -143,6 +127,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 93898b436..3e07a5fae 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"; @@ -23,11 +23,12 @@ export default function SessionBar({ onToggle }) { deleteSessionById, deleteSessionsByIds, editSession, + openSections, + setOpenSections, } = useGenerative(); 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; @@ -37,11 +38,14 @@ export default function SessionBar({ onToggle }) { }, [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 @@ -129,28 +133,32 @@ export default function SessionBar({ onToggle }) { 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"); - - 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 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; + }, {}); + + return groupedSessions + ? Object.keys(groupedSessions) + .sort() + .reduce((sorted, key) => { + sorted[key] = groupedSessions[key]; + return sorted; + }, {}) + : {}; + }, [filteredSessions, taskDisplayNameMap, t]); return ( @@ -202,7 +210,8 @@ export default function SessionBar({ onToggle }) { 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 7e9e6d9f7..05784c6cd 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"; const ModelsContext = createContext(null); @@ -38,29 +37,14 @@ export function ModelsProvider({ children }) { replaceDatasets, startDatasetPolling, moveDatasetToFolder, - } = 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, @@ -109,6 +93,7 @@ export function ModelsProvider({ children }) { const [runDetailTab, setRunDetailTab] = useState(null); const [explainerRefreshTrigger, setExplainerRefreshTrigger] = useState(0); const [explainerToCreate, setExplainerToCreate] = useState(null); + const [openSections, setOpenSections] = useState({}); const triggerExplainerRefresh = useCallback(() => { setExplainerRefreshTrigger((prev) => prev + 1); @@ -136,7 +121,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(); }, []); @@ -225,6 +211,10 @@ export function ModelsProvider({ children }) { explainerToCreate, openExplainerCreator, closeExplainerCreator, + openSections, + setOpenSections, + openFolderIds, + setOpenFolderIds, }), [ selectedModel, @@ -286,6 +276,8 @@ export function ModelsProvider({ children }) { explainerToCreate, openExplainerCreator, closeExplainerCreator, + openSections, + openFolderIds, ], ); 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 ; } From ef8725f511b094fb52a7860c7ffdcae0414abec1 Mon Sep 17 00:00:00 2001 From: Creylay Date: Fri, 21 Aug 2026 10:45:12 -0400 Subject: [PATCH 2/2] fix: ensure evaluation strategy is set in session parameters --- tests/back/api/test_model_session_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/back/api/test_model_session_api.py b/tests/back/api/test_model_session_api.py index 42b857f05..23624d537 100644 --- a/tests/back/api/test_model_session_api.py +++ b/tests/back/api/test_model_session_api.py @@ -16,6 +16,7 @@ "train_metrics": [], "validation_metrics": [], "test_metrics": [], + "evaluation_strategy": "HoldoutEvaluationStrategy", "splits": json.dumps( { "train": 0.5,