From 6c6a7383012749be8a2778c8abb943302de21039 Mon Sep 17 00:00:00 2001 From: Olga Nad Date: Mon, 4 Apr 2022 16:14:11 -0500 Subject: [PATCH 1/3] feat: project domain default page Signed-off-by: Olga Nad --- package.json | 2 +- .../ExecutionDetailsAppBarContent.tsx | 4 +- .../Navigation/ProjectNavigation.tsx | 26 +- src/components/Project/ProjectDashboard.tsx | 234 ++++++++++++++++++ src/components/Project/ProjectDetails.tsx | 12 +- src/components/Project/ProjectExecutions.tsx | 167 ------------- src/components/Project/strings.ts | 9 + ...ons.test.tsx => ProjectDashboard.test.tsx} | 7 +- src/components/common/DataTable.tsx | 56 +++++ .../common/DomainSettingsSection.tsx | 104 ++++++++ src/components/common/strings.ts | 16 ++ src/components/common/test/DataTable.test.tsx | 22 ++ .../test/DomainSettingsSection.test.tsx | 67 +++++ src/models/Common/constants.ts | 1 + src/routes/routes.ts | 4 +- yarn.lock | 8 +- 16 files changed, 540 insertions(+), 199 deletions(-) create mode 100644 src/components/Project/ProjectDashboard.tsx delete mode 100644 src/components/Project/ProjectExecutions.tsx create mode 100644 src/components/Project/strings.ts rename src/components/Project/test/{ProjectExecutions.test.tsx => ProjectDashboard.test.tsx} (96%) create mode 100644 src/components/common/DataTable.tsx create mode 100644 src/components/common/DomainSettingsSection.tsx create mode 100644 src/components/common/strings.ts create mode 100644 src/components/common/test/DataTable.test.tsx create mode 100644 src/components/common/test/DomainSettingsSection.test.tsx diff --git a/package.json b/package.json index c0133a7f2..4b54ebb1c 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@commitlint/cli": "^8.3.5", "@commitlint/config-conventional": "^8.3.4", "@date-io/moment": "1.3.9", - "@flyteorg/flyteidl": "0.23.1", + "@flyteorg/flyteidl": "0.24.11", "@material-ui/core": "^4.0.0", "@material-ui/icons": "^4.0.0", "@material-ui/pickers": "^3.2.2", diff --git a/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx b/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx index 2fc11f5b3..8c07f90d2 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx @@ -92,11 +92,11 @@ export const ExecutionDetailsAppBarContent: React.FC<{ const onCloseRelaunch = () => setShowRelaunchForm(false); const fromExecutionNav = new URLSearchParams(history.location.search).get('fromExecutionNav'); const backLink = fromExecutionNav - ? Routes.ProjectDetails.sections.executions.makeUrl(project, domain) + ? Routes.ProjectDetails.sections.dashboard.makeUrl(project, domain) : originalBackLink; const { recoverExecution, - recoverState: { isLoading: recovering, error, data: recoveredId }, + recoverState: { isLoading: recovering, data: recoveredId }, } = useRecoverExecutionState(); React.useEffect(() => { diff --git a/src/components/Navigation/ProjectNavigation.tsx b/src/components/Navigation/ProjectNavigation.tsx index 357781931..b4b75f645 100644 --- a/src/components/Navigation/ProjectNavigation.tsx +++ b/src/components/Navigation/ProjectNavigation.tsx @@ -3,7 +3,7 @@ import { SvgIconProps } from '@material-ui/core/SvgIcon'; import ChevronRight from '@material-ui/icons/ChevronRight'; import DeviceHub from '@material-ui/icons/DeviceHub'; import LinearScale from '@material-ui/icons/LinearScale'; -import TrendingFlat from '@material-ui/icons/TrendingFlat'; +import Dashboard from '@material-ui/icons/Dashboard'; import classnames from 'classnames'; import { useCommonStyles } from 'components/common/styles'; import { withRouteParams } from 'components/common/withRouteParams'; @@ -72,46 +72,46 @@ const ProjectNavigationImpl: React.FC = ({ const routes: ProjectRoute[] = [ { - icon: DeviceHub, + icon: Dashboard, isActive: (match, location) => { const finalMatch = match ? match : matchPath(location.pathname, { - path: Routes.WorkflowDetails.path, + path: Routes.ProjectDashboard.path, exact: false, }); return !!finalMatch; }, - path: Routes.ProjectDetails.sections.workflows.makeUrl(project.value.id, domainId), - text: 'Workflows', + path: Routes.ProjectDetails.sections.dashboard.makeUrl(project.value.id, domainId), + text: 'Project Dashboard', }, { - icon: LinearScale, + icon: DeviceHub, isActive: (match, location) => { const finalMatch = match ? match : matchPath(location.pathname, { - path: Routes.TaskDetails.path, + path: Routes.WorkflowDetails.path, exact: false, }); return !!finalMatch; }, - path: Routes.ProjectDetails.sections.tasks.makeUrl(project.value.id, domainId), - text: 'Tasks', + path: Routes.ProjectDetails.sections.workflows.makeUrl(project.value.id, domainId), + text: 'Workflows', }, { - icon: TrendingFlat, + icon: LinearScale, isActive: (match, location) => { const finalMatch = match ? match : matchPath(location.pathname, { - path: Routes.ProjectExecutions.path, + path: Routes.TaskDetails.path, exact: false, }); return !!finalMatch; }, - path: Routes.ProjectDetails.sections.executions.makeUrl(project.value.id, domainId), - text: 'Executions', + path: Routes.ProjectDetails.sections.tasks.makeUrl(project.value.id, domainId), + text: 'Tasks', }, ]; diff --git a/src/components/Project/ProjectDashboard.tsx b/src/components/Project/ProjectDashboard.tsx new file mode 100644 index 000000000..d5fb2f8b5 --- /dev/null +++ b/src/components/Project/ProjectDashboard.tsx @@ -0,0 +1,234 @@ +import { makeStyles, Theme } from '@material-ui/core/styles'; +import * as React from 'react'; +import { Typography } from '@material-ui/core'; +import { useTaskNameList } from 'components/hooks/useNamedEntity'; +import { useWorkflowExecutions } from 'components/hooks/useWorkflowExecutions'; +import { WaitForQuery } from 'components/common/WaitForQuery'; +import { useInfiniteQuery, useQuery, useQueryClient } from 'react-query'; +import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; +import { Admin } from 'flyteidl'; +import { endpointPrefixes } from 'models/Common/constants'; +import { DomainSettingsSection } from 'components/common/DomainSettingsSection'; +import { getCacheKey } from 'components/Cache/utils'; +import { ErrorBoundary } from 'components/common/ErrorBoundary'; +import { LargeLoadingSpinner } from 'components/common/LoadingSpinner'; +import { DataError } from 'components/Errors/DataError'; +import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; +import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; +import { WorkflowExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; +import { makeWorkflowExecutionListQuery } from 'components/Executions/workflowExecutionQueries'; +import { SortDirection } from 'models/AdminEntity/types'; +import { executionSortFields } from 'models/Execution/constants'; +import { Execution } from 'models/Execution/types'; +import { BarChart } from 'components/common/BarChart'; +import { + getExecutionTimeData, + getStartExecutionTime, +} from 'components/Entities/EntityExecutionsBarChart'; +import classNames from 'classnames'; +import { useExecutionShowArchivedState } from 'components/Executions/filters/useExecutionArchiveState'; +import { useOnlyMyExecutionsFilterState } from 'components/Executions/filters/useOnlyMyExecutionsFilterState'; +import { WaitForData } from 'components/common/WaitForData'; +import { history } from 'routes/history'; +import { Routes } from 'routes/routes'; +import { compact } from 'lodash'; +import t from './strings'; +import { failedToLoadExecutionsString } from './constants'; + +const useStyles = makeStyles((theme: Theme) => ({ + projectStats: { + paddingTop: theme.spacing(7), + paddingBottom: theme.spacing(7), + display: 'flex', + justifyContent: 'space-evenly', + alignItems: 'center', + }, + container: { + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + }, + header: { + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(1), + borderBottom: `1px solid ${theme.palette.divider}`, + }, + marginTop: { + marginTop: theme.spacing(2), + }, + chartContainer: { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(3), + paddingTop: theme.spacing(1), + }, +})); + +export interface ProjectDashboardProps { + projectId: string; + domainId: string; +} + +const defaultSort = { + key: executionSortFields.createdAt, + direction: SortDirection.DESCENDING, +}; + +const makeProjectDomainAttributesPath = ({ project, domain }) => + [endpointPrefixes.projectDomainAtributes, project, domain].join('/'); + +export const ProjectDashboard: React.FC = ({ + domainId: domain, + projectId: project, +}) => { + const styles = useStyles(); + const archivedFilter = useExecutionShowArchivedState(); + const filtersState = useWorkflowExecutionFiltersState(); + const onlyMyExecutionsFilterState = useOnlyMyExecutionsFilterState({}); + + const allFilters = compact([ + ...filtersState.appliedFilters, + archivedFilter.getFilter(), + onlyMyExecutionsFilterState.getFilter(), + ]); + const config = { + sort: defaultSort, + filter: allFilters, + }; + + // Remount the table whenever we change project/domain/filters to ensure + // things are virtualized correctly. + const tableKey = React.useMemo( + () => + getCacheKey({ + domain, + project, + filters: allFilters, + }), + [domain, project, allFilters], + ); + + const executionsQuery = useInfiniteQuery({ + ...makeWorkflowExecutionListQuery({ domain, project }, config), + }); + + // useInfiniteQuery returns pages of items, but the table would like a single + // flat list. + const executions = React.useMemo( + () => + executionsQuery.data?.pages + ? executionsQuery.data.pages.reduce((acc, { data }) => acc.concat(data), []) + : [], + [executionsQuery.data?.pages], + ); + + const handleBarChartItemClick = React.useCallback((item) => { + history.push(Routes.ExecutionDetails.makeUrl(item.metadata)); + }, []); + + // to show only in bar chart view + const last100Executions = useWorkflowExecutions( + { domain, project }, + { + sort: defaultSort, + filter: allFilters, + limit: 100, + }, + ); + + const fetch = React.useCallback(() => executionsQuery.fetchNextPage(), [executionsQuery]); + + const numberOfExecutions = executions.length; + const { value: tasks } = useTaskNameList({ domain, project }, {}); + const numberOfTasks = tasks.length; + + const queryClient = useQueryClient(); + + const projectDomainAttributesQuery = useQuery({ + queryKey: ['projectDomainAttributes', project, domain], + queryFn: async () => { + const projectDomainAtributes = await getAdminEntity< + Admin.ProjectDomainAttributesGetResponse, + Admin.ProjectDomainAttributesGetResponse + >( + { + path: makeProjectDomainAttributesPath({ project, domain }), + messageType: Admin.ProjectDomainAttributesGetResponse, + }, + { + params: { + resource_type: 'WORKFLOW_EXECUTION_CONFIG', + }, + }, + ); + queryClient.setQueryData( + ['projectDomainAttributes', project, domain], + projectDomainAtributes, + ); + return projectDomainAtributes; + }, + staleTime: Infinity, + }); + + const content = executionsQuery.isLoadingError ? ( + + ) : executionsQuery.isLoading ? ( + + ) : ( + + ); + + const configData = + projectDomainAttributesQuery.data?.attributes?.matchingAttributes?.workflowExecutionConfig ?? + undefined; + const renderDomainSettingsSection = () => ; + + return ( +
+
+ {t('executionsTotal', numberOfExecutions)} + {t('tasksTotal', numberOfTasks)} +
+ + {renderDomainSettingsSection} + +
+ + Last 100 Executions in the Project + +
+ + + +
+ + All Executions in the Project + + + {content} +
+
+ ); +}; diff --git a/src/components/Project/ProjectDetails.tsx b/src/components/Project/ProjectDetails.tsx index 2751fd4ab..96f346856 100644 --- a/src/components/Project/ProjectDetails.tsx +++ b/src/components/Project/ProjectDetails.tsx @@ -8,7 +8,7 @@ import { Project } from 'models/Project/types'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router'; import { Routes } from 'routes/routes'; -import { ProjectExecutions } from './ProjectExecutions'; +import { ProjectDashboard } from './ProjectDashboard'; import { ProjectTasks } from './ProjectTasks'; import { ProjectWorkflows } from './ProjectWorkflows'; @@ -27,7 +27,7 @@ export interface ProjectDetailsRouteParams { export type ProjectDetailsProps = ProjectDetailsRouteParams; const entityTypeToComponent = { - executions: ProjectExecutions, + executions: ProjectDashboard, tasks: ProjectTasks, workflows: ProjectWorkflows, }; @@ -59,7 +59,7 @@ const ProjectEntitiesByDomain: React.FC<{ ); }; -const ProjectExecutionsByDomain: React.FC<{ project: Project }> = ({ project }) => ( +const ProjectDashboardByDomain: React.FC<{ project: Project }> = ({ project }) => ( ); @@ -79,15 +79,15 @@ export const ProjectDetailsContainer: React.FC = ({ p {() => { return ( + + + - - - ); diff --git a/src/components/Project/ProjectExecutions.tsx b/src/components/Project/ProjectExecutions.tsx deleted file mode 100644 index 08da705f8..000000000 --- a/src/components/Project/ProjectExecutions.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import Typography from '@material-ui/core/Typography'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import { getCacheKey } from 'components/Cache/utils'; -import { ErrorBoundary } from 'components/common/ErrorBoundary'; -import { LargeLoadingSpinner } from 'components/common/LoadingSpinner'; -import { DataError } from 'components/Errors/DataError'; -import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; -import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; -import { WorkflowExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; -import { makeWorkflowExecutionListQuery } from 'components/Executions/workflowExecutionQueries'; -import { SortDirection } from 'models/AdminEntity/types'; -import { executionSortFields } from 'models/Execution/constants'; -import { Execution } from 'models/Execution/types'; -import * as React from 'react'; -import { useInfiniteQuery } from 'react-query'; -import { BarChart } from 'components/common/BarChart'; -import { - getExecutionTimeData, - getStartExecutionTime, -} from 'components/Entities/EntityExecutionsBarChart'; -import classNames from 'classnames'; -import { useWorkflowExecutions } from 'components/hooks/useWorkflowExecutions'; -import { useExecutionShowArchivedState } from 'components/Executions/filters/useExecutionArchiveState'; -import { useOnlyMyExecutionsFilterState } from 'components/Executions/filters/useOnlyMyExecutionsFilterState'; -import { WaitForData } from 'components/common/WaitForData'; -import { history } from 'routes/history'; -import { Routes } from 'routes/routes'; -import { compact } from 'lodash'; -import { failedToLoadExecutionsString } from './constants'; - -const useStyles = makeStyles((theme: Theme) => ({ - container: { - display: 'flex', - flex: '1 1 auto', - flexDirection: 'column', - }, - header: { - paddingBottom: theme.spacing(1), - paddingLeft: theme.spacing(1), - borderBottom: `1px solid ${theme.palette.divider}`, - }, - marginTop: { - marginTop: theme.spacing(2), - }, - chartContainer: { - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(3), - paddingTop: theme.spacing(1), - }, -})); -export interface ProjectExecutionsProps { - projectId: string; - domainId: string; -} - -const defaultSort = { - key: executionSortFields.createdAt, - direction: SortDirection.DESCENDING, -}; - -/** A listing of all executions across a project/domain combination. */ -export const ProjectExecutions: React.FC = ({ - domainId: domain, - projectId: project, -}) => { - const styles = useStyles(); - const archivedFilter = useExecutionShowArchivedState(); - const filtersState = useWorkflowExecutionFiltersState(); - const onlyMyExecutionsFilterState = useOnlyMyExecutionsFilterState({}); - - const allFilters = compact([ - ...filtersState.appliedFilters, - archivedFilter.getFilter(), - onlyMyExecutionsFilterState.getFilter(), - ]); - const config = { - sort: defaultSort, - filter: allFilters, - }; - - // Remount the table whenever we change project/domain/filters to ensure - // things are virtualized correctly. - const tableKey = React.useMemo( - () => - getCacheKey({ - domain, - project, - filters: allFilters, - }), - [domain, project, allFilters], - ); - - const query = useInfiniteQuery({ - ...makeWorkflowExecutionListQuery({ domain, project }, config), - }); - - // useInfiniteQuery returns pages of items, but the table would like a single - // flat list. - const executions = React.useMemo( - () => - query.data?.pages - ? query.data.pages.reduce((acc, { data }) => acc.concat(data), []) - : [], - [query.data?.pages], - ); - - const handleBarChartItemClick = React.useCallback((item) => { - history.push(Routes.ExecutionDetails.makeUrl(item.metadata)); - }, []); - - // to show only in bar chart view - const last100Executions = useWorkflowExecutions( - { domain, project }, - { - sort: defaultSort, - filter: allFilters, - limit: 100, - }, - ); - - const fetch = React.useCallback(() => query.fetchNextPage(), [query]); - - const content = query.isLoadingError ? ( - - ) : query.isLoading ? ( - - ) : ( - - ); - - return ( -
- - Last 100 Executions in the Project - -
- - - -
- - All Executions in the Project - - - {content} -
- ); -}; diff --git a/src/components/Project/strings.ts b/src/components/Project/strings.ts new file mode 100644 index 000000000..81ca60343 --- /dev/null +++ b/src/components/Project/strings.ts @@ -0,0 +1,9 @@ +import { createLocalizedString } from 'basics/Locale'; + +const str = { + executionsTotal: (n: number) => `${n} Executions`, + tasksTotal: (n: number) => `${n} Tasks`, +}; + +export { patternKey } from 'basics/Locale'; +export default createLocalizedString(str); diff --git a/src/components/Project/test/ProjectExecutions.test.tsx b/src/components/Project/test/ProjectDashboard.test.tsx similarity index 96% rename from src/components/Project/test/ProjectExecutions.test.tsx rename to src/components/Project/test/ProjectDashboard.test.tsx index 4772c5119..c1fb47521 100644 --- a/src/components/Project/test/ProjectExecutions.test.tsx +++ b/src/components/Project/test/ProjectDashboard.test.tsx @@ -17,16 +17,15 @@ import { createTestQueryClient, disableQueryLogger, enableQueryLogger } from 'te import { APIContext } from 'components/data/apiContext'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; import { getUserProfile } from 'models/Common/api'; -import { ProjectExecutions } from '../ProjectExecutions'; +import { ProjectDashboard } from '../ProjectDashboard'; import { failedToLoadExecutionsString } from '../constants'; jest.mock('components/Executions/Tables/WorkflowExecutionsTable'); -// jest.mock('components/common/LoadingSpinner'); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), })); -describe('ProjectExecutions', () => { +describe('ProjectDashboard', () => { let basicPythonFixture: ReturnType; let failedTaskFixture: ReturnType; let executions1: Execution[]; @@ -76,7 +75,7 @@ describe('ProjectExecutions', () => { getUserProfile: mockGetUserProfile, })} > - + , { wrapper: MemoryRouter }, diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx new file mode 100644 index 000000000..6f12a421e --- /dev/null +++ b/src/components/common/DataTable.tsx @@ -0,0 +1,56 @@ +import { makeStyles, Theme } from '@material-ui/core/styles'; +import * as React from 'react'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@material-ui/core'; +import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; + +const useStyles = makeStyles((theme: Theme) => ({ + headerCell: { + padding: theme.spacing(1, 0, 1, 0), + color: COLOR_SPECTRUM.gray40.color, + }, + cell: { + padding: theme.spacing(1, 0, 1, 0), + minWidth: '100px', + }, + cellLeft: { + padding: theme.spacing(1, 1, 1, 0), + minWidth: '100px', + }, +})); + +export interface DataTableProps { + data: { [k: string]: string }; +} + +export const DataTable: React.FC = ({ data }) => { + const styles = useStyles(); + + return ( + + + + + Key + Value + + + + {Object.keys(data).map((key) => ( + + {key} + {data[key]} + + ))} + +
+
+ ); +}; diff --git a/src/components/common/DomainSettingsSection.tsx b/src/components/common/DomainSettingsSection.tsx new file mode 100644 index 000000000..54d1901c8 --- /dev/null +++ b/src/components/common/DomainSettingsSection.tsx @@ -0,0 +1,104 @@ +import { makeStyles, Theme } from '@material-ui/core/styles'; +import * as React from 'react'; +import { Typography } from '@material-ui/core'; +import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; +import { DataTable } from 'components/common/DataTable'; +import { Admin } from 'flyteidl'; +import { isEmpty } from 'lodash'; +import t from './strings'; + +const useStyles = makeStyles((theme: Theme) => ({ + domainSettingsWrapper: { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + domainSettings: { + marginTop: theme.spacing(1), + borderTop: `1px solid ${COLOR_SPECTRUM.gray15.color}`, + padding: theme.spacing(2, 4, 0, 4), + display: 'flex', + justifyContent: 'space-between', + }, + sectionHeader: { + margin: 0, + fontWeight: 700, + fontSize: '16px', + }, + subHeader: { + margin: 0, + paddingBottom: theme.spacing(2), + fontSize: '16px', + fontWeight: 600, + }, + grayText: { + padding: theme.spacing(1, 0, 1, 0), + color: COLOR_SPECTRUM.gray40.color, + }, +})); + +interface DomainSettingsSectionProps { + configData?: Admin.IWorkflowExecutionConfig; +} + +export const DomainSettingsSection = ({ configData }: DomainSettingsSectionProps) => { + const styles = useStyles(); + if (!configData || isEmpty(configData)) { + return null; + } + + const role = configData.securityContext?.runAs?.iamRole || t('inherited'); + const serviceAccount = configData.securityContext?.runAs?.k8sServiceAccount || t('inherited'); + const rawData = configData.rawOutputDataConfig?.outputLocationPrefix || t('inherited'); + const maxParallelism = configData.maxParallelism || undefined; + + return ( +
+

{t('domainSettingsTitle')}

+
+
+

{t('securityContextHeader')}

+
+ + {t('iamRoleHeader')} + + {role} +
+
+ + {t('serviceAccountHeader')} + + {serviceAccount} +
+
+
+

{t('labelsHeader')}

+ {configData.labels?.values ? ( + + ) : ( + t('inherited') + )} +
+
+

{t('annotationsHeader')}

+ {configData.annotations?.values ? ( + + ) : ( + t('inherited') + )} +
+
+
+

{t('rawDataHeader')}

+ {rawData} +
+
+

+ {t('maxParallelismHeader')} +

+ {maxParallelism ?? t('inherited')} +
+
+
+
+ ); +}; diff --git a/src/components/common/strings.ts b/src/components/common/strings.ts new file mode 100644 index 000000000..bfc5636fb --- /dev/null +++ b/src/components/common/strings.ts @@ -0,0 +1,16 @@ +import { createLocalizedString } from 'basics/Locale'; + +const str = { + annotationsHeader: 'Annotations', + domainSettingsTitle: 'Domain Settings', + iamRoleHeader: 'IAM Role', + inherited: 'Inherits from project level values', + labelsHeader: 'Labels', + maxParallelismHeader: 'Max_parallelism', + rawDataHeader: 'Raw output data config', + securityContextHeader: 'Security Context', + serviceAccountHeader: 'Service Account', +}; + +export { patternKey } from 'basics/Locale'; +export default createLocalizedString(str); diff --git a/src/components/common/test/DataTable.test.tsx b/src/components/common/test/DataTable.test.tsx new file mode 100644 index 000000000..4ed7fe902 --- /dev/null +++ b/src/components/common/test/DataTable.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import * as React from 'react'; + +import { DataTable } from '../DataTable'; + +const mockData = { key1: 'value1', key2: 'value2' }; + +describe('DataTable', () => { + it('should render a table with mocked data', () => { + const { getAllByRole } = render(); + const headers = getAllByRole('columnheader'); + const cells = getAllByRole('cell'); + expect(headers).toHaveLength(2); + expect(headers[0]).toHaveTextContent('Key'); + expect(headers[1]).toHaveTextContent('Value'); + expect(cells).toHaveLength(4); + expect(cells[0]).toHaveTextContent('key1'); + expect(cells[1]).toHaveTextContent('value1'); + expect(cells[2]).toHaveTextContent('key2'); + expect(cells[3]).toHaveTextContent('value2'); + }); +}); diff --git a/src/components/common/test/DomainSettingsSection.test.tsx b/src/components/common/test/DomainSettingsSection.test.tsx new file mode 100644 index 000000000..fdcb24039 --- /dev/null +++ b/src/components/common/test/DomainSettingsSection.test.tsx @@ -0,0 +1,67 @@ +import { render } from '@testing-library/react'; +import * as React from 'react'; + +import { DomainSettingsSection } from '../DomainSettingsSection'; + +const serviceAccount = 'default'; +const rawData = 'cliOutputLocationPrefix'; +const maxParallelism = 10; + +const mockConfigData = { + maxParallelism: maxParallelism, + securityContext: { runAs: { k8sServiceAccount: serviceAccount } }, + rawOutputDataConfig: { outputLocationPrefix: rawData }, + annotations: { values: { cliAnnotationKey: 'cliAnnotationValue' } }, + labels: { values: { cliLabelKey: 'cliLabelValue' } }, +}; +const mockConfigDataWithoutLabels = { + maxParallelism: maxParallelism, + securityContext: { runAs: { k8sServiceAccount: serviceAccount } }, + rawOutputDataConfig: { outputLocationPrefix: rawData }, + annotations: { values: { cliAnnotationKey: 'cliAnnotationValue' } }, +}; + +describe('DomainSettingsSection', () => { + it('should not render a block if config data passed is empty', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should render a section with mocked data', () => { + const { queryByText, getAllByRole } = render( + , + ); + expect(queryByText('Domain Settings')).toBeInTheDocument(); + // should display serviceAccount value + expect(queryByText(serviceAccount)).toBeInTheDocument(); + // should display rawData value + expect(queryByText(rawData)).toBeInTheDocument(); + // should display maxParallelism value + expect(queryByText(maxParallelism)).toBeInTheDocument(); + // should display 2 data tables + const tables = getAllByRole('table'); + expect(tables).toHaveLength(2); + // should display a placeholder text, as role was not passed + const emptyRole = queryByText('Inherits from project level values'); + expect(emptyRole).toBeInTheDocument(); + }); + + it('should render a section with mocked data', () => { + const { queryByText, queryAllByText, getAllByRole } = render( + , + ); + expect(queryByText('Domain Settings')).toBeInTheDocument(); + // should display serviceAccount value + expect(queryByText(serviceAccount)).toBeInTheDocument(); + // should display rawData value + expect(queryByText(rawData)).toBeInTheDocument(); + // should display maxParallelism value + expect(queryByText(maxParallelism)).toBeInTheDocument(); + // should display 1 data table + const tables = getAllByRole('table'); + expect(tables).toHaveLength(1); + // should display two placeholder text, as role and labels were not passed + const inheritedPlaceholders = queryAllByText('Inherits from project level values'); + expect(inheritedPlaceholders).toHaveLength(2); + }); +}); diff --git a/src/models/Common/constants.ts b/src/models/Common/constants.ts index b40484545..df752491c 100644 --- a/src/models/Common/constants.ts +++ b/src/models/Common/constants.ts @@ -11,6 +11,7 @@ export const endpointPrefixes = { nodeExecution: '/node_executions', dynamicWorkflowExecution: '/data/node_executions', project: '/projects', + projectDomainAtributes: '/project_domain_attributes', relaunchExecution: '/executions/relaunch', recoverExecution: '/executions/recover', task: '/tasks', diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 65cbcc8ff..8588fb4cd 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -19,7 +19,7 @@ export class Routes { makeProjectBoundPath(project, section ? `/${section}` : ''), path: projectBasePath, sections: { - executions: { + dashboard: { makeUrl: (project: string, domain?: string) => makeProjectBoundPath(project, `/executions${domain ? `?domain=${domain}` : ''}`), path: `${projectBasePath}/executions`, @@ -37,7 +37,7 @@ export class Routes { }, }; - static ProjectExecutions = { + static ProjectDashboard = { makeUrl: (project: string, domain: string) => makeProjectDomainBoundPath(project, domain, '/executions'), path: `${projectDomainBasePath}/executions`, diff --git a/yarn.lock b/yarn.lock index 4d7664047..a0ad6f557 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1731,10 +1731,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@flyteorg/flyteidl@0.23.1": - version "0.23.1" - resolved "https://registry.yarnpkg.com/@flyteorg/flyteidl/-/flyteidl-0.23.1.tgz#da88166e1c0bd404a4f00db425fe07afaaa76a8e" - integrity sha512-3M6o3EObbE35cFoY88/DNPcKEp+g2mOuFc2DNlCFntKH1lsB+EEl9k+chuWy8F8iWe7DHkCKJvM5AeEjSseahg== +"@flyteorg/flyteidl@0.24.11": + version "0.24.11" + resolved "https://registry.yarnpkg.com/@flyteorg/flyteidl/-/flyteidl-0.24.11.tgz#a1a6e17a0cd9303cf335fae50162d66c6a0ed980" + integrity sha512-PBRei4Yu5wHp8Fa683EW2xYllyApNcZOZwEh7f1tf68tnhrrvV455HBKez2U1qcqFg179qTy7VNX1zvjGCMmuA== "@gar/promisify@^1.0.1": version "1.1.3" From 38d1374ab1b26a745b5c6e69eda83ada22e9cdc0 Mon Sep 17 00:00:00 2001 From: Olga Nad Date: Tue, 5 Apr 2022 16:23:54 -0500 Subject: [PATCH 2/3] feat: add tests, address feedback Signed-off-by: Olga Nad --- src/components/Project/ProjectDashboard.tsx | 41 +++++++------------ src/components/Project/strings.ts | 4 +- .../Project/test/ProjectDashboard.test.tsx | 28 +++++++++++++ src/components/common/strings.ts | 2 +- .../test/DomainSettingsSection.test.tsx | 38 ++++++++++++++--- src/models/Project/api.ts | 18 ++++++++ src/models/Project/utils.ts | 8 ++++ 7 files changed, 105 insertions(+), 34 deletions(-) diff --git a/src/components/Project/ProjectDashboard.tsx b/src/components/Project/ProjectDashboard.tsx index d5fb2f8b5..7b71c4465 100644 --- a/src/components/Project/ProjectDashboard.tsx +++ b/src/components/Project/ProjectDashboard.tsx @@ -1,13 +1,11 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import * as React from 'react'; import { Typography } from '@material-ui/core'; -import { useTaskNameList } from 'components/hooks/useNamedEntity'; +import { useTaskNameList, useWorkflowNameList } from 'components/hooks/useNamedEntity'; import { useWorkflowExecutions } from 'components/hooks/useWorkflowExecutions'; import { WaitForQuery } from 'components/common/WaitForQuery'; import { useInfiniteQuery, useQuery, useQueryClient } from 'react-query'; -import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; import { Admin } from 'flyteidl'; -import { endpointPrefixes } from 'models/Common/constants'; import { DomainSettingsSection } from 'components/common/DomainSettingsSection'; import { getCacheKey } from 'components/Cache/utils'; import { ErrorBoundary } from 'components/common/ErrorBoundary'; @@ -32,6 +30,7 @@ import { WaitForData } from 'components/common/WaitForData'; import { history } from 'routes/history'; import { Routes } from 'routes/routes'; import { compact } from 'lodash'; +import { getProjectDomainAttributes } from 'models/Project/api'; import t from './strings'; import { failedToLoadExecutionsString } from './constants'; @@ -73,9 +72,6 @@ const defaultSort = { direction: SortDirection.DESCENDING, }; -const makeProjectDomainAttributesPath = ({ project, domain }) => - [endpointPrefixes.projectDomainAtributes, project, domain].join('/'); - export const ProjectDashboard: React.FC = ({ domainId: domain, projectId: project, @@ -137,7 +133,8 @@ export const ProjectDashboard: React.FC = ({ const fetch = React.useCallback(() => executionsQuery.fetchNextPage(), [executionsQuery]); - const numberOfExecutions = executions.length; + const { value: workflows } = useWorkflowNameList({ domain, project }, {}); + const numberOfWorkflows = workflows.length; const { value: tasks } = useTaskNameList({ domain, project }, {}); const numberOfTasks = tasks.length; @@ -146,20 +143,7 @@ export const ProjectDashboard: React.FC = ({ const projectDomainAttributesQuery = useQuery({ queryKey: ['projectDomainAttributes', project, domain], queryFn: async () => { - const projectDomainAtributes = await getAdminEntity< - Admin.ProjectDomainAttributesGetResponse, - Admin.ProjectDomainAttributesGetResponse - >( - { - path: makeProjectDomainAttributesPath({ project, domain }), - messageType: Admin.ProjectDomainAttributesGetResponse, - }, - { - params: { - resource_type: 'WORKFLOW_EXECUTION_CONFIG', - }, - }, - ); + const projectDomainAtributes = await getProjectDomainAttributes({ domain, project }); queryClient.setQueryData( ['projectDomainAttributes', project, domain], projectDomainAtributes, @@ -193,20 +177,25 @@ export const ProjectDashboard: React.FC = ({ const configData = projectDomainAttributesQuery.data?.attributes?.matchingAttributes?.workflowExecutionConfig ?? undefined; - const renderDomainSettingsSection = () => ; - return ( + const renderDomainSettingsSection = () => (
- {t('executionsTotal', numberOfExecutions)} + {t('workflowsTotal', numberOfWorkflows)} {t('tasksTotal', numberOfTasks)}
+ {' '} +
+ ); + + return ( +
{renderDomainSettingsSection}
- Last 100 Executions in the Project + {t('last100ExecutionsTitle')}
@@ -219,7 +208,7 @@ export const ProjectDashboard: React.FC = ({
- All Executions in the Project + {t('allExecutionsTitle')} `${n} Executions`, + allExecutionsTitle: 'All Executions in the Project', + last100ExecutionsTitle: 'Last 100 Executions in the Project', tasksTotal: (n: number) => `${n} Tasks`, + workflowsTotal: (n: number) => `${n} Workflows`, }; export { patternKey } from 'basics/Locale'; diff --git a/src/components/Project/test/ProjectDashboard.test.tsx b/src/components/Project/test/ProjectDashboard.test.tsx index c1fb47521..6c76b68f8 100644 --- a/src/components/Project/test/ProjectDashboard.test.tsx +++ b/src/components/Project/test/ProjectDashboard.test.tsx @@ -17,6 +17,8 @@ import { createTestQueryClient, disableQueryLogger, enableQueryLogger } from 'te import { APIContext } from 'components/data/apiContext'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; import { getUserProfile } from 'models/Common/api'; +import { getProjectDomainAttributes } from 'models/Project/api'; +import { Admin } from 'flyteidl'; import { ProjectDashboard } from '../ProjectDashboard'; import { failedToLoadExecutionsString } from '../constants'; @@ -25,6 +27,24 @@ jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), })); +const projectDomainAttributesMock: Admin.ProjectDomainAttributesDeleteResponse = { + attributes: { + matchingAttributes: { + workflowExecutionConfig: { + maxParallelism: 5, + securityContext: { runAs: { k8sServiceAccount: 'default' } }, + rawOutputDataConfig: { outputLocationPrefix: 'cliOutputLocationPrefix' }, + annotations: { values: { cliAnnotationKey: 'cliAnnotationValue' } }, + labels: { values: { cliLabelKey: 'cliLabelValue' } }, + }, + }, + }, +}; + +jest.mock('models/Project/api', () => ({ + getProjectDomainAttributes: jest.fn().mockResolvedValue(projectDomainAttributesMock), +})); + describe('ProjectDashboard', () => { let basicPythonFixture: ReturnType; let failedTaskFixture: ReturnType; @@ -81,6 +101,14 @@ describe('ProjectDashboard', () => { { wrapper: MemoryRouter }, ); + it('should display domain attributes section when config was provided', async () => { + const { getByText } = renderView(); + expect(getProjectDomainAttributes).toHaveBeenCalled(); + await waitFor(() => { + expect(getByText('Domain Settings')).toBeInTheDocument(); + }); + }); + it('should show loading spinner', async () => { mockGetUserProfile.mockResolvedValue(sampleUserProfile); const { queryByTestId } = renderView(); diff --git a/src/components/common/strings.ts b/src/components/common/strings.ts index bfc5636fb..33a3e0f0b 100644 --- a/src/components/common/strings.ts +++ b/src/components/common/strings.ts @@ -6,7 +6,7 @@ const str = { iamRoleHeader: 'IAM Role', inherited: 'Inherits from project level values', labelsHeader: 'Labels', - maxParallelismHeader: 'Max_parallelism', + maxParallelismHeader: 'Max parallelism', rawDataHeader: 'Raw output data config', securityContextHeader: 'Security Context', serviceAccountHeader: 'Service Account', diff --git a/src/components/common/test/DomainSettingsSection.test.tsx b/src/components/common/test/DomainSettingsSection.test.tsx index fdcb24039..74433e715 100644 --- a/src/components/common/test/DomainSettingsSection.test.tsx +++ b/src/components/common/test/DomainSettingsSection.test.tsx @@ -14,6 +14,7 @@ const mockConfigData = { annotations: { values: { cliAnnotationKey: 'cliAnnotationValue' } }, labels: { values: { cliLabelKey: 'cliLabelValue' } }, }; + const mockConfigDataWithoutLabels = { maxParallelism: maxParallelism, securityContext: { runAs: { k8sServiceAccount: serviceAccount } }, @@ -21,14 +22,20 @@ const mockConfigDataWithoutLabels = { annotations: { values: { cliAnnotationKey: 'cliAnnotationValue' } }, }; +const mockConfigDataWithoutLabelsAndAnnotations = { + maxParallelism: maxParallelism, + securityContext: { runAs: { k8sServiceAccount: serviceAccount } }, + rawOutputDataConfig: { outputLocationPrefix: rawData }, +}; + describe('DomainSettingsSection', () => { it('should not render a block if config data passed is empty', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); - it('should render a section with mocked data', () => { - const { queryByText, getAllByRole } = render( + it('should render a section without IAMRole data', () => { + const { queryByText, queryAllByRole } = render( , ); expect(queryByText('Domain Settings')).toBeInTheDocument(); @@ -39,15 +46,15 @@ describe('DomainSettingsSection', () => { // should display maxParallelism value expect(queryByText(maxParallelism)).toBeInTheDocument(); // should display 2 data tables - const tables = getAllByRole('table'); + const tables = queryAllByRole('table'); expect(tables).toHaveLength(2); // should display a placeholder text, as role was not passed const emptyRole = queryByText('Inherits from project level values'); expect(emptyRole).toBeInTheDocument(); }); - it('should render a section with mocked data', () => { - const { queryByText, queryAllByText, getAllByRole } = render( + it('should render a section without IAMRole and Labels data', () => { + const { queryByText, queryAllByText, queryAllByRole } = render( , ); expect(queryByText('Domain Settings')).toBeInTheDocument(); @@ -58,10 +65,29 @@ describe('DomainSettingsSection', () => { // should display maxParallelism value expect(queryByText(maxParallelism)).toBeInTheDocument(); // should display 1 data table - const tables = getAllByRole('table'); + const tables = queryAllByRole('table'); expect(tables).toHaveLength(1); // should display two placeholder text, as role and labels were not passed const inheritedPlaceholders = queryAllByText('Inherits from project level values'); expect(inheritedPlaceholders).toHaveLength(2); }); + + it('should render a section without IAMRole, Labels, Annotations data', () => { + const { queryByText, queryAllByText, queryByRole } = render( + , + ); + expect(queryByText('Domain Settings')).toBeInTheDocument(); + // should display serviceAccount value + expect(queryByText(serviceAccount)).toBeInTheDocument(); + // should display rawData value + expect(queryByText(rawData)).toBeInTheDocument(); + // should display maxParallelism value + expect(queryByText(maxParallelism)).toBeInTheDocument(); + // should not display any data tables + const tables = queryByRole('table'); + expect(tables).not.toBeInTheDocument(); + // should display three placeholder text, as role, labels, annotations were not passed + const inheritedPlaceholders = queryAllByText('Inherits from project level values'); + expect(inheritedPlaceholders).toHaveLength(3); + }); }); diff --git a/src/models/Project/api.ts b/src/models/Project/api.ts index b86cf6c55..278f40144 100644 --- a/src/models/Project/api.ts +++ b/src/models/Project/api.ts @@ -3,7 +3,9 @@ import { sortBy } from 'lodash'; import { endpointPrefixes } from 'models/Common/constants'; import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; +import { IdentifierScope } from 'models/Common/types'; import { Project } from './types'; +import { makeProjectDomainAttributesPath } from './utils'; /** Fetches the list of available `Project`s */ export const listProjects = () => @@ -15,3 +17,19 @@ export const listProjects = () => transform: ({ projects }: Admin.Projects) => sortBy(projects, (project) => `${project.name}`.toLowerCase()) as Project[], }); + +export const getProjectDomainAttributes = (scope: IdentifierScope) => + getAdminEntity< + Admin.ProjectDomainAttributesGetResponse, + Admin.ProjectDomainAttributesGetResponse + >( + { + path: makeProjectDomainAttributesPath(endpointPrefixes.projectDomainAtributes, scope), + messageType: Admin.ProjectDomainAttributesGetResponse, + }, + { + params: { + resource_type: 'WORKFLOW_EXECUTION_CONFIG', + }, + }, + ); diff --git a/src/models/Project/utils.ts b/src/models/Project/utils.ts index 59b5c7de6..6a17f7de6 100644 --- a/src/models/Project/utils.ts +++ b/src/models/Project/utils.ts @@ -1,3 +1,4 @@ +import { Identifier } from 'models/Common/types'; import { Project } from './types'; export function getProjectDomain(project: Project, domainId: string) { @@ -7,3 +8,10 @@ export function getProjectDomain(project: Project, domainId: string) { } return domain; } + +export function makeProjectDomainAttributesPath( + prefix: string, + { project, domain }: Partial, +) { + return [prefix, project, domain].join('/'); +} From 6449a6cdc07f5744d889034c6e71841ba385a557 Mon Sep 17 00:00:00 2001 From: Olga Nad Date: Tue, 5 Apr 2022 17:28:56 -0500 Subject: [PATCH 3/3] feat: always render numbers block Signed-off-by: Olga Nad --- src/components/Project/ProjectDashboard.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/Project/ProjectDashboard.tsx b/src/components/Project/ProjectDashboard.tsx index 7b71c4465..3f42c51f5 100644 --- a/src/components/Project/ProjectDashboard.tsx +++ b/src/components/Project/ProjectDashboard.tsx @@ -178,18 +178,14 @@ export const ProjectDashboard: React.FC = ({ projectDomainAttributesQuery.data?.attributes?.matchingAttributes?.workflowExecutionConfig ?? undefined; - const renderDomainSettingsSection = () => ( -
+ const renderDomainSettingsSection = () => ; + + return ( +
{t('workflowsTotal', numberOfWorkflows)} {t('tasksTotal', numberOfTasks)}
- {' '} -
- ); - - return ( -
{renderDomainSettingsSection}