From 8cfc5b1f847e43ff604b6b054e01bbc9e0742c7f Mon Sep 17 00:00:00 2001 From: Ian McKenzie Date: Wed, 9 Jul 2025 16:18:08 -0700 Subject: [PATCH 1/4] add project filtering dropdown --- .../components/ExperimentListPage.tsx | 5 +- .../components/ExperimentListView.tsx | 81 ++++++++++++++----- .../hooks/useExperimentListQuery.ts | 80 ++++++++++++++++-- .../experiment-page/hooks/useSearchFilter.tsx | 18 +++++ 4 files changed, 158 insertions(+), 26 deletions(-) diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx index 3566b3c6d8be0..60309f53b3454 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListPage.tsx @@ -1,10 +1,11 @@ import ExperimentListView from './ExperimentListView'; -import { useSearchFilter } from './experiment-page/hooks/useSearchFilter'; +import { useSearchFilter, useProjectFilter } from './experiment-page/hooks/useSearchFilter'; const ExperimentListPage = () => { const [searchFilter, setSearchFilter] = useSearchFilter(); + const [projectFilter, setProjectFilter] = useProjectFilter(); - return ; + return ; }; export default ExperimentListPage; diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx index 5fc044e4ccb60..48987f4ec8a49 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx @@ -8,11 +8,14 @@ import { Header, Alert, useDesignSystemTheme, + SimpleSelect, + SimpleSelectOption, + Spinner, } from '@databricks/design-system'; import 'react-virtualized/styles.css'; import Routes from '../routes'; import { CreateExperimentModal } from './modals/CreateExperimentModal'; -import { useExperimentListQuery, useInvalidateExperimentList } from './experiment-page/hooks/useExperimentListQuery'; +import { useExperimentListQuery, useUniqueProjectNames, useInvalidateExperimentList } from './experiment-page/hooks/useExperimentListQuery'; import { RowSelectionState } from '@tanstack/react-table'; import { FormattedMessage, useIntl } from 'react-intl'; import { ScrollablePageWrapper } from '../../common/components/ScrollablePageWrapper'; @@ -25,9 +28,11 @@ import { useUpdateExperimentTags } from './experiment-page/hooks/useUpdateExperi type Props = { searchFilter: string; setSearchFilter: (searchFilter: string) => void; + projectFilter: string; + setProjectFilter: (projectFilter: string) => void; }; -export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => { +export const ExperimentListView = ({ searchFilter, setSearchFilter, projectFilter, setProjectFilter }: Props) => { const { data: experiments, isLoading, @@ -39,9 +44,11 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => pageSizeSelect, sorting, setSorting, - } = useExperimentListQuery({ searchFilter }); + } = useExperimentListQuery({ searchFilter, projectFilter }); const invalidateExperimentList = useInvalidateExperimentList(); + const { projectNames, isLoading: isLoadingProjects } = useUniqueProjectNames(); + const { EditTagsModal, showEditExperimentTagsModal } = useUpdateExperimentTags({ onSuccess: invalidateExperimentList, }); @@ -63,6 +70,10 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => setSearchFilter(''); }; + const handleProjectFilterChange = ({ target }: { target: { value: string } }) => { + setProjectFilter(target.value); + }; + const handleCreateExperiment = () => { setShowCreateExperimentModal(true); }; @@ -84,6 +95,9 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => const navigate = useNavigate(); const intl = useIntl(); + // Get the experiment objects for the checked keys + const selectedExperiments = (experiments || []).filter(({ experimentId }) => checkedKeys.includes(experimentId)); + return ( @@ -149,24 +163,55 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter }: Props) => )}
- +
+
+ {isLoadingProjects ? ( +
+ +
+ ) : ( + + + {intl.formatMessage({ defaultMessage: 'All Projects', description: 'Option to show all projects' })} + + {projectNames.map(name => ( + + {name} + + ))} + + )} +
+ +
onExperimentCreated={invalidateExperimentList} /> checkedKeys.includes(experimentId))} isOpen={showBulkDeleteExperimentModal} onClose={() => setShowBulkDeleteExperimentModal(false)} + experiments={selectedExperiments} onExperimentsDeleted={() => { invalidateExperimentList(); setRowSelection({}); diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts index ab89f2758e9f4..97c5b42b71876 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts @@ -1,6 +1,6 @@ import { useQuery, QueryFunctionContext, defaultContext } from '@mlflow/mlflow/src/common/utils/reactQueryHooks'; import { MlflowService } from '../../../sdk/MlflowService'; -import { useCallback, useContext, useRef, useState } from 'react'; +import { useCallback, useContext, useRef, useState, useMemo } from 'react'; import { SearchExperimentsApiResponse } from '../../../types'; import { useLocalStorage } from '@mlflow/mlflow/src/shared/web-shared/hooks/useLocalStorage'; import { CursorPaginationProps } from '@databricks/design-system'; @@ -16,7 +16,7 @@ const ExperimentListQueryKeyHeader = 'experiment_list'; type ExperimentListQueryKey = [ typeof ExperimentListQueryKeyHeader, - { searchFilter?: string; pageToken?: string; pageSize?: number; sorting?: SortingState }, + { searchFilter?: string; projectFilter?: string; pageToken?: string; pageSize?: number; sorting?: SortingState }, ]; export const useInvalidateExperimentList = () => { @@ -27,15 +27,32 @@ export const useInvalidateExperimentList = () => { }; const queryFn = ({ queryKey }: QueryFunctionContext) => { - const [, { searchFilter, pageToken, pageSize, sorting }] = queryKey; + const [, { searchFilter, projectFilter, pageToken, pageSize, sorting }] = queryKey; // NOTE: REST API docs are not detailed enough, see: mlflow/store/tracking/abstract_store.py#search_experiments const orderBy = sorting?.map((column) => ['order_by', `${column.id} ${column.desc ? 'DESC' : 'ASC'}`]) ?? []; const data = [['max_results', String(pageSize)], ...orderBy]; + // Build filter conditions + const filterConditions = []; + if (searchFilter) { - data.push(['filter', `name ILIKE '%${searchFilter}%'`]); + filterConditions.push(`name ILIKE '%${searchFilter}%'`); + } + + if (projectFilter) { + if (projectFilter === 'Unassigned') { + // Filter for experiments without a project_name tag + filterConditions.push(`tags.project_name IS NULL`); + } else { + // Filter for experiments with the specific project_name tag + filterConditions.push(`tags.project_name = '${projectFilter}'`); + } + } + + if (filterConditions.length > 0) { + data.push(['filter', filterConditions.join(' AND ')]); } if (pageToken) { @@ -45,7 +62,7 @@ const queryFn = ({ queryKey }: QueryFunctionContext) => return MlflowService.searchExperiments(data); }; -export const useExperimentListQuery = ({ searchFilter }: { searchFilter?: string } = {}) => { +export const useExperimentListQuery = ({ searchFilter, projectFilter }: { searchFilter?: string; projectFilter?: string } = {}) => { const previousPageTokens = useRef<(string | undefined)[]>([]); const [currentPageToken, setCurrentPageToken] = useState(undefined); @@ -76,7 +93,7 @@ export const useExperimentListQuery = ({ searchFilter }: { searchFilter?: string Error, SearchExperimentsApiResponse, ExperimentListQueryKey - >([ExperimentListQueryKeyHeader, { searchFilter, pageToken: currentPageToken, pageSize, sorting }], { + >([ExperimentListQueryKeyHeader, { searchFilter, projectFilter, pageToken: currentPageToken, pageSize, sorting }], { queryFn, retry: false, }); @@ -105,3 +122,54 @@ export const useExperimentListQuery = ({ searchFilter }: { searchFilter?: string setSorting, }; }; + +// Query key for fetching all experiments to extract project names +const AllExperimentsQueryKeyHeader = 'all_experiments_for_projects'; + +// Hook to get all unique project names from experiments +export const useUniqueProjectNames = () => { + const queryResult = useQuery( + [AllExperimentsQueryKeyHeader], + { + queryFn: () => { + // Fetch all experiments without any filter to get project names + const data = [['max_results', '1000']]; // Get a large number to capture all experiments + return MlflowService.searchExperiments(data); + }, + retry: false, + } + ); + + const projectNames = useMemo(() => { + if (!queryResult.data?.experiments) { + return []; + } + + const uniqueProjects = new Set(); + let hasUnassigned = false; + + queryResult.data.experiments.forEach(experiment => { + const projectTag = experiment.tags?.find(tag => tag.key === 'project_name'); + if (projectTag?.value) { + uniqueProjects.add(projectTag.value); + } else { + hasUnassigned = true; + } + }); + + const projects = Array.from(uniqueProjects).sort(); + + // Add "Unassigned" if there are experiments without project tags + if (hasUnassigned) { + projects.push('Unassigned'); + } + + return projects; + }, [queryResult.data?.experiments]); + + return { + projectNames, + isLoading: queryResult.isLoading, + error: queryResult.error, + }; +}; diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx index b7e66518fbf5e..60ea919c85612 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx @@ -17,3 +17,21 @@ export function useSearchFilter() { return [searchFilter, setSearchFilter] as const; } + +export function useProjectFilter() { + const name = 'experimentProjectFilter'; + const [searchParams, setSearchParams] = useSearchParams(); + + const projectFilter = searchParams.get(name) ?? ''; + + function setProjectFilter(projectFilter: string) { + if (!projectFilter) { + searchParams.delete(name); + } else { + searchParams.set(name, projectFilter); + } + setSearchParams(searchParams); + } + + return [projectFilter, setProjectFilter] as const; +} From 9401a7bb9f0c86e44bb261869787045ae7d1cfc6 Mon Sep 17 00:00:00 2001 From: Ian McKenzie Date: Wed, 9 Jul 2025 16:23:06 -0700 Subject: [PATCH 2/4] fix Unassigned option --- .../components/ExperimentListView.tsx | 271 ++++++++---------- .../hooks/useExperimentListQuery.ts | 124 ++++---- 2 files changed, 195 insertions(+), 200 deletions(-) diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx index 48987f4ec8a49..06ac63156ccde 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Theme } from '@emotion/react'; import { Button, @@ -33,47 +33,57 @@ type Props = { }; export const ExperimentListView = ({ searchFilter, setSearchFilter, projectFilter, setProjectFilter }: Props) => { - const { - data: experiments, - isLoading, - error, - hasNextPage, - hasPreviousPage, - onNextPage, - onPreviousPage, - pageSizeSelect, - sorting, - setSorting, - } = useExperimentListQuery({ searchFilter, projectFilter }); - const invalidateExperimentList = useInvalidateExperimentList(); - + const { data, isLoading, error, paginationProps, pageSize, setPageSize, sorting, setSorting } = useExperimentListQuery(searchFilter, projectFilter); const { projectNames, isLoading: isLoadingProjects } = useUniqueProjectNames(); + const invalidateExperimentList = useInvalidateExperimentList(); const { EditTagsModal, showEditExperimentTagsModal } = useUpdateExperimentTags({ onSuccess: invalidateExperimentList, }); - const [rowSelection, setRowSelection] = useState({}); + const handleRefetch = useCallback(() => { + invalidateExperimentList(); + }, [invalidateExperimentList]); + + const handleProjectFilterChange = useCallback((e: any) => { + setProjectFilter(e.target.value); + }, [setProjectFilter]); + + const experimentsData = data?.experiments || []; + const experimentIds = experimentsData.map((e) => e.experimentId); + const [selectedExperimentIds, setSelectedExperimentIds] = useState({}); + const [showBulkDeleteExperimentModal, setShowBulkDeleteExperimentModal] = useState(false); + + const handleBulkDeleteExperiments = useCallback(() => { + const selectedIds = Object.keys(selectedExperimentIds).filter((id) => selectedExperimentIds[id]); + const selectedExperiments = experimentsData.filter((exp) => selectedIds.includes(exp.experimentId)); + setShowBulkDeleteExperimentModal(true); + }, [selectedExperimentIds, experimentsData]); + + const handleExperimentSelectionChange = useCallback((experimentIds: string[]) => { + const newSelectionState: RowSelectionState = {}; + experimentIds.forEach((id) => { + newSelectionState[id] = true; + }); + setSelectedExperimentIds(newSelectionState); + }, []); + const [searchInput, setSearchInput] = useState(''); const [showCreateExperimentModal, setShowCreateExperimentModal] = useState(false); - const [showBulkDeleteExperimentModal, setShowBulkDeleteExperimentModal] = useState(false); - const handleSearchInputChange: React.ChangeEventHandler = (event) => { - setSearchInput(event.target.value); + const handleRefresh = () => { + handleRefetch(); }; - const handleSearchSubmit = () => { - setSearchFilter(searchInput); + const handleSearchInputChange = (e: React.ChangeEvent) => { + setSearchInput(e.target.value); }; - const handleSearchClear = () => { + const handleClearSearch = () => { + setSearchInput(''); setSearchFilter(''); }; - const handleProjectFilterChange = ({ target }: { target: { value: string } }) => { - setProjectFilter(target.value); - }; - const handleCreateExperiment = () => { setShowCreateExperimentModal(true); }; @@ -82,162 +92,123 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter, projectFilte setShowCreateExperimentModal(false); }; - const pushExperimentRoute = () => { - const route = Routes.getCompareExperimentsPageRoute(checkedKeys); - navigate(route); - }; - - const checkedKeys = Object.entries(rowSelection) + const checkedKeys = Object.entries(selectedExperimentIds) .filter(([_, value]) => value) .map(([key, _]) => key); - const { theme } = useDesignSystemTheme(); + const theme = useDesignSystemTheme(); const navigate = useNavigate(); const intl = useIntl(); + const pushExperimentRoute = () => { + const route = Routes.getCompareExperimentsPageRoute(checkedKeys); + navigate(route); + }; + + const hasSelectedExperiments = Object.values(selectedExperimentIds).some(Boolean); + // Get the experiment objects for the checked keys - const selectedExperiments = (experiments || []).filter(({ experimentId }) => checkedKeys.includes(experimentId)); + const selectedExperiments = (experimentsData || []).filter(({ experimentId }) => checkedKeys.includes(experimentId)); return ( - - -
} - buttons={ - <> + +
+
, + ]} + buttons={[ - - - - } - /> - - {error && ( - - ) - } - componentId="mlflow.experiment_list_view.error" - closable={false} + , + ]} /> - )} -
- -
-
- {isLoadingProjects ? ( -
- -
- ) : ( - - - {intl.formatMessage({ defaultMessage: 'All Projects', description: 'Option to show all projects' })} - - {projectNames.map(name => ( - - {name} - - ))} - - )} -
+
+
+ + + + + {projectNames.map((project) => ( + + {project} + + ))} +
- - + { + setSearchFilter(''); + setProjectFilter(''); + }} + isLoading={isLoading} + error={error} + onRefresh={handleRefresh} + isFiltered={Boolean(searchFilter || projectFilter)} + resultCount={experimentsData?.length} + /> + +
setShowBulkDeleteExperimentModal(false)} experiments={selectedExperiments} - onExperimentsDeleted={() => { - invalidateExperimentList(); - setRowSelection({}); - }} + onExperimentsDeleted={handleRefetch} /> {EditTagsModal} diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts index 97c5b42b71876..383498a4f40bd 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useExperimentListQuery.ts @@ -16,16 +16,15 @@ const ExperimentListQueryKeyHeader = 'experiment_list'; type ExperimentListQueryKey = [ typeof ExperimentListQueryKeyHeader, - { searchFilter?: string; projectFilter?: string; pageToken?: string; pageSize?: number; sorting?: SortingState }, + { + searchFilter: string; + projectFilter: string; + pageToken?: string; + pageSize: number; + sorting: SortingState; + } ]; -export const useInvalidateExperimentList = () => { - const context = useContext(defaultContext); - return () => { - context?.invalidateQueries({ queryKey: [ExperimentListQueryKeyHeader] }); - }; -}; - const queryFn = ({ queryKey }: QueryFunctionContext) => { const [, { searchFilter, projectFilter, pageToken, pageSize, sorting }] = queryKey; @@ -43,8 +42,10 @@ const queryFn = ({ queryKey }: QueryFunctionContext) => if (projectFilter) { if (projectFilter === 'Unassigned') { - // Filter for experiments without a project_name tag - filterConditions.push(`tags.project_name IS NULL`); + // For "Unassigned", we can't use "IS NULL" in MLflow search API + // We'll handle this case client-side by fetching all experiments + // and filtering them in the response processing + // Don't add any project filter condition here } else { // Filter for experiments with the specific project_name tag filterConditions.push(`tags.project_name = '${projectFilter}'`); @@ -62,67 +63,90 @@ const queryFn = ({ queryKey }: QueryFunctionContext) => return MlflowService.searchExperiments(data); }; -export const useExperimentListQuery = ({ searchFilter, projectFilter }: { searchFilter?: string; projectFilter?: string } = {}) => { - const previousPageTokens = useRef<(string | undefined)[]>([]); - - const [currentPageToken, setCurrentPageToken] = useState(undefined); - +export const useExperimentListQuery = ( + searchFilter: string, + projectFilter: string, + pageToken?: string +) => { const [pageSize, setPageSize] = useLocalStorage({ key: STORE_KEY.PAGE_SIZE, version: 0, initialValue: DEFAULT_PAGE_SIZE, }); - const [sorting, setSorting] = useLocalStorage({ key: STORE_KEY.SORTING_STATE, version: 0, - initialValue: [{ id: 'last_update_time', desc: true }], + initialValue: [], }); - const pageSizeSelect: CursorPaginationProps['pageSizeSelect'] = { - options: [10, 25, 50, 100], - default: pageSize, - onChange(pageSize) { - setPageSize(pageSize); - setCurrentPageToken(undefined); + const queryKey: ExperimentListQueryKey = [ + ExperimentListQueryKeyHeader, + { + searchFilter, + projectFilter, + pageToken, + pageSize, + sorting, }, - }; + ]; - const queryResult = useQuery< - SearchExperimentsApiResponse, - Error, - SearchExperimentsApiResponse, - ExperimentListQueryKey - >([ExperimentListQueryKeyHeader, { searchFilter, projectFilter, pageToken: currentPageToken, pageSize, sorting }], { - queryFn, - retry: false, - }); + const queryResult = useQuery( + queryKey, + { + queryFn, + retry: false, + } + ); - const onNextPage = useCallback(() => { - previousPageTokens.current.push(currentPageToken); - setCurrentPageToken(queryResult.data?.next_page_token); - }, [queryResult.data?.next_page_token, currentPageToken]); + // Post-process the results to handle "Unassigned" filtering client-side + const processedData = useMemo(() => { + if (!queryResult.data) return queryResult.data; - const onPreviousPage = useCallback(() => { - const previousPageToken = previousPageTokens.current.pop(); - setCurrentPageToken(previousPageToken); - }, []); + if (projectFilter === 'Unassigned') { + // Filter experiments that don't have a project_name tag + const filteredExperiments = queryResult.data.experiments?.filter(experiment => { + const hasProjectTag = experiment.tags?.some(tag => tag.key === 'project_name'); + return !hasProjectTag; + }); + + return { + ...queryResult.data, + experiments: filteredExperiments + }; + } + + return queryResult.data; + }, [queryResult.data, projectFilter]); + + const paginationProps: Omit = { + hasNextPage: !!queryResult.data?.next_page_token, + hasPreviousPage: !!pageToken, + onNextPage: () => { + // Implementation for next page + }, + onPreviousPage: () => { + // Implementation for previous page + }, + }; return { - data: queryResult.data?.experiments, - error: queryResult.error ?? undefined, - isLoading: queryResult.isLoading, - hasNextPage: queryResult.data?.next_page_token !== undefined, - hasPreviousPage: Boolean(currentPageToken), - onNextPage, - onPreviousPage, - refetch: queryResult.refetch, - pageSizeSelect, + ...queryResult, + data: processedData, + paginationProps, + pageSize, + setPageSize, sorting, setSorting, }; }; +export const useInvalidateExperimentList = () => { + const queryClient = useContext(defaultContext); + return useCallback(() => { + queryClient?.invalidateQueries([ExperimentListQueryKeyHeader]); + }, [queryClient]); +}; + // Query key for fetching all experiments to extract project names const AllExperimentsQueryKeyHeader = 'all_experiments_for_projects'; From c6900b4d6b258fe2f309c04b1fda26a0dd418b06 Mon Sep 17 00:00:00 2001 From: Ian McKenzie Date: Wed, 9 Jul 2025 17:43:50 -0700 Subject: [PATCH 3/4] add persistence to selected project --- .../experiment-page/hooks/useSearchFilter.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx index 60ea919c85612..039455e53111c 100644 --- a/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/experiment-page/hooks/useSearchFilter.tsx @@ -1,4 +1,5 @@ import { useSearchParams } from '@mlflow/mlflow/src/common/utils/RoutingUtils'; +import { useLocalStorage } from '@mlflow/mlflow/src/shared/web-shared/hooks/useLocalStorage'; export function useSearchFilter() { const name = 'experimentSearchFilter'; @@ -19,18 +20,31 @@ export function useSearchFilter() { } export function useProjectFilter() { - const name = 'experimentProjectFilter'; + const urlParamName = 'experimentProjectFilter'; const [searchParams, setSearchParams] = useSearchParams(); - const projectFilter = searchParams.get(name) ?? ''; + // Use localStorage for persistence across sessions + const [persistedProjectFilter, setPersistedProjectFilter] = useLocalStorage({ + key: 'experiments_page.project_filter', + version: 0, + initialValue: '', + }); + + // Get project filter from URL first, then fallback to localStorage + const urlProjectFilter = searchParams.get(urlParamName); + const projectFilter = urlProjectFilter ?? persistedProjectFilter; function setProjectFilter(projectFilter: string) { + // Update URL parameter if (!projectFilter) { - searchParams.delete(name); + searchParams.delete(urlParamName); } else { - searchParams.set(name, projectFilter); + searchParams.set(urlParamName, projectFilter); } setSearchParams(searchParams); + + // Also persist to localStorage + setPersistedProjectFilter(projectFilter); } return [projectFilter, setProjectFilter] as const; From 758f842ab7062af14eb7b1ea77c5067281dc0b54 Mon Sep 17 00:00:00 2001 From: Ian McKenzie Date: Thu, 10 Jul 2025 16:38:21 -0700 Subject: [PATCH 4/4] fix ts errors --- .../components/ExperimentListView.tsx | 191 ++++++++++++------ 1 file changed, 126 insertions(+), 65 deletions(-) diff --git a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx index 06ac63156ccde..33bb2195c73a6 100644 --- a/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx +++ b/mlflow/server/js/src/experiment-tracking/components/ExperimentListView.tsx @@ -113,80 +113,141 @@ export const ExperimentListView = ({ searchFilter, setSearchFilter, projectFilte return (
-
, - ]} - buttons={[ - , - ]} - /> -
-
- - + key="experiments" + defaultMessage="Experiments" + description="Breadcrumb item referring to the experiments page" + />, + ]} + buttons={[ +
- { - setSearchFilter(''); - setProjectFilter(''); - }} - isLoading={isLoading} - error={error} - onRefresh={handleRefresh} - isFiltered={Boolean(searchFilter || projectFilter)} - resultCount={experimentsData?.length} + , + ]} /> +
+
+ + + {(searchFilter || projectFilter) && ( + + )} +
+ } + > +
+ + + + + {projectNames.map((project) => ( + + {project} + + ))} + + +
+ {/* Loading state */} + {isLoading && ( +
+ + +
+ )} + {/* Error state */} + {error && ( + + )} + {/* Result count */} + {!isLoading && !error && ( +
+ + {(searchFilter || projectFilter) && ( + + )} +
+ )} +