From c9eda6146b711011ad27c80e9bd78761267e5f91 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Mon, 29 Apr 2024 10:46:46 -0400 Subject: [PATCH] [RHOAIENG-2977] Artifacts table --- .../cypress/pages/pipelines/experiments.ts | 2 +- .../pages/pipelines/pipelineFilterBar.ts | 2 +- .../pages/pipelines/pipelineRunTable.ts | 4 +- frontend/src/components/table/TableBase.tsx | 29 ++- .../content/tables/PipelineFilterBar.tsx | 45 ++-- .../pipelines/context/MlmdListContext.tsx | 61 ++++++ .../src/concepts/pipelines/context/index.ts | 1 + ...tifactsListTable.tsx => ArtifactsList.tsx} | 23 +- .../experiments/artifacts/ArtifactsTable.tsx | 200 ++++++++++++++++++ .../artifacts/GlobalArtifactsPage.tsx | 9 +- .../global/experiments/artifacts/constants.ts | 51 +++++ .../artifacts/useGetArtifactsList.ts | 35 ++- 12 files changed, 419 insertions(+), 43 deletions(-) create mode 100644 frontend/src/concepts/pipelines/context/MlmdListContext.tsx rename frontend/src/pages/pipelines/global/experiments/artifacts/{ArtifactsListTable.tsx => ArtifactsList.tsx} (68%) create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts index 12a5ad9ae0..ac6be2ab3f 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/experiments.ts @@ -140,7 +140,7 @@ class ExperimentsTable { findFilterTextField() { return this.findContainer() .findByTestId('experiment-table-toolbar') - .findByTestId('run-table-toolbar-filter-text-field'); + .findByTestId('pipeline-filter-text-field'); } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts index abaa913272..95dcce235c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineFilterBar.ts @@ -12,7 +12,7 @@ class PipelineRunFilterBar extends PipelineFilterBar { } findExperimentInput() { - return cy.findByTestId('run-table-toolbar-filter-text-field').find('#experiment-search-input'); + return cy.findByTestId('pipeline-filter-text-field').find('#experiment-search-input'); } findPipelineVersionSelect() { diff --git a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineRunTable.ts b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineRunTable.ts index 861361c7d2..46af7584be 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineRunTable.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/pipelines/pipelineRunTable.ts @@ -152,9 +152,7 @@ class PipelineRunJobTable extends PipelineRunsTable { } findFilterTextField() { - return cy - .findByTestId('schedules-table-toolbar') - .findByTestId('run-table-toolbar-filter-text-field'); + return cy.findByTestId('schedules-table-toolbar').findByTestId('pipeline-filter-text-field'); } findExperimentFilterSelect() { diff --git a/frontend/src/components/table/TableBase.tsx b/frontend/src/components/table/TableBase.tsx index 1b00e9d1d8..9c4c6ff695 100644 --- a/frontend/src/components/table/TableBase.tsx +++ b/frontend/src/components/table/TableBase.tsx @@ -46,12 +46,24 @@ type Props = { tooltip?: string; }; getColumnSort?: GetColumnSort; + disableItemCount?: boolean; } & EitherNotBoth< { disableRowRenderSupport?: boolean }, { tbodyProps?: TbodyProps & { ref?: React.Ref } } > & Omit & - Pick; + Pick< + PaginationProps, + | 'itemCount' + | 'onPerPageSelect' + | 'onSetPage' + | 'page' + | 'perPage' + | 'perPageOptions' + | 'toggleTemplate' + | 'onNextClick' + | 'onPreviousClick' + >; export const MIN_PAGE_SIZE = 10; @@ -87,26 +99,35 @@ const TableBase = ({ tbodyProps, perPage = 10, page = 1, + perPageOptions = defaultPerPageOptions, onSetPage, + onNextClick, + onPreviousClick, onPerPageSelect, getColumnSort, itemCount = 0, loading, + toggleTemplate, + disableItemCount = false, ...props }: Props): React.ReactElement => { const selectAllRef = React.useRef(null); - const showPagination = enablePagination && itemCount > MIN_PAGE_SIZE; + const showPagination = enablePagination; + const pagination = (variant: 'top' | 'bottom') => ( = { +type ToolbarFilterProps = React.ComponentProps & { children?: React.ReactNode; - filterOptions: { [key in Options]?: string }; - filterOptionRenders: Record React.ReactNode>; - filterData: Record; - onFilterUpdate: (filterType: Options, value?: string | { label: string; value: string }) => void; + filterOptions: { [key in T]?: string }; + filterOptionRenders: Record React.ReactNode>; + filterData: Record; + onFilterUpdate: (filterType: T, value?: string | { label: string; value: string }) => void; onClearFilters: () => void; + testId?: string; }; export type FilterProps = Pick< - React.ComponentProps, + React.ComponentProps, 'filterData' | 'onFilterUpdate' | 'onClearFilters' >; -const PipelineFilterBar = ({ +export function FilterToolbar({ filterOptions, filterOptionRenders, filterData, onFilterUpdate, onClearFilters, children, - ...props -}: PipelineFilterBarProps): React.JSX.Element => { - const keys = Object.keys(filterOptions) as Array; + testId = 'filter-toolbar', + ...toolbarGroupProps +}: ToolbarFilterProps): React.JSX.Element { + const keys = Object.keys(filterOptions) as Array; const [open, setOpen] = React.useState(false); - const [currentFilterType, setCurrentFilterType] = React.useState(keys[0]); - const isToolbarChip = (v: unknown): v is ToolbarChip & { key: Options } => + const [currentFilterType, setCurrentFilterType] = React.useState(keys[0]); + const isToolbarChip = (v: unknown): v is ToolbarChip & { key: T } => !!v && Object.keys(v as ToolbarChip).every((k) => ['key', 'node'].includes(k)); return ( <> - + setOpen(!open)}> + setOpen(!open)}> <> {filterOptions[currentFilterType]} @@ -60,7 +62,8 @@ const PipelineFilterBar = ({ isOpen={open} dropdownItems={keys.map((filterKey) => ( { setOpen(false); setCurrentFilterType(filterKey); @@ -69,12 +72,12 @@ const PipelineFilterBar = ({ {filterOptions[filterKey]} ))} - data-testid="pipeline-filter-dropdown" + data-testid={`${testId}-dropdown`} /> ((filterKey) => { @@ -100,7 +103,7 @@ const PipelineFilterBar = ({ .filter(isToolbarChip)} deleteChip={(_, chip) => { if (isToolbarChip(chip)) { - onFilterUpdate(chip.key); + onFilterUpdate(chip.key, ''); } }} deleteChipGroup={() => onClearFilters()} @@ -117,6 +120,10 @@ const PipelineFilterBar = ({ {children} ); -}; +} + +const PipelineFilterBar = ( + props: ToolbarFilterProps, +): React.JSX.Element => ; export default PipelineFilterBar; diff --git a/frontend/src/concepts/pipelines/context/MlmdListContext.tsx b/frontend/src/concepts/pipelines/context/MlmdListContext.tsx new file mode 100644 index 0000000000..5535a830ef --- /dev/null +++ b/frontend/src/concepts/pipelines/context/MlmdListContext.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +interface MlmdOrderBy { + field: string; + direction: 'asc' | 'desc'; +} + +interface MlmdListContextProps { + filterQuery: string | undefined; + pageToken: string | undefined; + maxResultSize: number; + orderBy: MlmdOrderBy | undefined; + setFilterQuery: (filterQuery: string | undefined) => void; + setPageToken: (pageToken: string | undefined) => void; + setMaxResultSize: (maxResultSize: number) => void; + setOrderBy: (orderBy: MlmdOrderBy | undefined) => void; +} + +const MlmdListContext = React.createContext({} as MlmdListContextProps); + +export const MlmdListContextProvider: React.FC = ({ children }) => { + const [filterQuery, setFilterQuery] = React.useState(); + const [pageToken, setPageToken] = React.useState(); + const [maxResultSize, setMaxResultSize] = React.useState(10); + const [orderBy, setOrderBy] = React.useState(); + const value = React.useMemo( + () => ({ + filterQuery, + pageToken, + maxResultSize, + orderBy, + setFilterQuery, + setPageToken, + setMaxResultSize, + setOrderBy, + }), + [filterQuery, maxResultSize, orderBy, pageToken], + ); + + return {children}; +}; + +export const useMlmdListContext = (nextPageToken?: string): MlmdListContextProps => { + // https://github.com/patternfly/patternfly-react/issues/10312 + // Force disabled state to pagination when there is no nextPageToken + React.useEffect(() => { + const paginationNextButtons = document.querySelectorAll('button[aria-label="Go to next page"]'); + + if (paginationNextButtons.length > 0) { + paginationNextButtons.forEach((button) => { + if (!nextPageToken) { + button.setAttribute('disabled', ''); + } else { + button.removeAttribute('disabled'); + } + }); + } + }, [nextPageToken]); + + return React.useContext(MlmdListContext); +}; diff --git a/frontend/src/concepts/pipelines/context/index.ts b/frontend/src/concepts/pipelines/context/index.ts index 98c15164e0..6c9e2b4c05 100644 --- a/frontend/src/concepts/pipelines/context/index.ts +++ b/frontend/src/concepts/pipelines/context/index.ts @@ -6,3 +6,4 @@ export { ViewServerModal, PipelineServerTimedOut, } from './PipelinesContext'; +export * from './MlmdListContext'; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsListTable.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsList.tsx similarity index 68% rename from frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsListTable.tsx rename to frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsList.tsx index a0e30127f3..b35fcb139c 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsListTable.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsList.tsx @@ -3,18 +3,23 @@ import React from 'react'; import { Bullseye, EmptyState, - EmptyStateBody, + EmptyStateVariant, EmptyStateHeader, EmptyStateIcon, - EmptyStateVariant, + EmptyStateBody, Spinner, } from '@patternfly/react-core'; import { ExclamationCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { useMlmdListContext } from '~/concepts/pipelines/context'; import { useGetArtifactsList } from './useGetArtifactsList'; +import { ArtifactsTable } from './ArtifactsTable'; -export const ArtifactsListTable: React.FC = () => { - const [artifacts, isArtifactsLoaded, artifactsError] = useGetArtifactsList(); +export const ArtifactsList: React.FC = () => { + const { filterQuery } = useMlmdListContext(); + const [artifactsResponse, isArtifactsLoaded, artifactsError] = useGetArtifactsList(); + const { artifacts, nextPageToken } = artifactsResponse || {}; + const filterQueryRef = React.useRef(filterQuery); if (artifactsError) { return ( @@ -39,7 +44,7 @@ export const ArtifactsListTable: React.FC = () => { ); } - if (!artifacts?.length) { + if (!artifacts?.length && !filterQuery && filterQueryRef.current === filterQuery) { return ( { ); } - return <>; + return ( + + ); }; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx new file mode 100644 index 0000000000..24e8e81613 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Flex, FlexItem, TextInput, Truncate } from '@patternfly/react-core'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { TableVariant, Td, Tr } from '@patternfly/react-table'; + +import { Artifact } from '~/third_party/mlmd'; +import { TableBase } from '~/components/table'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import PipelinesTableRowTime from '~/concepts/pipelines/content/tables/PipelinesTableRowTime'; +import { FilterToolbar } from '~/concepts/pipelines/content/tables/PipelineFilterBar'; +import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; +import { ArtifactType } from '~/concepts/pipelines/kfTypes'; +import { useMlmdListContext } from '~/concepts/pipelines/context'; +import { FilterOptions, columns, initialFilterData, options } from './constants'; + +interface ArtifactsTableProps { + artifacts: Artifact[] | null | undefined; + nextPageToken: string | undefined; + isLoaded: boolean; +} + +export const ArtifactsTable: React.FC = ({ + artifacts, + isLoaded, + nextPageToken, +}) => { + const { + maxResultSize, + setFilterQuery, + setPageToken: setRequestToken, + setMaxResultSize, + } = useMlmdListContext(nextPageToken); + const [page, setPage] = React.useState(1); + const [filterData, setFilterData] = React.useState(initialFilterData); + const onClearFilters = React.useCallback(() => setFilterData(initialFilterData), []); + const [pageTokens, setPageTokens] = React.useState>({}); + + const onFilterUpdate = React.useCallback( + (key: string, value: string | { label: string; value: string } | undefined) => + setFilterData((prevValues) => ({ ...prevValues, [key]: value })), + [], + ); + + const onNextPageClick = React.useCallback( + (_: React.SyntheticEvent, nextPage: number) => { + if (nextPageToken) { + setPageTokens((prevTokens) => ({ ...prevTokens, [nextPage]: nextPageToken })); + setRequestToken(nextPageToken); + setPage(nextPage); + } + }, + [nextPageToken, setRequestToken], + ); + + const onPrevPageClick = React.useCallback( + (_: React.SyntheticEvent, prevPage: number) => { + if (pageTokens[prevPage]) { + setRequestToken(pageTokens[prevPage]); + setPage(prevPage); + } else { + setRequestToken(undefined); + } + }, + [pageTokens, setRequestToken], + ); + + React.useEffect(() => { + if (Object.values(filterData).some((filterOption) => !!filterOption)) { + let filterQuery = ''; + + if (filterData[FilterOptions.Artifact]) { + const artifactNameQuery = `custom_properties.display_name.string_value LIKE '%${ + filterData[FilterOptions.Artifact] + }%'`; + filterQuery += filterQuery.length ? ` AND ${artifactNameQuery}` : artifactNameQuery; + } + + if (filterData[FilterOptions.Id]) { + const artifactIdQuery = `id = cast(${filterData[FilterOptions.Id]} as int64)`; + filterQuery += filterQuery.length ? ` AND ${artifactIdQuery}` : artifactIdQuery; + } + + if (filterData[FilterOptions.Type]) { + const artifactTypeQuery = `type LIKE '%${filterData[FilterOptions.Type]}%'`; + filterQuery += filterQuery.length ? ` AND ${artifactTypeQuery}` : artifactTypeQuery; + } + + setFilterQuery(filterQuery); + } else { + setFilterQuery(''); + } + }, [filterData, setFilterQuery]); + + const toolbarContent = React.useMemo( + () => ( + + filterOptions={options} + filterOptionRenders={{ + [FilterOptions.Artifact]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [FilterOptions.Id]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [FilterOptions.Type]: ({ value, onChange, ...props }) => ( + ({ + key: v, + label: v, + }))} + onChange={(v) => onChange(v)} + /> + ), + }} + filterData={filterData} + onClearFilters={onClearFilters} + onFilterUpdate={onFilterUpdate} + /> + ), + [filterData, onClearFilters, onFilterUpdate], + ); + + const rowRenderer = React.useCallback( + (artifact: Artifact.AsObject) => ( + + + {artifact.name || + artifact.customPropertiesMap.find(([name]) => name === 'display_name')?.[1].stringValue} + + {artifact.id} + {artifact.type} + + + + + + + + + + + + + + + + + + ), + [], + ); + + return ( + artifact.toObject()) ?? []} + columns={columns} + enablePagination="compact" + page={page} + perPage={maxResultSize} + disableItemCount + onNextClick={onNextPageClick} + onPreviousClick={onPrevPageClick} + onSetPage={(_, newPage) => { + if (newPage < page || !isLoaded) { + setPage(newPage); + } + }} + onPerPageSelect={(_, newSize) => setMaxResultSize(newSize)} + toggleTemplate={() => <>{maxResultSize} per page } + toolbarContent={toolbarContent} + emptyTableView={} + rowRenderer={rowRenderer} + variant={TableVariant.compact} + data-testid="artifacts-list-table" + id="artifacts-list-table" + /> + ); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/GlobalArtifactsPage.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/GlobalArtifactsPage.tsx index 2443af6acc..2e27ebee86 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/GlobalArtifactsPage.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/GlobalArtifactsPage.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import { usePipelinesAPI, MlmdListContextProvider } from '~/concepts/pipelines/context'; import PipelineServerActions from '~/concepts/pipelines/content/PipelineServerActions'; import PipelineCoreApplicationPage from '~/pages/pipelines/global/PipelineCoreApplicationPage'; import EnsureAPIAvailability from '~/concepts/pipelines/EnsureAPIAvailability'; import EnsureCompatiblePipelineServer from '~/concepts/pipelines/EnsureCompatiblePipelineServer'; import { artifactsBaseRoute } from '~/routes'; -import { ArtifactsListTable } from './ArtifactsListTable'; +import { ArtifactsList } from './ArtifactsList'; export const GlobalArtifactsPage: React.FC = () => { const pipelinesAPI = usePipelinesAPI(); @@ -17,11 +17,12 @@ export const GlobalArtifactsPage: React.FC = () => { description="View your artifacts and their metadata." headerAction={} getRedirectPath={artifactsBaseRoute} - overrideChildPadding > - + + + diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts new file mode 100644 index 0000000000..16e737bbf0 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts @@ -0,0 +1,51 @@ +import { SortableData } from '~/components/table'; +import { Artifact as MlmdArtifact } from '~/third_party/mlmd'; + +export enum FilterOptions { + Artifact = 'name', + Id = 'id', + Type = 'type', +} +export const initialFilterData: Record = { + [FilterOptions.Artifact]: '', + [FilterOptions.Id]: '', + [FilterOptions.Type]: undefined, +}; + +export const options = { + [FilterOptions.Artifact]: 'Artifact', + [FilterOptions.Id]: 'ID', + [FilterOptions.Type]: 'Type', +}; + +export const columns: SortableData[] = [ + { + label: 'Artifact', + field: 'name', + sortable: false, + width: 20, + }, + { + label: 'ID', + field: 'id', + sortable: false, + width: 10, + }, + { + label: 'Type', + field: 'type', + sortable: false, + width: 15, + }, + { + label: 'URI', + field: 'uri', + sortable: false, + }, + { + label: 'Created', + field: 'createTimeSinceEpoch', + sortable: false, + width: 15, + }, +]; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactsList.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactsList.ts index 647a8d481d..db9f88d5c2 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactsList.ts +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactsList.ts @@ -1,18 +1,43 @@ import React from 'react'; -import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import { usePipelinesAPI, useMlmdListContext } from '~/concepts/pipelines/context'; import { Artifact, GetArtifactsRequest } from '~/third_party/mlmd'; +import { ListOperationOptions } from '~/third_party/mlmd/generated/ml_metadata/proto/metadata_store_pb'; import useFetchState, { FetchState } from '~/utilities/useFetchState'; +export interface ArtifactsListResponse { + artifacts: Artifact[]; + nextPageToken: string; +} + export const useGetArtifactsList = ( refreshRate?: number, -): FetchState => { +): FetchState => { + const { pageToken, maxResultSize, filterQuery } = useMlmdListContext(); const { metadataStoreServiceClient } = usePipelinesAPI(); const fetchArtifactsList = React.useCallback(async () => { - const response = await metadataStoreServiceClient.getArtifacts(new GetArtifactsRequest()); - return response.toObject().artifactsList; - }, [metadataStoreServiceClient]); + const request = new GetArtifactsRequest(); + const listOperationOptions = new ListOperationOptions(); + + if (filterQuery) { + listOperationOptions.setFilterQuery(filterQuery); + } + + if (pageToken) { + listOperationOptions.setNextPageToken(pageToken); + } + + listOperationOptions.setMaxResultSize(maxResultSize); + request.setOptions(listOperationOptions); + + const response = await metadataStoreServiceClient.getArtifacts(request); + + return { + artifacts: response.getArtifactsList(), + nextPageToken: response.getNextPageToken(), + }; + }, [filterQuery, pageToken, maxResultSize, metadataStoreServiceClient]); return useFetchState(fetchArtifactsList, null, { refreshRate,