diff --git a/public/locales/en.json b/public/locales/en.json index 63ff429e..572ded8f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -40,7 +40,9 @@ "tableHeaderUsage": "Usage" }, "ControlPlaneListToolbar": { - "buttonText": "Workspace" + "buttonText": "Workspace", + "deleteWorkspace": "Delete workspace", + "createNewManagedControlPlane": "Create new Managed Control Plane" }, "ControlPlaneListWorkspaceGridTile": { "deleteConfirmationDialog": "Workspace deletion triggered. The list will refresh automatically once completed.", @@ -79,8 +81,8 @@ "menuCopy": "Copy to clipboard" }, "IllustratedBanner": { - "titleMessage": "No ManagedControlPlane", - "subtitleMessage": "Create a ManagedControlPlane to get started", + "titleMessage": "No Managed Control Planes found", + "subtitleMessage": "Get started by creating your first Managed Control Plane.", "helpButton": "Help" }, "IntelligentBreadcrumbs": { @@ -256,21 +258,39 @@ "validationErrors": { "required": "This field is required!", "properFormatting": "Use A-Z, a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", + "properFormattingLowercase": "Use lowercase a-z, 0-9, hyphen (-), and period (.), but note that whitespace (spaces, tabs, etc.) is not allowed for proper compatibility.", "max25chars": "Max length is 25 characters.", "userExists": "User with this email already exists!", "atLeastOneUser": "You need to have at least one member assigned." }, "common": { "close": "Close", - "cannotLoadData": "Cannot load data" + "cannotLoadData": "Cannot load data", + "metadata": "Metadata", + "members": "Members", + "summarize": "Summarize", + "namespace": "Namespace", + "region": "Region", + "success": "Success", + "displayName": "Display Name", + "name": "Name" }, "buttons": { "viewResource": "View resource", "download": "Download", - "copy": "Copy" + "copy": "Copy", + "next": "Next", + "create": "Create", + "close": "Close", + "back": "Back" }, "yaml": { "copiedToClipboard": "YAML copied to clipboard!", "YAML": "YAML" + }, + "createMCP": { + "dialogTitle": "Create Managed Control Plane", + "titleText": "Managed Control Plane Created Successfully!", + "subtitleText": "Your Managed Control Plane is being set up. It will be ready to use in just a few minutes. You can safely close this window." } } diff --git a/src/components/ControlPlanes/ControlPlanesListMenu.tsx b/src/components/ControlPlanes/ControlPlanesListMenu.tsx new file mode 100644 index 00000000..68ae8f4f --- /dev/null +++ b/src/components/ControlPlanes/ControlPlanesListMenu.tsx @@ -0,0 +1,72 @@ +import { + Button, + ButtonDomRef, + Menu, + MenuItem, + Ui5CustomEvent, + MenuDomRef, +} from '@ui5/webcomponents-react'; +import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js'; +import { Dispatch, FC, SetStateAction, useRef, useState } from 'react'; +import '@ui5/webcomponents-icons/dist/copy'; +import '@ui5/webcomponents-icons/dist/accept'; + +import { useTranslation } from 'react-i18next'; + +type ControlPlanesListMenuProps = { + setDialogDeleteWsIsOpen: Dispatch>; + setIsCreateManagedControlPlaneWizardOpen: Dispatch>; +}; + +export const ControlPlanesListMenu: FC = ({ + setDialogDeleteWsIsOpen, + setIsCreateManagedControlPlaneWizardOpen, +}) => { + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const handleOpenerClick = ( + e: Ui5CustomEvent, + ) => { + if (popoverRef.current && e.currentTarget) { + popoverRef.current.opener = e.currentTarget as HTMLElement; + setOpen((prev) => !prev); + } + }; + + return ( + <> + + } /> ) : ( @@ -191,6 +208,12 @@ export function ControlPlaneListWorkspaceGridTile({ ); }} /> + ); } diff --git a/src/components/ControlPlanes/List/MembersAvatarView.tsx b/src/components/ControlPlanes/List/MembersAvatarView.tsx index 27e4dca8..94803d6f 100644 --- a/src/components/ControlPlanes/List/MembersAvatarView.tsx +++ b/src/components/ControlPlanes/List/MembersAvatarView.tsx @@ -49,7 +49,7 @@ export function MembersAvatarView({ members, project, workspace }: Props) { setPopoverIsOpen(false); }} > - + ); diff --git a/src/components/ControlPlanes/List/WorkspacesList.module.css b/src/components/ControlPlanes/List/WorkspacesList.module.css new file mode 100644 index 00000000..04406250 --- /dev/null +++ b/src/components/ControlPlanes/List/WorkspacesList.module.css @@ -0,0 +1,3 @@ +.createButton { + margin-bottom: 2rem; +} diff --git a/src/components/Dialogs/CreateProjectWorkspaceDialog.module.css b/src/components/Dialogs/CreateProjectWorkspaceDialog.module.css new file mode 100644 index 00000000..05707b43 --- /dev/null +++ b/src/components/Dialogs/CreateProjectWorkspaceDialog.module.css @@ -0,0 +1,5 @@ +.input { + width: 100%; + margin-bottom: 2rem; + max-width: 40ch; +} \ No newline at end of file diff --git a/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx b/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx index f0ac47ca..894d8a87 100644 --- a/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx +++ b/src/components/Dialogs/CreateProjectWorkspaceDialog.tsx @@ -1,13 +1,4 @@ -import { - Bar, - Button, - Dialog, - Form, - FormGroup, - FormItem, - Input, - Label, -} from '@ui5/webcomponents-react'; +import { Bar, Button, Dialog, FormGroup } from '@ui5/webcomponents-react'; import { Member } from '../../lib/api/types/shared/members'; import { ErrorDialog, ErrorDialogHandle } from '../Shared/ErrorMessageBox.tsx'; @@ -18,11 +9,12 @@ import { KubectlCreateWorkspaceDialog } from './KubectlCommandInfo/KubectlCreate import { KubectlCreateProjectDialog } from './KubectlCommandInfo/KubectlCreateProjectDialog.tsx'; import { EditMembers } from '../Members/EditMembers.tsx'; -// import { useFrontendConfig } from '../../context/FrontendConfigContext.tsx'; + import { useTranslation } from 'react-i18next'; import { CreateDialogProps } from './CreateWorkspaceDialogContainer.tsx'; import { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; +import { MetadataForm } from './MetadataForm.tsx'; export type OnCreatePayload = { name: string; @@ -56,12 +48,14 @@ export function CreateProjectWorkspaceDialog({ setValue, projectName, }: CreateProjectWorkspaceDialogProps) { - // const { links } = useFrontendConfig(); const { t } = useTranslation(); const [isKubectlDialogOpen, setIsKubectlDialogOpen] = useState(false); const openKubectlDialog = () => setIsKubectlDialogOpen(true); const closeKubectlDialog = () => setIsKubectlDialogOpen(false); + const setMembers = (members: Member[]) => { + setValue('members', members); + }; return ( <> @@ -79,7 +73,6 @@ export function CreateProjectWorkspaceDialog({ > - - - + + + ); }; diff --git a/src/components/Members/MemberRoleSelect.tsx b/src/components/Members/MemberRoleSelect.tsx index 8489f24d..a170d227 100644 --- a/src/components/Members/MemberRoleSelect.tsx +++ b/src/components/Members/MemberRoleSelect.tsx @@ -3,6 +3,8 @@ import { MemberRolesDetailed, } from '../../lib/api/types/shared/members'; import { + FlexBox, + Label, Option, Select, SelectDomRef, @@ -36,7 +38,8 @@ export function MemberRoleSelect({ }, [value]); return ( - <> + + - + ); } diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index 1600ac3a..3c80e35e 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -8,16 +8,31 @@ import { useTranslation } from 'react-i18next'; import { FC } from 'react'; import { Infobox } from '../Ui/Infobox/Infobox.tsx'; +type MemberTableRow = { + email: string; + role: string; +}; + type MemberTableProps = { members: Member[]; onDeleteMember?: (email: string) => void; isValidationError?: boolean; + requireAtLeastOneMember: boolean; +}; + +type CellInstance = { + cell: { + row: { + original: MemberTableRow; + }; + }; }; export const MemberTable: FC = ({ members, onDeleteMember, isValidationError = false, + requireAtLeastOneMember, }) => { const { t } = useTranslation(); @@ -37,43 +52,36 @@ export const MemberTable: FC = ({ Header: '', accessor: '.', width: 50, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Cell: (instance: any) => ( + Cell: (instance: CellInstance) => ( + ) : ( + + ))} + + + } + /> + } + data-testid="create-mcp-dialog" + onClose={resetFormAndClose} + > + + + + + + +
+ + + +
+
+ +

{t('common.summarize')}

+ +
+ + + + + + +
+ + {getValues('members').map((member) => ( + + ))} + +
+
+ +
+
+
+ + + +
+ + ); +}; diff --git a/src/index.css b/src/index.css index d9da15ec..6bf46d3d 100644 --- a/src/index.css +++ b/src/index.css @@ -125,4 +125,5 @@ ui5-toast { .mono-font { font-family: 'Roboto Mono', monospace; font-weight: bold; -} \ No newline at end of file +} + diff --git a/src/lib/api/types/crate/createManagedControlPlane.ts b/src/lib/api/types/crate/createManagedControlPlane.ts new file mode 100644 index 00000000..dd0cb8dd --- /dev/null +++ b/src/lib/api/types/crate/createManagedControlPlane.ts @@ -0,0 +1,63 @@ +import { Resource } from '../resource'; +import { + CHARGING_TARGET_LABEL, + DISPLAY_NAME_ANNOTATION, +} from '../shared/keyNames'; +import { Member } from '../shared/members'; + +export type Annotations = Record; +export type Labels = Record; + +export interface CreateManagedControlPlaneType { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace: string; + annotations: Annotations; + labels: Labels; + }; + spec: { + members: Member[]; + }; +} + +export const CreateManagedControlPlane = ( + name: string, + namespace: string, + optional?: { + displayName?: string; + chargingTarget?: string; + members?: Member[]; + }, +): CreateManagedControlPlaneType => { + return { + apiVersion: 'core.openmcp.cloud/v1alpha1', + kind: 'ManagedControlPlane', + metadata: { + name: name, + namespace: namespace, + annotations: { + [DISPLAY_NAME_ANNOTATION]: optional?.displayName ?? '', + }, + labels: { + [CHARGING_TARGET_LABEL]: optional?.chargingTarget ?? '', + }, + }, + spec: { + members: optional?.members ?? [], + }, + }; +}; + +export const CreateManagedControlPlaneResource = ( + projectName: string, + workspaceName: string, +): Resource => { + return { + path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/${projectName}--ws-${workspaceName}/managedcontrolplanes`, + method: 'POST', + jq: undefined, + body: undefined, + }; +}; diff --git a/src/lib/api/validations/regex.spec.ts b/src/lib/api/validations/regex.spec.ts new file mode 100644 index 00000000..a86ff156 --- /dev/null +++ b/src/lib/api/validations/regex.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + projectWorkspaceNameRegex, + managedControlPlaneNameRegex, +} from './regex'; + +describe('projectWorkspaceNameRegex', () => { + const valid = [ + 'Project1', + 'my-project', + 'A1-B2-C3', + 'abc.DEF-123', + 'a'.repeat(63), + 'abc.def.ghi', + 'A-1.b-2', + 'abc123', + 'abc-123.DEF', + ]; + const invalid = [ + '-project', + 'project-', + '.project', + 'project.', + 'a'.repeat(64), + 'abc..def', + 'abc.-def', + 'abc-.def', + 'abc_def', + 'abc@def', + ]; + + it('matches valid project or workspace names', () => { + for (const name of valid) { + expect(projectWorkspaceNameRegex.test(name)).toBe(true); + } + }); + + it('does not match invalid project or workspace names', () => { + for (const name of invalid) { + expect(projectWorkspaceNameRegex.test(name)).toBe(false); + } + }); +}); + +describe('managedControlPlaneNameRegex', () => { + const valid = [ + 'my-mcp', + 'abc123', + 'abc-123', + 'abc.def', + 'a'.repeat(63), + 'abc.def-ghi', + 'abc-123.def', + 'abc1-2.def3', + 'abc.def.ghi', + ]; + const invalid = [ + 'My-MCP', + 'ABC', + '-mcp', + 'mcp-', + '.mcp', + 'mcp.', + 'a'.repeat(64), + 'abc..def', + 'abc-.def', + 'abc.-def', + ]; + + it('matches valid managed control plane names', () => { + for (const name of valid) { + expect(managedControlPlaneNameRegex.test(name)).toBe(true); + } + }); + + it('does not match invalid managed control plane names', () => { + for (const name of invalid) { + expect(managedControlPlaneNameRegex.test(name)).toBe(false); + } + }); +}); diff --git a/src/lib/api/validations/regex.ts b/src/lib/api/validations/regex.ts new file mode 100644 index 00000000..4310068c --- /dev/null +++ b/src/lib/api/validations/regex.ts @@ -0,0 +1,7 @@ +// Matches project or workspace names: 1-63 chars per segment, alphanum/dash, dot-separated, no leading/trailing dash, allows uppercase. +export const projectWorkspaceNameRegex = + /^(?!-)[a-zA-Z0-9-]{1,63}(?(); export const validationSchemaProjectWorkspace = z.object({ + name: z + .string() + .min(1, t('validationErrors.required')) + .regex(projectWorkspaceNameRegex, t('validationErrors.properFormatting')) + .max(25, t('validationErrors.max25chars')), + displayName: z.string().optional(), + chargingTarget: z.string().optional(), + members: z.array(member).refine((members) => members?.length > 0), +}); +export const validationSchemaCreateManagedControlPlane = z.object({ name: z .string() .min(1, t('validationErrors.required')) .regex( - /^(?!-)[a-zA-Z0-9-]{1,63}(? members?.length > 0), + members: z.array(member), });