diff --git a/DashAI/back/converters/base_converter.py b/DashAI/back/converters/base_converter.py index a8a534034..6a7408c29 100644 --- a/DashAI/back/converters/base_converter.py +++ b/DashAI/back/converters/base_converter.py @@ -70,6 +70,8 @@ def get_metadata(cls) -> Dict[str, Any]: meta["category"] = cls.CATEGORY if cls.CATEGORY else "Other" meta["icon"] = cls.ICON if cls.ICON else Icon.Extension.value meta["color"] = cls.COLOR if cls.COLOR else "rgb(255, 255, 255)" + meta["requires_download"] = bool(getattr(cls, "REQUIRES_DOWNLOAD", False)) + meta["download_size_bytes"] = getattr(cls, "DOWNLOAD_SIZE_BYTES", None) meta["supervised"] = cls.SUPERVISED meta["changes_row_count"] = cls.CHANGES_ROW_COUNT meta["n_components_features_bounded"] = getattr( diff --git a/DashAI/back/exploration/base_explorer.py b/DashAI/back/exploration/base_explorer.py index 65520cd5e..fac016eb9 100644 --- a/DashAI/back/exploration/base_explorer.py +++ b/DashAI/back/exploration/base_explorer.py @@ -89,6 +89,8 @@ def get_metadata(cls) -> Dict[str, Any]: meta["category"] = cls.CATEGORY if cls.CATEGORY else "Other" meta["icon"] = cls.ICON if cls.ICON else Icon.Extension.value meta["color"] = cls.COLOR if cls.COLOR else "rgb(255, 255, 255)" + meta["requires_download"] = bool(getattr(cls, "REQUIRES_DOWNLOAD", False)) + meta["download_size_bytes"] = getattr(cls, "DOWNLOAD_SIZE_BYTES", None) if meta.get("input_cardinality") is None: meta["input_cardinality"] = {"min": 1} diff --git a/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx b/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx index dbbadcb14..bb18cc4cd 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx @@ -6,14 +6,30 @@ import { useTourContext } from "../../tour/TourProvider"; import { groupByCategory, sortCategories } from "./toolCategories"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; +import { useSnackbar } from "notistack"; import { useExplorersAndConverters } from "../context/ExplorersAndConvertersContext"; +import { startComponentDownload } from "../../models/model/ComponentDownloadControl"; +import CredentialsDialog from "../../credentials/CredentialsDialog"; +import { useToolGate } from "./useToolGate"; + +function ResolveDrop({ tool, onUse, onDownload, onNeedsCredentials }) { + const gate = useToolGate(tool); + useEffect(() => { + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + // Resolve once when the dropped tool changes; re-resolving on every state + // change could restart a download or reopen the dialog. + }, [tool?.name]); + return null; +} export default function ToolGrid({ tools, notebook, FormComponent }) { const [open, setOpen] = useState(false); const [selectedTool, setSelectedTool] = useState(null); + const [credentialsDialogOpen, setCredentialsDialogOpen] = useState(false); const tourContext = useTourContext(); const { t } = useTranslation(["datasets", "common"]); const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); const { pendingDropTool, setPendingDropTool } = useExplorersAndConverters(); const grouped = useMemo(() => groupByCategory(tools), [tools]); @@ -22,7 +38,7 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { [grouped], ); - const handleToolClick = (tool) => { + const handleUseTool = (tool) => { setSelectedTool(tool); setOpen(true); @@ -41,14 +57,23 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { } }; + const handleDownloadTool = (tool) => { + startComponentDownload({ component: tool, enqueueSnackbar, t }); + }; + + const handleNeedsCredentials = () => { + setCredentialsDialogOpen(true); + }; + + const droppedTool = useMemo( + () => tools.find((tool) => tool.name === pendingDropTool?.name), + [pendingDropTool, tools], + ); + useEffect(() => { - if (!pendingDropTool) return; - const match = tools.find((t) => t.name === pendingDropTool.name); - if (match) { - handleToolClick(match); - setPendingDropTool(null); - } - }, [pendingDropTool, tools]); + if (!droppedTool) return; + setPendingDropTool(null); + }, [droppedTool, setPendingDropTool]); if (!tools || tools.length === 0) { return ( @@ -114,7 +139,9 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { key={tool.name} tool={tool} disabled={tool.disabled} - onClick={() => handleToolClick(tool)} + onUse={() => handleUseTool(tool)} + onDownload={() => handleDownloadTool(tool)} + onNeedsCredentials={handleNeedsCredentials} /> ))} @@ -122,6 +149,15 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { ); })} + {droppedTool && ( + handleUseTool(droppedTool)} + onDownload={() => handleDownloadTool(droppedTool)} + onNeedsCredentials={handleNeedsCredentials} + /> + )} + {selectedTool && ( )} + + setCredentialsDialogOpen(false)} + /> ); } diff --git a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx index 4a71ffdb0..b3cf46917 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx @@ -1,18 +1,32 @@ import React, { useState } from "react"; -import { Box, Typography, Chip, Tooltip } from "@mui/material"; +import { Box, Typography, Tooltip, Stack } from "@mui/material"; +import { VpnKeyOutlined as KeyIcon } from "@mui/icons-material"; import HoverToolInfo from "./HoverToolInfo"; import api from "../../../api/api"; import { CategoryIcon } from "./CategoryIcon"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { setCustomDragImage } from "../../../utils/dragImage"; +import ModelDownloadStatusIcon from "../../models/model/ModelDownloadStatusIcon"; +import { useToolGate } from "./useToolGate"; -export default function ToolGridItem({ tool, disabled, onClick }) { +export default function ToolGridItem({ + tool, + disabled, + onUse, + onDownload, + onNeedsCredentials, +}) { const [anchorEl, setAnchorEl] = useState(null); const [hoveredTool, setHoveredTool] = useState(null); - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "credentials"]); const theme = useTheme(); + const gate = useToolGate({ ...tool, disabled }); + + const handleClick = () => + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + const handleMouseEnter = (event, tool) => { if (!disabled) { setAnchorEl(event.currentTarget); @@ -25,6 +39,24 @@ export default function ToolGridItem({ tool, disabled, onClick }) { setHoveredTool(null); }; + const action = + gate.locked || gate.requiresDownload ? ( + + {gate.locked && ( + + + + )} + {gate.requiresDownload && ( + + )} + + ) : null; + return ( <> { e.dataTransfer.setData( "application/x-dashai-tool", @@ -67,19 +99,21 @@ export default function ToolGridItem({ tool, disabled, onClick }) { } onMouseEnter={(e) => handleMouseEnter(e, tool)} onMouseLeave={handleMouseLeave} - onClick={disabled ? null : onClick} + onClick={handleClick} sx={{ position: "relative", - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.box, border: `1px solid ${theme.palette.ui.border}`, borderRadius: 1.5, overflow: "hidden", - cursor: disabled ? "not-allowed" : "grab", + cursor: gate.blocked + ? gate.gated + ? "pointer" + : "not-allowed" + : "grab", transition: "all 0.2s", - opacity: disabled ? 0.5 : 1, - filter: disabled ? "grayscale(0.6)" : "none", "&:hover": { bgcolor: disabled ? theme.palette.ui.disabled @@ -90,7 +124,7 @@ export default function ToolGridItem({ tool, disabled, onClick }) { transform: disabled ? "none" : "translateY(-4px)", boxShadow: disabled ? "none" : `0 8px 16px rgba(0, 0, 0, 0.2)`, }, - "&::after": disabled + "&::after": gate.blocked ? { content: '""', position: "absolute", @@ -104,17 +138,23 @@ export default function ToolGridItem({ tool, disabled, onClick }) { : {}, }} > - {/* Preview Image */} + {/* Preview Image — dimmed when blocked; the download/credential icons + below are kept out of every dimmed subtree so they keep full + color. */} @@ -142,33 +182,53 @@ export default function ToolGridItem({ tool, disabled, onClick }) { height: 28, p: 4, borderRadius: 0.75, - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.border, - color: disabled + color: gate.blocked ? theme.palette.text.disabled : theme.palette.text.primary, flexShrink: 0, + opacity: gate.blocked ? 0.5 : 1, + filter: gate.blocked ? "grayscale(0.6)" : "none", }} > + + {action && ( + + {action} + + )} {/* Title */} {tool.metadata.category ?? t("common:other")} diff --git a/DashAI/front/src/components/notebooks/tool/ToolList.jsx b/DashAI/front/src/components/notebooks/tool/ToolList.jsx index e95ce31af..21f566c0a 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolList.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolList.jsx @@ -14,14 +14,30 @@ import ConfigureToolModal from "./ConfigureToolModal"; import { useTourContext } from "../../tour/TourProvider"; import { groupByCategory, sortCategories } from "./toolCategories"; import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; import { useExplorersAndConverters } from "../context/ExplorersAndConvertersContext"; +import { startComponentDownload } from "../../models/model/ComponentDownloadControl"; +import CredentialsDialog from "../../credentials/CredentialsDialog"; +import { useToolGate } from "./useToolGate"; + +function ResolveDrop({ tool, onUse, onDownload, onNeedsCredentials }) { + const gate = useToolGate(tool); + useEffect(() => { + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + // Resolve once when the dropped tool changes; re-resolving on every state + // change could restart a download or reopen the dialog. + }, [tool?.name]); + return null; +} export default function ToolList({ tools, notebook, FormComponent }) { const theme = useTheme(); const [open, setOpen] = useState(false); const [selectedTool, setSelectedTool] = useState(null); + const [credentialsDialogOpen, setCredentialsDialogOpen] = useState(false); const tourContext = useTourContext(); const { t } = useTranslation(["datasets", "common"]); + const { enqueueSnackbar } = useSnackbar(); const { pendingDropTool, setPendingDropTool } = useExplorersAndConverters(); const grouped = useMemo(() => groupByCategory(tools), [tools]); @@ -30,7 +46,7 @@ export default function ToolList({ tools, notebook, FormComponent }) { [grouped], ); - const handleToolClick = (tool) => { + const handleUseTool = (tool) => { setSelectedTool(tool); setOpen(true); if (tourContext && tourContext.run) { @@ -40,14 +56,23 @@ export default function ToolList({ tools, notebook, FormComponent }) { } }; + const handleDownloadTool = (tool) => { + startComponentDownload({ component: tool, enqueueSnackbar, t }); + }; + + const handleNeedsCredentials = () => { + setCredentialsDialogOpen(true); + }; + + const droppedTool = useMemo( + () => tools.find((tool) => tool.name === pendingDropTool?.name), + [pendingDropTool, tools], + ); + useEffect(() => { - if (!pendingDropTool) return; - const match = tools.find((t) => t.name === pendingDropTool.name); - if (match) { - handleToolClick(match); - setPendingDropTool(null); - } - }, [pendingDropTool, tools]); + if (!droppedTool) return; + setPendingDropTool(null); + }, [droppedTool, setPendingDropTool]); if (!tools || tools.length === 0) { return ( @@ -129,7 +154,9 @@ export default function ToolList({ tools, notebook, FormComponent }) { key={tool.name} tool={tool} disabled={tool.disabled} - onClick={() => handleToolClick(tool)} + onUse={() => handleUseTool(tool)} + onDownload={() => handleDownloadTool(tool)} + onNeedsCredentials={handleNeedsCredentials} /> ))} @@ -138,6 +165,15 @@ export default function ToolList({ tools, notebook, FormComponent }) { ); })} + {droppedTool && ( + handleUseTool(droppedTool)} + onDownload={() => handleDownloadTool(droppedTool)} + onNeedsCredentials={handleNeedsCredentials} + /> + )} + {selectedTool && ( )} + + setCredentialsDialogOpen(false)} + /> ); } diff --git a/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx b/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx index 0232cd470..7b248d596 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx @@ -1,22 +1,32 @@ import React, { useState } from "react"; -import { Box, Typography, Chip, Tooltip } from "@mui/material"; +import { Box, Typography, Tooltip, Stack } from "@mui/material"; +import { VpnKeyOutlined as KeyIcon } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; import HoverToolInfo from "./HoverToolInfo"; import api from "../../../api/api"; import { CategoryIcon } from "./CategoryIcon"; import { useTranslation } from "react-i18next"; import { setCustomDragImage } from "../../../utils/dragImage"; +import ModelDownloadStatusIcon from "../../models/model/ModelDownloadStatusIcon"; +import { useToolGate } from "./useToolGate"; export default function ToolListItem({ tool, disabled = false, - onClick, + onUse, + onDownload, + onNeedsCredentials, ...props }) { const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const [hoveredTool, setHoveredTool] = useState(null); - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "credentials"]); + + const gate = useToolGate({ ...tool, disabled }); + + const handleClick = () => + gate.resolve({ onUse, onDownload, onNeedsCredentials }); const handleMouseEnter = (event, tool) => { if (!disabled) { @@ -43,8 +53,26 @@ export default function ToolListItem({ setHoveredTool(null); }; + const action = + gate.locked || gate.requiresDownload ? ( + + {gate.locked && ( + + + + )} + {gate.requiresDownload && ( + + )} + + ) : null; + return ( - <> + { e.dataTransfer.setData( "application/x-dashai-tool", @@ -87,21 +115,25 @@ export default function ToolListItem({ } onMouseEnter={(e) => handleMouseEnter(e, tool)} onMouseLeave={handleMouseLeave} - onClick={disabled ? null : onClick} + onClick={handleClick} sx={{ display: "flex", alignItems: "center", gap: 6, p: 6, - bgcolor: disabled + flex: 1, + minWidth: 0, + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.box, border: `1px solid ${theme.palette.ui.border}`, borderRadius: 1, - cursor: disabled ? "not-allowed" : "grab", + cursor: gate.blocked + ? gate.gated + ? "pointer" + : "not-allowed" + : "grab", transition: "all 0.2s", - opacity: disabled ? 0.5 : 1, - filter: disabled ? "grayscale(0.6)" : "none", position: "relative", "&:hover": { bgcolor: disabled @@ -112,7 +144,7 @@ export default function ToolListItem({ : tool.metadata.color, transform: disabled ? "none" : "translateX(4px)", }, - "&::after": disabled + "&::after": gate.blocked ? { content: '""', position: "absolute", @@ -125,84 +157,112 @@ export default function ToolListItem({ : {}, }} > - {/* Icon */} + {/* Content (tool icon, text) dimmed when the card is blocked; the + download/credential icons below stay outside it so they keep full + color. */} - - - - {/* Content */} - + {/* Icon */} - + + + {/* Content */} + + + + {tool.display_name || tool.name} + + + - {tool.display_name || tool.name} - + + {tool.metadata.category ?? t("common:other")} + + + + + {/* Download/credential status — left of the thumbnail, full color */} + {action && ( - - {tool.metadata.category ?? t("common:other")} - + {action} - + )} {/* Preview Thumbnail */} + {!disabled && ( )} - + ); } diff --git a/DashAI/front/src/components/notebooks/tool/useToolGate.js b/DashAI/front/src/components/notebooks/tool/useToolGate.js new file mode 100644 index 000000000..e6a4fd0fa --- /dev/null +++ b/DashAI/front/src/components/notebooks/tool/useToolGate.js @@ -0,0 +1,56 @@ +import { useComponentDownloadState } from "../../models/model/ComponentDownloadControl"; +import { + useCredentialStatuses, + getComponentCredentialState, +} from "../../credentials/credentialStatus"; + +/** + * Shared download/credential gating for explorer/converter tool cards, mirroring + * how model rows in the models side bar are gated. A tool is blocked when its + * dataset columns don't fit it (`disabled`) OR it requires an authenticated + * credential (`locked`) OR it requires a download that has not finished. + * + * Returns the gate state plus a `resolve` helper that dispatches a click to the + * right handler: credentials dialog, download start, or the normal "use" action. + */ +export const useToolGate = (tool) => { + const requiresDownload = Boolean(tool?.metadata?.requires_download); + const { downloaded, downloading } = useComponentDownloadState( + tool || { name: "" }, + ); + const { statuses, loaded } = useCredentialStatuses(); + const { locked, requiredPlatforms } = getComponentCredentialState( + tool || {}, + statuses, + loaded, + ); + const ready = !locked && (!requiresDownload || (downloaded && !downloading)); + const blocked = Boolean(tool?.disabled) || !ready; + const gated = locked || (requiresDownload && !(downloaded && !downloading)); + + const resolve = ({ onUse, onDownload, onNeedsCredentials }) => { + if (downloading) return; + if (locked) { + onNeedsCredentials?.(); + return; + } + if (requiresDownload && !(downloaded && !downloading)) { + onDownload?.(); + return; + } + if (tool?.disabled) return; + onUse?.(); + }; + + return { + requiresDownload, + downloaded, + downloading, + locked, + requiredPlatforms, + ready, + blocked, + gated, + resolve, + }; +}; diff --git a/tests/back/converters/test_base_converter_metadata.py b/tests/back/converters/test_base_converter_metadata.py index d3c4ecafe..3ac5f1307 100644 --- a/tests/back/converters/test_base_converter_metadata.py +++ b/tests/back/converters/test_base_converter_metadata.py @@ -21,6 +21,22 @@ def transform(self, x, y=None): return x +class _DownloadableConverter(BaseConverter): + SCHEMA = None + metadata = {} + REQUIRES_DOWNLOAD = True + DOWNLOAD_SIZE_BYTES = 1234 + + def get_output_type(self, column_name=None): + return None + + def fit(self, x, y=None): + return self + + def transform(self, x, y=None): + return x + + class _StarDtypeConverter(BaseConverter): SCHEMA = None metadata = {"allowed_types": [], "allowed_dtypes": ["*"]} @@ -92,6 +108,18 @@ def test_get_metadata_none_metadata_produces_empty_lists(): assert "restricted_dtypes" not in meta +def test_get_metadata_plain_converter_not_downloadable(): + meta = _FloatIntConverter.get_metadata() + assert meta["requires_download"] is False + assert meta["download_size_bytes"] is None + + +def test_get_metadata_downloadable_converter_metadata(): + meta = _DownloadableConverter.get_metadata() + assert meta["requires_download"] is True + assert meta["download_size_bytes"] == 1234 + + def test_get_metadata_categorical_text_serialized_correctly(): class _CatTextConverter(BaseConverter): SCHEMA = None diff --git a/tests/back/exploration/test_base_explorer_metadata.py b/tests/back/exploration/test_base_explorer_metadata.py index 9eebb5901..ddc4a733c 100644 --- a/tests/back/exploration/test_base_explorer_metadata.py +++ b/tests/back/exploration/test_base_explorer_metadata.py @@ -22,6 +22,27 @@ def get_results(self, exploration_path, options): return _StubExplorer +def _make_downloadable_explorer(): + """Return a minimal concrete BaseExplorer that requires a download.""" + + class _DownloadableExplorer(BaseExplorer): + SCHEMA = BaseExplorerSchema + metadata = {} + REQUIRES_DOWNLOAD = True + DOWNLOAD_SIZE_BYTES = 5678 + + def launch_exploration(self, dataset, explorer_info): + return None + + def save_notebook(self, notebook_info, explorer_info, save_path, result): + return "" + + def get_results(self, exploration_path, options): + return {} + + return _DownloadableExplorer + + # --- get_metadata tests --- @@ -72,6 +93,20 @@ def get_results(self, exploration_path, options): assert "restricted_dtypes" not in meta +def test_get_metadata_plain_explorer_not_downloadable(): + cls = _make_explorer() + meta = cls.get_metadata() + assert meta["requires_download"] is False + assert meta["download_size_bytes"] is None + + +def test_get_metadata_downloadable_explorer_metadata(): + cls = _make_downloadable_explorer() + meta = cls.get_metadata() + assert meta["requires_download"] is True + assert meta["download_size_bytes"] == 5678 + + def test_get_metadata_does_not_mutate_class_attribute(): cls = _make_explorer(allowed_types=[Float], allowed_dtypes=[]) original_metadata = dict(cls.metadata)