From 2ae6e4ecbb4aca4cdc5f81824a03e41f537cf689 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Tue, 23 Apr 2024 11:32:07 -0400 Subject: [PATCH] [RHOAIENG-5489] Home page: projects w/ tiles --- .../cypress/e2e/home/homeProjects.cy.ts | 95 ++++++++++++++++- .../src/components/EvenlySpacedGallery.tsx | 32 ++++++ frontend/src/components/OdhCard.scss | 9 -- frontend/src/components/OdhDocCard.tsx | 3 +- frontend/src/components/TruncatedText.tsx | 25 +++++ .../src/concepts/projects/ProjectsContext.tsx | 14 ++- .../src/pages/home/aiFlows/useAIFlows.tsx | 15 +-- .../pages/home/projects/CreateProjectCard.tsx | 63 +++++++++++ .../src/pages/home/projects/ProjectCard.tsx | 81 ++++++++++++++ .../pages/home/projects/ProjectsSection.tsx | 100 ++++++++++++++---- .../home/projects/ProjectsSectionHeader.tsx | 72 +++++++++++++ 11 files changed, 464 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/EvenlySpacedGallery.tsx create mode 100644 frontend/src/components/TruncatedText.tsx create mode 100644 frontend/src/pages/home/projects/CreateProjectCard.tsx create mode 100644 frontend/src/pages/home/projects/ProjectCard.tsx create mode 100644 frontend/src/pages/home/projects/ProjectsSectionHeader.tsx diff --git a/frontend/src/__tests__/cypress/cypress/e2e/home/homeProjects.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/home/homeProjects.cy.ts index 52311476f6..ce07228fa4 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/home/homeProjects.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/home/homeProjects.cy.ts @@ -1,7 +1,15 @@ import { initHomeIntercepts } from '~/__tests__/cypress/cypress/e2e/home/homeUtils'; import { mockSelfSubjectAccessReview } from '~/__mocks__/mockSelfSubjectAccessReview'; -import { createProjectModal } from '~/__tests__/cypress/cypress/pages/projects'; -import { SelfSubjectAccessReviewModel } from '~/__tests__/cypress/cypress/utils/models'; +import { + createProjectModal, + projectDetails, + projectListPage, +} from '~/__tests__/cypress/cypress/pages/projects'; +import { + ProjectModel, + SelfSubjectAccessReviewModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { mockProjectsK8sList } from '~/__mocks__'; const interceptAccessReview = (allowed: boolean) => { cy.interceptK8s( @@ -42,4 +50,87 @@ describe('Home page Projects section', () => { cy.findByTestId('landing-page-projects-empty').should('be.visible'); cy.findByTestId('create-project-button').should('not.exist'); }); + it('should show create project button when more projects exist', () => { + initHomeIntercepts({ disableHome: false }); + const projectsMock = mockProjectsK8sList(); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId('create-project').should('be.visible'); + cy.findByTestId('create-project-card').should('not.exist'); + }); + it('should not show create project button when more projects exist but user is not allowed', () => { + initHomeIntercepts({ disableHome: false }); + interceptAccessReview(false); + const projectsMock = mockProjectsK8sList(); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId('create-project').should('not.exist'); + cy.findByTestId('create-project-card').should('not.exist'); + cy.findByTestId('request-project-help').should('be.visible'); + cy.findByTestId('request-project-card').should('not.exist'); + }); + it('should show create project card when no more projects exist', () => { + initHomeIntercepts({ disableHome: false }); + const projectsMock = mockProjectsK8sList(); + const projects = projectsMock.items; + projectsMock.items = projects.slice(0, 2); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId('create-project').should('not.exist'); + cy.findByTestId('create-project-card').should('be.visible'); + }); + it('should show a request project card when no more projects exist but user is not allowed', () => { + initHomeIntercepts({ disableHome: false }); + interceptAccessReview(false); + const projectsMock = mockProjectsK8sList(); + const projects = projectsMock.items; + projectsMock.items = projects.slice(0, 2); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId('create-project').should('not.exist'); + cy.findByTestId('create-project-card').should('not.exist'); + cy.findByTestId('request-project-card').should('be.visible'); + cy.findByTestId('request-project-help').should('not.exist'); + }); + it('should navigate to the project when the name is clicked', () => { + initHomeIntercepts({ disableHome: false }); + interceptAccessReview(false); + const projectsMock = mockProjectsK8sList(); + const projects = projectsMock.items; + projectsMock.items = projects.slice(0, 2); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId(`project-link-${projects[0].metadata.name}`).click(); + cy.url().should('include', projects[0].metadata.name); + projectDetails.findComponent('overview').should('be.visible'); + }); + it('should navigate to the project list', () => { + initHomeIntercepts({ disableHome: false }); + interceptAccessReview(false); + const projectsMock = mockProjectsK8sList(); + const projects = projectsMock.items; + projectsMock.items = projects.slice(0, 2); + + cy.interceptK8sList(ProjectModel, projectsMock); + + cy.visit('/'); + + cy.findByTestId('goto-projects-link').click(); + projectListPage.findProjectsTable().should('be.visible'); + }); }); diff --git a/frontend/src/components/EvenlySpacedGallery.tsx b/frontend/src/components/EvenlySpacedGallery.tsx new file mode 100644 index 0000000000..08467655db --- /dev/null +++ b/frontend/src/components/EvenlySpacedGallery.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Gallery, GalleryProps } from '@patternfly/react-core'; + +type EvenlySpacedGalleryProps = Omit & { + minSize?: string; + itemCount: number; +}; + +const EvenlySpacedGallery: React.FC = ({ + minSize, + itemCount, + hasGutter, + children, + ...rest +}) => { + const galleryWidths = `calc(${100 / itemCount}%${ + hasGutter ? ` - (1rem * ${itemCount - 1} / ${itemCount})` : '' + })`; + + return ( + + {children} + + ); +}; + +export default EvenlySpacedGallery; diff --git a/frontend/src/components/OdhCard.scss b/frontend/src/components/OdhCard.scss index d0b24773e5..7e000e1fab 100644 --- a/frontend/src/components/OdhCard.scss +++ b/frontend/src/components/OdhCard.scss @@ -73,15 +73,6 @@ align-items: center; } - .pf-v5-c-card__body { - .odh-card__body-text { - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; - } - } - &__coming-soon { color: var(--pf-v5-global--disabled-color--100); font-size: var(--pf-v5-global--FontSize--md); diff --git a/frontend/src/components/OdhDocCard.tsx b/frontend/src/components/OdhDocCard.tsx index a102e38321..d7286e30fb 100644 --- a/frontend/src/components/OdhDocCard.tsx +++ b/frontend/src/components/OdhDocCard.tsx @@ -27,6 +27,7 @@ import BrandImage from './BrandImage'; import DocCardBadges from './DocCardBadges'; import { useQuickStartCardSelected } from './useQuickStartCardSelected'; import FavoriteButton from './FavoriteButton'; +import TruncatedText from './TruncatedText'; import './OdhCard.scss'; @@ -179,7 +180,7 @@ const OdhDocCard: React.FC = ({ odhDoc, favorite, updateFavorit - {odhDoc.spec.description} + diff --git a/frontend/src/components/TruncatedText.tsx b/frontend/src/components/TruncatedText.tsx new file mode 100644 index 0000000000..4046133b17 --- /dev/null +++ b/frontend/src/components/TruncatedText.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Tooltip } from '@patternfly/react-core'; + +type TruncatedTextProps = { + maxLines: number; + content: string; +} & React.HTMLProps; + +const TruncatedText: React.FC = ({ maxLines, content, ...rest }) => ( + + + {content} + + +); + +export default TruncatedText; diff --git a/frontend/src/concepts/projects/ProjectsContext.tsx b/frontend/src/concepts/projects/ProjectsContext.tsx index fb344d349d..84814de437 100644 --- a/frontend/src/concepts/projects/ProjectsContext.tsx +++ b/frontend/src/concepts/projects/ProjectsContext.tsx @@ -5,6 +5,12 @@ import { KnownLabels, ProjectKind } from '~/k8sTypes'; import { useDashboardNamespace } from '~/redux/selectors'; import { isAvailableProject } from '~/concepts/projects/utils'; +const getProjectDisplayName = (resource: ProjectKind): string => + resource.metadata.annotations?.['openshift.io/display-name'] || resource.metadata.name; + +const projectSorter = (projectA: ProjectKind, projectB: ProjectKind) => + getProjectDisplayName(projectA).localeCompare(getProjectDisplayName(projectB)); + type ProjectFetchState = FetchState; type ProjectsContextType = { projects: ProjectKind[]; @@ -128,10 +134,10 @@ const ProjectsContextProvider: React.FC = ({ children }) const contextValue = React.useMemo( () => ({ - projects, - dataScienceProjects, - modelServingProjects, - nonActiveProjects, + projects: projects.sort(projectSorter), + dataScienceProjects: dataScienceProjects.sort(projectSorter), + modelServingProjects: modelServingProjects.sort(projectSorter), + nonActiveProjects: nonActiveProjects.sort(projectSorter), preferredProject, updatePreferredProject, loaded, diff --git a/frontend/src/pages/home/aiFlows/useAIFlows.tsx b/frontend/src/pages/home/aiFlows/useAIFlows.tsx index 3a5533836c..495f27bcc4 100644 --- a/frontend/src/pages/home/aiFlows/useAIFlows.tsx +++ b/frontend/src/pages/home/aiFlows/useAIFlows.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { Gallery, PageSection, Stack, Text, TextContent } from '@patternfly/react-core'; +import { PageSection, Stack, Text, TextContent } from '@patternfly/react-core'; import projectIcon from '~/images/UI_icon-Red_Hat-Folder-Color.svg'; import pipelineIcon from '~/images/UI_icon-Red_Hat-Branch-Color.svg'; import chartIcon from '~/images/UI_icon-Red_Hat-Chart-Color.svg'; import { SectionType } from '~/concepts/design/utils'; import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; import { SupportedArea } from '~/concepts/areas'; +import EvenlySpacedGallery from '~/components/EvenlySpacedGallery'; import ProjectsGallery from './ProjectsGallery'; import CreateAndTrainGallery from './CreateAndTrainGallery'; import DeployAndMonitorGallery from './DeployAndMonitorGallery'; @@ -67,23 +68,15 @@ export const useAIFlows = (): React.ReactNode => { return null; } - const galleryWidths = `calc(${100 / cards.length}% - (1rem * ${cards.length - 1} / ${ - cards.length - }))`; - return ( Train, serve, monitor, and manage AI/ML models - + {cards} - + {selected === 'organize' ? ( setSelected(undefined)} /> ) : null} diff --git a/frontend/src/pages/home/projects/CreateProjectCard.tsx b/frontend/src/pages/home/projects/CreateProjectCard.tsx new file mode 100644 index 0000000000..9d77477f2e --- /dev/null +++ b/frontend/src/pages/home/projects/CreateProjectCard.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { + Bullseye, + Button, + CardBody, + Flex, + FlexItem, + Text, + TextContent, +} from '@patternfly/react-core'; +import { ProjectObjectType, SectionType, typedObjectImage } from '~/concepts/design/utils'; +import TypeBorderedCard from '~/concepts/design/TypeBorderedCard'; + +interface CreateProjectCardProps { + allowCreate: boolean; + onCreateProject: () => void; +} + +const CreateProjectCard: React.FC = ({ allowCreate, onCreateProject }) => ( + + + + + + Add project + + {allowCreate ? ( + + + + ) : ( + <> + + + Need another project? + + + + + + Contact your administrator to request a project creation for you. + + + + + )} + + + + +); + +export default CreateProjectCard; diff --git a/frontend/src/pages/home/projects/ProjectCard.tsx b/frontend/src/pages/home/projects/ProjectCard.tsx new file mode 100644 index 0000000000..0d15f9903f --- /dev/null +++ b/frontend/src/pages/home/projects/ProjectCard.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { + Button, + CardBody, + CardFooter, + CardHeader, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Text, + TextContent, + Timestamp, + Truncate, +} from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; +import { ProjectKind } from '~/k8sTypes'; +import TruncatedText from '~/components/TruncatedText'; +import { SectionType } from '~/concepts/design/utils'; +import TypeBorderedCard from '~/concepts/design/TypeBorderedCard'; +import { + getProjectDescription, + getProjectDisplayName, + getProjectOwner, +} from '~/concepts/projects/utils'; + +interface ProjectCardProps { + project: ProjectKind; +} + +const ProjectCard: React.FC = ({ project }) => { + const navigate = useNavigate(); + + return ( + + + + + + + + + + + + + + + Created + + {project.metadata.creationTimestamp ? ( + + ) : ( + 'Unknown' + )} + + + + Owner + + + + + + + + ); +}; + +export default ProjectCard; diff --git a/frontend/src/pages/home/projects/ProjectsSection.tsx b/frontend/src/pages/home/projects/ProjectsSection.tsx index 99c75a6f08..bf4a93760d 100644 --- a/frontend/src/pages/home/projects/ProjectsSection.tsx +++ b/frontend/src/pages/home/projects/ProjectsSection.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; import { Button, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, Flex, FlexItem, PageSection, @@ -8,18 +13,22 @@ import { StackItem, Text, TextContent, - TextVariants, } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; -import HeaderIcon from '~/concepts/design/HeaderIcon'; -import { ProjectObjectType, SectionType } from '~/concepts/design/utils'; +import useDimensions from 'react-cool-dimensions'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; import ManageProjectModal from '~/pages/projects/screens/projects/ManageProjectModal'; import { AccessReviewResourceAttributes } from '~/k8sTypes'; import { useAccessReview } from '~/api'; import { SupportedArea } from '~/concepts/areas'; import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import EvenlySpacedGallery from '~/components/EvenlySpacedGallery'; +import ProjectsSectionHeader from './ProjectsSectionHeader'; import EmptyProjectsCard from './EmptyProjectsCard'; import ProjectsLoading from './ProjectsLoading'; +import ProjectCard from './ProjectCard'; +import CreateProjectCard from './CreateProjectCard'; const accessReviewResource: AccessReviewResourceAttributes = { group: 'project.openshift.io', @@ -27,45 +36,100 @@ const accessReviewResource: AccessReviewResourceAttributes = { verb: 'create', }; +const MAX_SHOWN_PROJECTS = 5; +const MIN_CARD_WIDTH = 225; + const ProjectsSection: React.FC = () => { const navigate = useNavigate(); const { status: projectsAvailable } = useIsAreaAvailable(SupportedArea.DS_PROJECTS_VIEW); + const { projects: projects, loaded, loadError } = React.useContext(ProjectsContext); const [allowCreate, rbacLoaded] = useAccessReview(accessReviewResource); const [createProjectOpen, setCreateProjectOpen] = React.useState(false); + const [visibleCardCount, setVisibleCardCount] = React.useState(5); + + const { observe } = useDimensions({ + onResize: ({ width }) => { + setVisibleCardCount(Math.min(MAX_SHOWN_PROJECTS, Math.floor(width / MIN_CARD_WIDTH))); + }, + }); + + const shownProjects = React.useMemo( + () => (loaded ? projects.slice(0, visibleCardCount) : []), + [loaded, projects, visibleCardCount], + ); if (!projectsAvailable) { return null; } + const numCards = Math.min(projects.length + 1, visibleCardCount); + const showCreateCard = projects.length < visibleCardCount; + const onCreateProject = () => setCreateProjectOpen(true); return ( - - - - - - - Projects - - - + - {!rbacLoaded ? ( + {loadError ? ( + + } + headingLevel="h3" + /> + {loadError.message} + + ) : !rbacLoaded || !loaded ? ( - ) : ( + ) : !projects.length ? ( + ) : ( +
+ + {shownProjects.map((project) => ( + + ))} + {showCreateCard ? ( + + ) : null} + +
)}
- + + + {shownProjects.length ? ( + + + {shownProjects.length < projects.length + ? `${shownProjects.length} of ${projects.length} projects` + : 'Showing all projects'} + + + ) : null} + + + + +
{createProjectOpen ? ( diff --git a/frontend/src/pages/home/projects/ProjectsSectionHeader.tsx b/frontend/src/pages/home/projects/ProjectsSectionHeader.tsx new file mode 100644 index 0000000000..5dcdcefa38 --- /dev/null +++ b/frontend/src/pages/home/projects/ProjectsSectionHeader.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { + Button, + Flex, + FlexItem, + Icon, + Popover, + Text, + TextContent, + TextVariants, +} from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import HeaderIcon from '~/concepts/design/HeaderIcon'; +import { ProjectObjectType, SectionType } from '~/concepts/design/utils'; + +interface ProjectsSectionHeaderProps { + showCreate: boolean; + allowCreate: boolean; + onCreateProject: () => void; +} + +const ProjectsSectionHeader: React.FC = ({ + showCreate, + allowCreate, + onCreateProject, +}) => ( + + + + + + + + + Projects + + + {showCreate && !allowCreate ? ( + + Additional projects request} + bodyContent={ +
Contact your administrator to request a project creation for you.
+ } + > + + + +
+
+ ) : null} +
+
+ {showCreate && allowCreate ? ( + + + + ) : null} +
+); + +export default ProjectsSectionHeader;