diff --git a/frontend/src/__tests__/cypress/cypress/pages/projects.ts b/frontend/src/__tests__/cypress/cypress/pages/projects.ts index ff168853cc..679352bf29 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/projects.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/projects.ts @@ -195,6 +195,34 @@ class ProjectDetails { ); } + showProjectResourceDetails() { + return cy.findByTestId('resource-name-icon-button').click(); + } + + findProjectResourceNameText() { + return cy.findByTestId('resource-name-text'); + } + + findProjectResourceKindText() { + return cy.findByTestId('resource-kind-text'); + } + + findProjectActions() { + return cy.findByTestId('project-actions'); + } + + showProjectActions() { + cy.findByTestId('project-actions').click(); + } + + findEditProjectAction() { + return cy.findByTestId('edit-project-action'); + } + + findDeleteProjectAction() { + return cy.findByTestId('delete-project-action'); + } + findImportPipelineButton(timeout?: number) { return cy.findByTestId('import-pipeline-button', { timeout }); } diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts index 2ee52b7425..19ecd3e472 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectDetails.cy.ts @@ -13,7 +13,11 @@ import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; import { mockRouteK8sResource } from '~/__mocks__/mockRouteK8sResource'; import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; import { mockServingRuntimeTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; -import { projectDetails } from '~/__tests__/cypress/cypress/pages/projects'; +import { + deleteProjectModal, + editProjectModal, + projectDetails, +} from '~/__tests__/cypress/cypress/pages/projects'; import { ServingRuntimePlatform } from '~/types'; import { DataSciencePipelineApplicationModel, @@ -31,6 +35,7 @@ import { } from '~/__tests__/cypress/cypress/utils/models'; import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; +import { asProjectAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; type HandlersProps = { isEmpty?: boolean; @@ -259,6 +264,41 @@ describe('Project Details', () => { projectDetails.shouldBeEmptyState('Pipelines', 'pipelines-projects', true); }); + it('Shows project information', () => { + initIntercepts({ disableKServeConfig: true, disableModelConfig: true }); + projectDetails.visit('test-project'); + projectDetails.showProjectResourceDetails(); + projectDetails.findProjectResourceNameText().should('have.text', 'test-project'); + projectDetails.findProjectResourceKindText().should('have.text', 'Project'); + }); + + it('Should not allow actions for non-provisioning users', () => { + asProjectAdminUser({ isSelfProvisioner: false }); + initIntercepts({ disableKServeConfig: true, disableModelConfig: true }); + projectDetails.visit('test-project'); + + projectDetails.findProjectActions().should('not.exist'); + }); + + it('Should allow actions for provisioning users', () => { + asProjectAdminUser({ isSelfProvisioner: true }); + initIntercepts({ disableKServeConfig: true, disableModelConfig: true }); + projectDetails.visit('test-project'); + + projectDetails.showProjectActions(); + projectDetails.findEditProjectAction().click(); + + editProjectModal.shouldBeOpen(); + editProjectModal.findCancelButton().click(); + editProjectModal.shouldBeOpen(false); + + projectDetails.showProjectActions(); + projectDetails.findDeleteProjectAction().click(); + deleteProjectModal.shouldBeOpen(); + deleteProjectModal.findCancelButton().click(); + deleteProjectModal.shouldBeOpen(false); + }); + it('Both model serving platforms are disabled', () => { initIntercepts({ disableKServeConfig: true, disableModelConfig: true }); projectDetails.visit('test-project'); diff --git a/frontend/src/components/ResourceNameTooltip.tsx b/frontend/src/components/ResourceNameTooltip.tsx index fec71a3a2e..07b517d5c7 100644 --- a/frontend/src/components/ResourceNameTooltip.tsx +++ b/frontend/src/components/ResourceNameTooltip.tsx @@ -5,6 +5,8 @@ import { DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, + Flex, + FlexItem, Popover, Stack, StackItem, @@ -26,9 +28,9 @@ const ResourceNameTooltip: React.FC = ({ wrap = true, }) => (
- {children} - {resource.metadata?.name && ( -
+ + {children} + {resource.metadata?.name && ( = ({ Resource name - + {resource.metadata.name} Resource type - {resource.kind} + + {resource.kind} + } > - } aria-label="More info" /> + } + aria-label="More info" + /> -
- )} + )} +
); diff --git a/frontend/src/pages/projects/screens/detail/ProjectActions.tsx b/frontend/src/pages/projects/screens/detail/ProjectActions.tsx new file mode 100644 index 0000000000..28861f973a --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/ProjectActions.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { Dropdown, DropdownItem, MenuToggle, DropdownList } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; +import { getDashboardMainContainer } from '~/utilities/utils'; +import { AccessReviewResourceAttributes, ProjectKind } from '~/k8sTypes'; +import { useAccessReview } from '~/api'; +import DeleteProjectModal from '~/pages/projects/screens/projects/DeleteProjectModal'; +import ManageProjectModal from '~/pages/projects/screens/projects/ManageProjectModal'; + +const projectEditAccessReview: AccessReviewResourceAttributes = { + group: 'project.openshift.io', + resource: 'projectrequests', + verb: 'update', +}; + +const projectDeleteAccessReview: AccessReviewResourceAttributes = { + group: 'project.openshift.io', + resource: 'projectrequests', + verb: 'delete', +}; + +type Props = { + project: ProjectKind; +}; + +const ProjectActions: React.FC = ({ project }) => { + const navigate = useNavigate(); + const [canEdit, editRbacLoaded] = useAccessReview({ + ...projectEditAccessReview, + namespace: project.metadata.name, + }); + const [canDelete, deleteRbacLoaded] = useAccessReview({ + ...projectDeleteAccessReview, + namespace: project.metadata.name, + }); + const [open, setOpen] = React.useState(false); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [editOpen, setEditOpen] = React.useState(false); + + if (!editRbacLoaded || !deleteRbacLoaded || (!canEdit && !canDelete)) { + return null; + } + + const DropdownComponent = ( + setOpen(false)} + toggle={(toggleRef) => ( + { + setOpen(!open); + }} + > + Actions + + )} + isOpen={open} + popperProps={{ position: 'right', appendTo: getDashboardMainContainer() }} + > + + {canEdit ? ( + setEditOpen(true)}> + Edit project + + ) : null} + {canDelete ? ( + setDeleteOpen(true)}> + Delete project + + ) : null} + + + ); + + return ( + <> + {DropdownComponent} + {editOpen ? ( + setEditOpen(false)} /> + ) : null} + {deleteOpen ? ( + { + setDeleteOpen(false); + if (deleted) { + navigate('/projects'); + } + }} + /> + ) : null} + + ); +}; + +export default ProjectActions; diff --git a/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx b/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx index 1f2c4ff95b..d57beba90c 100644 --- a/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx +++ b/frontend/src/pages/projects/screens/detail/ProjectDetails.tsx @@ -16,6 +16,7 @@ import { AccessReviewResourceAttributes } from '~/k8sTypes'; import { useAccessReview } from '~/api'; import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import useConnectionTypesEnabled from '~/concepts/connectionTypes/useConnectionTypesEnabled'; +import ResourceNameTooltip from '~/components/ResourceNameTooltip'; import useCheckLogoutParams from './useCheckLogoutParams'; import ProjectOverview from './overview/ProjectOverview'; import NotebookList from './notebooks/NotebookList'; @@ -23,6 +24,7 @@ import StorageList from './storage/StorageList'; import DataConnectionsList from './data-connections/DataConnectionsList'; import ConnectionsList from './connections/ConnectionsList'; import PipelinesSection from './pipelines/PipelinesSection'; +import ProjectActions from './ProjectActions'; import './ProjectDetails.scss'; @@ -123,7 +125,11 @@ const ProjectDetails: React.FC = () => { alignItems={{ default: 'alignItemsFlexStart' }} > - {displayName} + + + {displayName} + + } description={
{description}
} @@ -135,6 +141,7 @@ const ProjectDetails: React.FC = () => { } loaded={rbacLoaded} empty={false} + headerAction={} > {content()}