diff --git a/public/locales/en.json b/public/locales/en.json index b281794d..f6d20e58 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -119,9 +119,9 @@ "DeleteConfirmationDialog": { "deleteButton": "Delete", "cancelButton": "Cancel", - "deleteMessage": "You are about to delete the resource", - "deleteMessageType": "Please type", - "deleteMessageConfirm": "to confirm deletion!" + "header": "Delete {{resourceName}}", + "deleteMessage": "You are about to delete the resource {{resourceName}}.", + "deleteConfirmation": "To confirm, type “{{resourceName}}” in the box below" }, "Loading": { "title": "Getting Ready", @@ -157,7 +157,9 @@ }, "ProjectsListView": { "pageTitle": "Let's get started", - "title": "Projects" + "title": "Projects", + "deleteProject": "Delete project", + "deleteConfirmationDialog": "Project deleted" }, "ControlPlaneView": { "accessError": "Managed Control Plane does not have access information yet", @@ -190,6 +192,12 @@ "mainCommandDescription": "Run this command to delete the workspace:", "verificationCommandDescription": "To verify the workspace has been deleted, run:" }, + "DeleteProjectDialog": { + "title": "Delete a Project", + "introSection1": "The below instructions will help you delete the project named \"{{projectName}}\" using kubectl.", + "introSection2": "Remember that this action is irreversible and all resources within the project will be permanently deleted.", + "mainCommandDescription": "Run this command to delete the project:" + }, "KubectlDeleteMcpDialog": { "title": "Delete a Managed Control Plane", "introSection1": "The below will help you delete the Managed Control Plane \"{{mcpName}}\" from workspace \"{{workspaceNamespace}}\" using kubectl.", diff --git a/src/components/Dialogs/DeleteConfirmationDialog.cy.tsx b/src/components/Dialogs/DeleteConfirmationDialog.cy.tsx index 37c7670e..498a4f56 100644 --- a/src/components/Dialogs/DeleteConfirmationDialog.cy.tsx +++ b/src/components/Dialogs/DeleteConfirmationDialog.cy.tsx @@ -22,11 +22,9 @@ describe('DeleteConfirmationDialog', () => { cy.get('ui5-dialog').should('be.visible').should('have.attr', 'open'); - cy.contains('Confirm deletion').should('be.visible'); + cy.contains('Delete test-resource').should('be.visible'); - cy.contains('You are about to delete the resource test-resource').should( - 'be.visible', - ); + cy.contains('You are about to delete the resource test-resource').should('be.visible'); }); it('should not be visible when isOpen is false', () => { @@ -44,9 +42,7 @@ describe('DeleteConfirmationDialog', () => { it('should enable Delete button when correct resource name is typed', () => { mountDialog(); - cy.get('ui5-input[id*="mcp-name-input"]') - .find(' input[id*="inner"]') - .type('test-resource', { force: true }); + cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource', { force: true }); cy.get('ui5-button').contains('Delete').should('not.have.attr', 'disabled'); }); @@ -54,9 +50,7 @@ describe('DeleteConfirmationDialog', () => { it('should keep Delete button disabled when incorrect name is typed', () => { mountDialog(); - cy.get('ui5-input[id*="mcp-name-input"]') - .find(' input[id*="inner"]') - .type('wrong-name', { force: true }); + cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('wrong-name', { force: true }); cy.get('ui5-button').contains('Delete').should('have.attr', 'disabled'); }); @@ -74,9 +68,7 @@ describe('DeleteConfirmationDialog', () => { it('should call onDeletionConfirmed and setIsOpen when Delete is confirmed', () => { mountDialog(); - cy.get('ui5-input[id*="mcp-name-input"]') - .find(' input[id*="inner"]') - .type('test-resource'); + cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource'); cy.get('ui5-button').contains('Delete').click(); @@ -90,9 +82,7 @@ describe('DeleteConfirmationDialog', () => { mountDialog(); // Type something - cy.get('ui5-input[id*="mcp-name-input"]') - .find(' input[id*="inner"]') - .type('test-resource', { force: true }); + cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').type('test-resource', { force: true }); // Close dialog cy.get('ui5-button').contains('Cancel').click(); @@ -100,20 +90,14 @@ describe('DeleteConfirmationDialog', () => { // Reopen dialog mountDialog(); - cy.get('ui5-input[id*="mcp-name-input"]') - .find(' input[id*="inner"]') - .should('have.value', ''); + cy.get('ui5-input[id*="mcp-name-input"]').find(' input[id*="inner"]').should('have.value', ''); }); it('should display correct resource name in all labels', () => { mountDialog({ resourceName: 'custom-resource' }); - cy.contains('You are about to delete the resource custom-resource.').should( - 'be.visible', - ); + cy.contains('You are about to delete the resource custom-resource.').should('be.visible'); - cy.contains('Please type custom-resource to confirm deletion!').should( - 'be.visible', - ); + cy.contains('To confirm, type “custom-resource” in the box below').should('be.visible'); }); }); diff --git a/src/components/Dialogs/DeleteConfirmationDialog.module.css b/src/components/Dialogs/DeleteConfirmationDialog.module.css new file mode 100644 index 00000000..df880e65 --- /dev/null +++ b/src/components/Dialogs/DeleteConfirmationDialog.module.css @@ -0,0 +1,20 @@ +.dialogContent { + display: flex; + flex-direction: column; + margin-block-end: 1rem; + align-items: flex-start; +} + +.message { + color: var(--sapContent_LabelColor); +} + +.confirmLabel { + margin-block-start: 1rem; + font-weight: bold; +} + +.confirmationInput { + width: 100%; +} + diff --git a/src/components/Dialogs/DeleteConfirmationDialog.tsx b/src/components/Dialogs/DeleteConfirmationDialog.tsx index c63f1c57..06beb819 100644 --- a/src/components/Dialogs/DeleteConfirmationDialog.tsx +++ b/src/components/Dialogs/DeleteConfirmationDialog.tsx @@ -1,17 +1,10 @@ -import { ReactNode, useEffect, useRef, useState } from 'react'; -import { - Bar, - Button, - Dialog, - Form, - FormGroup, - FormItem, - Input, - InputDomRef, - Label, -} from '@ui5/webcomponents-react'; +import { ReactNode, useState } from 'react'; +import { Bar, Button, Dialog, Input, InputDomRef, Label } from '@ui5/webcomponents-react'; import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; + +import styles from './DeleteConfirmationDialog.module.css'; +import type { Ui5CustomEvent } from '@ui5/webcomponents-react-base'; interface DeleteConfirmationDialogProps { isOpen: boolean; @@ -30,96 +23,70 @@ export function DeleteConfirmationDialog({ onCanceled, kubectl, }: DeleteConfirmationDialogProps) { - const [confirmed, setConfirmed] = useState(false); - const confirmationInput = useRef(null); + const [confirmationText, setConfirmationText] = useState(''); const { t } = useTranslation(); - useEffect(() => { - return () => { - setConfirmed(false); - if (confirmationInput.current) { - confirmationInput.current.value = ''; - } - }; - }, [isOpen]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onConfirmationInputChange = (event: any) => { - if (event.target.value === resourceName) { - setConfirmed(true); - } else { - setConfirmed(false); - } + const onConfirmationInputChange = (event: Ui5CustomEvent) => { + setConfirmationText(event.target.value); }; + const isConfirmed = confirmationText === resourceName; + return ( - <> - - - - - } + + + + {kubectl} + + } + /> + } + > +
+ + , + }} /> - } - > -
- - - - - - - {t('DeleteConfirmationDialog.deleteMessageType')}{' '} - {resourceName}{' '} - {t('DeleteConfirmationDialog.deleteMessageConfirm')} - - } - > - - - {kubectl} - -
-
- + + + + +
); } diff --git a/src/components/Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx b/src/components/Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx new file mode 100644 index 00000000..7e02d8e3 --- /dev/null +++ b/src/components/Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +import { KubectlInfoButton } from '../KubectlInfoButton'; +import { KubectlDeleteProjectDialog } from '../KubectlDeleteProjectDialog.tsx'; + +interface KubectlDeleteProjectProps { + projectName?: string; +} + +export const KubectlDeleteProject = ({ projectName }: KubectlDeleteProjectProps) => { + const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); + + const openInfoDialog = () => setIsInfoDialogOpen(true); + const closeInfoDialog = () => setIsInfoDialogOpen(false); + + return ( + <> + + + + ); +}; diff --git a/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteProjectDialog.tsx b/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteProjectDialog.tsx new file mode 100644 index 00000000..1ae3d95c --- /dev/null +++ b/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteProjectDialog.tsx @@ -0,0 +1,54 @@ +import { KubectlBaseDialog, CustomCommand } from './KubectlBaseDialog'; +import { Text } from '@ui5/webcomponents-react'; +import { useTranslation, Trans } from 'react-i18next'; +import { Fragment } from 'react/jsx-runtime'; + +interface KubectlDeleteProjectDialogProps { + onClose: () => void; + resourceName?: string; + projectName?: string; + isOpen: boolean; +} + +export const KubectlDeleteProjectDialog = ({ onClose, projectName, isOpen }: KubectlDeleteProjectDialogProps) => { + const { t } = useTranslation(); + + const projectNamespace = projectName ?? '"'; + + const customCommands: CustomCommand[] = [ + { + command: `kubectl delete project ${projectNamespace}`, + description: t('DeleteProjectDialog.mainCommandDescription'), + isMainCommand: true, + }, + ]; + + const introSection = [ + + + {t('DeleteProjectDialog.introSection1', { + projectName, + })} + + + , + bold2: , + }} + /> + + , + ]; + + return ( + + ); +}; diff --git a/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteWorkspaceDialog.tsx b/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteWorkspaceDialog.tsx index 67793c4b..4ec8a73c 100644 --- a/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteWorkspaceDialog.tsx +++ b/src/components/Dialogs/KubectlCommandInfo/KubectlDeleteWorkspaceDialog.tsx @@ -10,17 +10,10 @@ interface DeleteWorkspaceDialogProps { isOpen: boolean; } -export const DeleteWorkspaceDialog = ({ - onClose, - resourceName, - projectName, - isOpen, -}: DeleteWorkspaceDialogProps) => { +export const DeleteWorkspaceDialog = ({ onClose, resourceName, projectName, isOpen }: DeleteWorkspaceDialogProps) => { const { t } = useTranslation(); - const projectNamespace = projectName - ? `project-${projectName}` - : '"'; + const projectNamespace = projectName ? `project-${projectName}` : '"'; const workspaceName = resourceName || ''; const customCommands: CustomCommand[] = [ diff --git a/src/components/Projects/ProjectsList.tsx b/src/components/Projects/ProjectsList.tsx index bbaf8c69..be22fdb0 100644 --- a/src/components/Projects/ProjectsList.tsx +++ b/src/components/Projects/ProjectsList.tsx @@ -1,8 +1,5 @@ -import { - AnalyticalTable, - AnalyticalTableColumnDefinition, -} from '@ui5/webcomponents-react'; -import { ThemingParameters } from '@ui5/webcomponents-react-base'; +import { AnalyticalTable, AnalyticalTableColumnDefinition, Link } from '@ui5/webcomponents-react'; + import { CopyButton } from '../Shared/CopyButton.tsx'; import useLuigiNavigate from '../Shared/useLuigiNavigate.tsx'; import IllustratedError from '../Shared/IllustratedError.tsx'; @@ -14,6 +11,7 @@ import { ListProjectNames } from '../../lib/api/types/crate/listProjectNames'; import { t } from 'i18next'; import { YamlViewButtonWithLoader } from '../Yaml/YamlViewButtonWithLoader.tsx'; import { useMemo } from 'react'; +import { ProjectsListItemMenu } from './ProjectsListItemMenu.tsx'; export default function ProjectsList() { const navigate = useLuigiNavigate(); @@ -37,24 +35,26 @@ export default function ProjectsList() { accessor: 'projectName', // eslint-disable-next-line @typescript-eslint/no-explicit-any Cell: (instance: any) => ( -
{ + navigate(`/mcp/projects/${instance.cell.row.original?.projectName}`); }} > {instance.cell.value} -
+ ), }, { Header: 'Namespace', accessor: 'nameSpace', + width: 340, // eslint-disable-next-line @typescript-eslint/no-explicit-any Cell: (instance: any) => (
@@ -94,6 +95,26 @@ export default function ProjectsList() {
), }, + { + Header: '', + accessor: 'options', + width: 60, + disableFilters: true, + hAlign: 'Center', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Cell: (instance: any) => ( +
+ +
+ ), + }, ], [], ); @@ -103,17 +124,7 @@ export default function ProjectsList() { return ( <> - { - navigate( - `/mcp/projects/${data ? [e.detail.row.values.projectName] : ''}`, - ); - }} - /> + ); } diff --git a/src/components/Projects/ProjectsListItemMenu.tsx b/src/components/Projects/ProjectsListItemMenu.tsx new file mode 100644 index 00000000..7a02fae0 --- /dev/null +++ b/src/components/Projects/ProjectsListItemMenu.tsx @@ -0,0 +1,70 @@ +import { Button, ButtonDomRef, Menu, MenuItem, Ui5CustomEvent, MenuDomRef } from '@ui5/webcomponents-react'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import { FC, useRef, useState } from 'react'; +import '@ui5/webcomponents-icons/dist/copy'; +import '@ui5/webcomponents-icons/dist/accept'; + +import { useTranslation } from 'react-i18next'; +import { DeleteConfirmationDialog } from '../Dialogs/DeleteConfirmationDialog.tsx'; + +import { useToast } from '../../context/ToastContext.tsx'; +import { useApiResourceMutation } from '../../lib/api/useApiResource.ts'; +import { DeleteWorkspaceType } from '../../lib/api/types/crate/deleteWorkspace.ts'; +import { DeleteProjectResource } from '../../lib/api/types/crate/deleteProject.ts'; +import { KubectlDeleteProject } from '../Dialogs/KubectlCommandInfo/Controllers/KubectlDeleteProject.tsx'; + +type ProjectsListItemMenuProps = { + projectName: string; +}; + +export const ProjectsListItemMenu: FC = ({ projectName }) => { + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + const [dialogDeleteProjectIsOpen, setDialogDeleteProjectIsOpen] = useState(false); + const { t } = useTranslation(); + const toast = useToast(); + const { trigger } = useApiResourceMutation(DeleteProjectResource(projectName)); + const handleOpenerClick = (e: Ui5CustomEvent) => { + e.stopImmediatePropagation(); + e.stopPropagation(); + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( +
+
+ ); +}; diff --git a/src/components/Yaml/YamlViewButtonWithLoader.tsx b/src/components/Yaml/YamlViewButtonWithLoader.tsx index 30e55a2b..9e66a4e2 100644 --- a/src/components/Yaml/YamlViewButtonWithLoader.tsx +++ b/src/components/Yaml/YamlViewButtonWithLoader.tsx @@ -12,11 +12,7 @@ export type YamlViewButtonProps = { resourceName: string; }; -export const YamlViewButtonWithLoader: FC = ({ - workspaceName, - resourceType, - resourceName, -}) => { +export const YamlViewButtonWithLoader: FC = ({ workspaceName, resourceType, resourceName }) => { const [isOpen, setIsOpen] = useState(false); const { t } = useTranslation(); return ( @@ -25,11 +21,7 @@ export const YamlViewButtonWithLoader: FC = ({ isOpen={isOpen} setIsOpen={setIsOpen} dialogContent={ - + } /> diff --git a/src/lib/api/types/crate/deleteProject.ts b/src/lib/api/types/crate/deleteProject.ts new file mode 100644 index 00000000..4b27abcf --- /dev/null +++ b/src/lib/api/types/crate/deleteProject.ts @@ -0,0 +1,10 @@ +import { Resource } from '../resource'; + +export const DeleteProjectResource = (projectName: string): Resource => { + return { + path: `/apis/core.openmcp.cloud/v1alpha1/projects/${projectName}`, + method: 'DELETE', + jq: undefined, + body: undefined, + }; +};