diff --git a/public/locales/en.json b/public/locales/en.json index da3857bc..e8523012 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -143,7 +143,9 @@ }, "MemberTable": { "columnEmailHeader": "Email", - "columnRoleHeader": "Role" + "columnRoleHeader": "Role", + "columnTypeHeader": "Type", + "columnNamespaceHeader": "Namespace" }, "IllustratedError": { "titleText": "Something went wrong", @@ -163,7 +165,12 @@ "membersHeader": "Members" }, "EditMembers": { - "addButton": "Add" + "addButton": "Add new member or service account", + "editHeader": "Edit member or service account", + "addHeader": "Add new member or service account", + "saveButton": "Save changes", + "defaultNamespaceInfo": "Leave empty to use default namespace", + "serviceAccoutsGuide": "You can also use our Service Account Guide for more information." }, "ProjectsPage": { "header": "Your instances of ManagedControlPlane", @@ -303,7 +310,7 @@ "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.", "maxChars": "Max length is {{maxLength}} characters.", - "userExists": "User with this email already exists!", + "userExists": "User with this name already exists!", "atLeastOneUser": "You need to have at least one member assigned.", "notValidChargingTargetFormat": "Use lowercase letters a-f, numbers 0-9, and hyphens (-) in the format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, @@ -343,7 +350,8 @@ "next": "Next", "create": "Create", "close": "Close", - "back": "Back" + "back": "Back", + "cancel": "Cancel" }, "yaml": { "copiedToClipboard": "YAML copied to clipboard!", diff --git a/src/components/Dialogs/CreateProjectDialog.cy.tsx b/src/components/Dialogs/CreateProjectDialog.cy.tsx deleted file mode 100644 index 4bd70c99..00000000 --- a/src/components/Dialogs/CreateProjectDialog.cy.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useState, useRef, useMemo } from 'react'; -import { CreateProjectWorkspaceDialog, OnCreatePayload } from './CreateProjectWorkspaceDialog'; -import { MemberRoles } from '../../lib/api/types/shared/members'; -import { ErrorDialogHandle } from '../Shared/ErrorMessageBox'; - -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { createProjectWorkspaceSchema } from '../../lib/api/validations/schemas.ts'; -import { CreateDialogProps } from './CreateWorkspaceDialogContainer.tsx'; -import { useTranslation } from 'react-i18next'; - -export const CreateProjectWorkspaceDialogWrapper: React.FC<{ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - spyFormBody?: (data: any) => object; -}> = ({ spyFormBody }) => { - const [isOpen, setIsOpen] = useState(true); - const { t } = useTranslation(); - - const errorDialogRef = useRef(null); - - const validationSchemaProjectWorkspace = useMemo(() => createProjectWorkspaceSchema(t), [t]); - - const { - register, - handleSubmit, - setValue, - formState: { errors }, - watch, - } = useForm({ - resolver: zodResolver(validationSchemaProjectWorkspace), - defaultValues: { - name: '', - displayName: '', - chargingTarget: '', - members: [{ name: 'user1@example.com', roles: [MemberRoles.admin], kind: 'User' }], - chargingTargetType: 'btp', - }, - }); - - const handleCreate = async ({ name, displayName, chargingTarget, members }: OnCreatePayload) => { - const payload: OnCreatePayload = { - name: name, - displayName: displayName, - chargingTarget: chargingTarget, - members: members, - }; - - spyFormBody?.(payload); - setIsOpen(false); - }; - return ( - - ); -}; - -describe('CreateProjectWorkspaceDialog', () => { - it('checks if there is existing member and delete it', () => { - cy.mount(, {}); - cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]') - .contains('user1@example.com') - .should('be.visible'); - cy.get('ui5-button[icon="delete"]').find('button').click({ force: true }); - cy.get('span[id="members-error"]').contains('You need to have at least one member assigned.').should('be.visible'); - }); - - it('should add a new member and display it in the table', () => { - cy.mount(, {}); - cy.get('ui5-input[id*="member-email-input"]').find('input[id*="inner"]').type('user2@example.com', { force: true }); - cy.get('ui5-button:contains("Add")').click({ force: true }); - cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]') - .contains('user2@example.com') - .should('be.visible'); - }); - - it('fills the form, adds user and checks if the request body is correct', () => { - const stubFn = cy.stub().as('stubFn'); - cy.mount(, {}); - - cy.get('ui5-input[id*="name"]').find('input[id*="inner"]').type('brand--01', { force: true }); - cy.get('ui5-input[id*="displayName"]') - .find('input[id*="inner"]') - .type('Brand new workspace number one', { force: true }); - cy.get('ui5-input[id*="chargingTarget"]') - .find('input[id*="inner"]') - .type('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', { force: true }); - cy.get('ui5-input[id*="email"]').find('input[id*="inner"]').type('user2@example.com', { force: true }); - cy.get('ui5-button:contains("Add")').click({ force: true }); - cy.get('ui5-button:contains("Create")').click({ force: true }); - - cy.get('@stubFn').should('have.been.calledWith', { - name: 'brand--01', - displayName: 'Brand new workspace number one', - chargingTarget: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - members: [ - { - name: 'user1@example.com', - roles: [MemberRoles.admin], - kind: 'User', - }, - { - name: 'user2@example.com', - roles: [MemberRoles.viewer], - kind: 'User', - }, - ], - }); - }); - - it('should handle multiple member additions', () => { - cy.mount(, {}); - - const newMembers = ['user3@example.com', 'user4@example.com']; - newMembers.forEach((email) => { - cy.get('ui5-input[id*="member-email-input"]') - .find('input[id*="inner"]') - .clear({ force: true }) - .type(email, { force: true }); - cy.get('ui5-button:contains("Add")').click({ force: true }); - }); - - newMembers.forEach((email) => { - cy.get('div[data-component-name="AnalyticalTableContainerWithScrollbar"]').contains(email).should('be.visible'); - }); - }); - - it('should close dialog when cancel is clicked', () => { - cy.mount(, {}); - - cy.get('ui5-dialog').should('be.visible'); - cy.get('ui5-button:contains("Cancel")').click({ force: true }); - cy.get('ui5-dialog').should('not.be.visible'); - }); -}); diff --git a/src/components/Dialogs/CreateProjectDialogContainer.tsx b/src/components/Dialogs/CreateProjectDialogContainer.tsx index 11eb95f4..8e4abed5 100644 --- a/src/components/Dialogs/CreateProjectDialogContainer.tsx +++ b/src/components/Dialogs/CreateProjectDialogContainer.tsx @@ -53,7 +53,7 @@ export function CreateProjectDialogContainer({ useEffect(() => { if (username) { - setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); + setValue('members', [{ name: username, role: MemberRoles.admin, kind: 'User' }]); } if (!isOpen) { clearForm(); diff --git a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx index bca3b319..adfa1264 100644 --- a/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx +++ b/src/components/Dialogs/CreateWorkspaceDialogContainer.tsx @@ -68,7 +68,7 @@ export function CreateWorkspaceDialogContainer({ useEffect(() => { if (username) { - setValue('members', [{ name: username, roles: [MemberRoles.admin], kind: 'User' }]); + setValue('members', [{ name: username, role: MemberRoles.admin, kind: 'User' }]); } if (!isOpen) { clearForm(); diff --git a/src/components/Members/AddEditMemberDialog.tsx b/src/components/Members/AddEditMemberDialog.tsx new file mode 100644 index 00000000..2d577f40 --- /dev/null +++ b/src/components/Members/AddEditMemberDialog.tsx @@ -0,0 +1,212 @@ +import { FC, useEffect, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Member, MemberRoles, memberRolesOptions } from '../../lib/api/types/shared/members.ts'; +import { Button, Dialog, FlexBox, Input, Label, Link, MessageStrip } from '@ui5/webcomponents-react'; +import styles from './Members.module.css'; +import { RadioButtonsSelect } from '../Ui/RadioButtonsSelect/RadioButtonsSelect.tsx'; +import FadeVisibility from '../Ui/FadeVisibility/FadeVisibility.tsx'; +import { ACCOUNT_TYPES, AccountType } from './EditMembers.tsx'; +import { useLink } from '../../lib/shared/useLink.ts'; + +interface AddEditMemberDialogProps { + open: boolean; + onClose: () => void; + onSave: (member: Member, isEdit: boolean) => void; + existingMembers: Member[]; + memberToEdit?: Member; +} + +type MemberFormData = { + accountType: AccountType; + name: string; + role: string; + namespace?: string; +}; + +export const AddEditMemberDialog: FC = ({ + open, + onClose, + onSave, + existingMembers, + memberToEdit, +}) => { + const { t } = useTranslation(); + const isEdit = !!memberToEdit; + const { serviceAccoutsGuide } = useLink(); + const memberFormSchema = useMemo( + () => + z + .object({ + accountType: z.enum(['User', 'ServiceAccount']), + name: z.string(), + role: z.string(), + namespace: z.string().optional(), + }) + .superRefine((data, ctx) => { + const trimmed = data.name.trim(); + if (!trimmed) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['name'], + message: t('validationErrors.required'), + }); + } + if (existingMembers.some((m) => m.name === trimmed && (!memberToEdit || trimmed !== memberToEdit.name))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['name'], + message: t('validationErrors.userExists'), + }); + } + }), + [t, existingMembers, memberToEdit], + ); + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(memberFormSchema), + mode: 'onChange', + defaultValues: { + accountType: 'User', + name: '', + role: MemberRoles.view, + namespace: '', + }, + }); + + const accountType = watch('accountType'); + const role = watch('role'); + + useEffect(() => { + if (open) { + if (memberToEdit) { + reset({ + name: memberToEdit.name, + role: memberToEdit.role || MemberRoles.view, + accountType: memberToEdit.kind === 'User' ? 'User' : 'ServiceAccount', + namespace: memberToEdit?.namespace || '', + }); + } else { + reset({ + accountType: 'User', + name: '', + role: MemberRoles.view, + namespace: '', + }); + } + } + }, [open, memberToEdit, reset]); + + const onFormSubmit = (data: MemberFormData) => { + const trimmedName = data.name.trim(); + + const newMember: Member = { + name: trimmedName, + role: data.role, + kind: data.accountType, + ...(data.accountType === 'ServiceAccount' && data.namespace && { namespace: data.namespace }), + }; + + onSave(newMember, isEdit); + onClose(); + }; + + const dialogHeader = memberToEdit ? t('EditMembers.editHeader') : t('EditMembers.addHeader') || 'Add member'; + + return ( + +
+ + + setValue('accountType', value as AccountType, { shouldValidate: true })} + /> + + + + + {errors.name?.message}} + data-testid="member-email-input" + /> + + +
+ setValue('role', value, { shouldValidate: true })} + label={t('MemberTable.columnRoleHeader')} + /> +
+ +
+ +
+ + + + +
+ + +
+ + , + }} + /> + +
+
+
+ + + +
+
+
+ ); +}; diff --git a/src/components/Members/EditMembers.cy.tsx b/src/components/Members/EditMembers.cy.tsx deleted file mode 100644 index cb8e5648..00000000 --- a/src/components/Members/EditMembers.cy.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Member, MemberRoles } from '../../lib/api/types/shared/members'; -import { EditMembers } from './EditMembers.tsx'; - -const email1 = 'test1@test.com'; -const email2 = 'test2@test.com'; - -function mountContainer(members: Member[] = []) { - cy.mount(); -} - -describe('', () => { - it('Should create member with Viewer role', () => { - mountContainer(); - - cy.get('#member-email-input').typeIntoUi5Input(email1); - cy.get('[data-testid="add-member-button"]').click(); - cy.get('@onChangeSpy').should('have.been.calledOnce'); - cy.get('@onChangeSpy').should('have.been.calledWith', [ - { name: email1, roles: [MemberRoles.viewer], kind: 'User' }, - ]); - }); - - it('Should create member with Adminisitrator role', () => { - mountContainer(); - - cy.get('#member-email-input').typeIntoUi5Input(email2); - cy.get('#member-role-select').openDropDownByClick(); - cy.get('#member-role-select [value="admin"]').clickDropdownMenuItem({ - force: true, - }); - cy.get('[data-testid="add-member-button"]').click(); - cy.get('@onChangeSpy').should('have.been.calledOnce'); - cy.get('@onChangeSpy').should('have.been.calledWith', [{ name: email2, roles: [MemberRoles.admin], kind: 'User' }]); - }); - - it('Should remove selected member', () => { - mountContainer([ - { name: email1, roles: [MemberRoles.admin], kind: 'User' }, - { name: email2, roles: [MemberRoles.viewer], kind: 'User' }, - ]); - cy.get('[aria-rowindex="1"] > [data-column-id-cell="."] > ui5-button').click(); - cy.get('@onChangeSpy').should('have.been.calledOnce'); - cy.get('@onChangeSpy').should('have.been.calledWith', [ - { name: email2, roles: [MemberRoles.viewer], kind: 'User' }, - ]); - }); -}); diff --git a/src/components/Members/EditMembers.tsx b/src/components/Members/EditMembers.tsx index 4fa6f07c..4d1eeb3d 100644 --- a/src/components/Members/EditMembers.tsx +++ b/src/components/Members/EditMembers.tsx @@ -1,11 +1,12 @@ -import { FC, useRef, useState, useCallback } from 'react'; -import { Button, FlexBox, Input, InputDomRef, Label } from '@ui5/webcomponents-react'; +import { FC, useCallback, useState } from 'react'; +import { Button, FlexBox } from '@ui5/webcomponents-react'; import { MemberTable } from './MemberTable.tsx'; -import { MemberRoleSelect } from './MemberRoleSelect.tsx'; -import { ValueState } from '../Shared/Ui5ValieState.tsx'; -import { Member, MemberRoles } from '../../lib/api/types/shared/members'; +import { Member } from '../../lib/api/types/shared/members'; import { useTranslation } from 'react-i18next'; import styles from './Members.module.css'; +import { RadioButtonsSelectOption } from '../Ui/RadioButtonsSelect/RadioButtonsSelect.tsx'; +import { AddEditMemberDialog } from './AddEditMemberDialog.tsx'; + export interface EditMembersProps { members: Member[]; onMemberChanged: (members: Member[]) => void; @@ -13,36 +14,23 @@ export interface EditMembersProps { requireAtLeastOneMember?: boolean; } +export const ACCOUNT_TYPES: RadioButtonsSelectOption[] = [ + { value: 'User', label: 'User Account', icon: 'employee' }, + { value: 'ServiceAccount', label: 'Service Account', icon: 'machine' }, +]; + +export type AccountType = 'User' | 'ServiceAccount'; + export const EditMembers: FC = ({ members, onMemberChanged, isValidationError = false, requireAtLeastOneMember = true, }) => { - const emailInputRef = useRef(null); - const [emailState, setEmailState] = useState('None'); - const [emailMessage, setEmailMessage] = useState(''); - const [selectedRole, setSelectedRole] = useState(MemberRoles.viewer); const { t } = useTranslation(); - const handleAddMember = useCallback(() => { - setEmailState('None'); - setEmailMessage(''); - const input = emailInputRef.current; - const email = input?.value.trim() || ''; - if (!email) { - setEmailState('Negative'); - setEmailMessage(t('validationErrors.required')); - return; - } - if (members.some((m) => m.name === email)) { - setEmailState('Negative'); - setEmailMessage(t('validationErrors.userExists')); - return; - } - onMemberChanged([...members, { name: email, roles: [selectedRole], kind: 'User' }]); - if (input) input.value = ''; - }, [members, onMemberChanged, selectedRole, t]); + const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); + const [memberToEdit, setMemberToEdit] = useState(undefined); const handleRemoveMember = useCallback( (email: string) => { @@ -51,45 +39,66 @@ export const EditMembers: FC = ({ [members, onMemberChanged], ); - const handleRoleChange = useCallback((role: MemberRoles) => { - setSelectedRole(role); + const handleOpenMemberFormDialog = useCallback(() => { + setMemberToEdit(undefined); + setIsMemberDialogOpen(true); + }, []); + + const handleEditMember = useCallback((member: Member) => { + setMemberToEdit(member); + setIsMemberDialogOpen(true); }, []); - const handleEmailInputChange = useCallback(() => { - setEmailState('None'); - setEmailMessage(''); + const handleCloseMemberFormDialog = useCallback(() => { + setIsMemberDialogOpen(false); }, []); + const handleSaveMember = useCallback( + (member: Member, isEdit: boolean) => { + let updatedMembers: Member[]; + if (isEdit) { + updatedMembers = members.map((m) => + m.name === memberToEdit?.name + ? { ...member, namespace: member.kind === 'ServiceAccount' ? member.namespace?.trim() : undefined } + : m, + ); + } else { + updatedMembers = [ + ...members, + { ...member, namespace: member.kind === 'ServiceAccount' ? member.namespace?.trim() : undefined }, + ]; + } + onMemberChanged(updatedMembers); + setIsMemberDialogOpen(false); + }, + [members, onMemberChanged, memberToEdit], + ); + return ( - - - - {emailMessage}} - data-testid="member-email-input" - onInput={handleEmailInputChange} - /> - - - - + + + ); diff --git a/src/components/Members/MemberRoleSelect.cy.tsx b/src/components/Members/MemberRoleSelect.cy.tsx deleted file mode 100644 index 1f4653be..00000000 --- a/src/components/Members/MemberRoleSelect.cy.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { MemberRoleSelect } from './MemberRoleSelect.tsx'; -import { MemberRoles } from '../../lib/api/types/shared/members'; -import '@ui5/webcomponents-cypress-commands'; - -function mountContainer(fn = function (_: MemberRoles): void {}) { - cy.mount(); -} - -describe('', () => { - beforeEach(() => { - mountContainer(); - }); - - it('Should select Administrator value', () => { - cy.get('#member-role-select').openDropDownByClick(); - cy.get('#member-role-select [value="admin"]').clickDropdownMenuItem({ - force: true, - }); - - cy.get('#member-role-select').should('have.value', MemberRoles.admin); - }); - - it('Should select Viewer value', () => { - cy.get('#member-role-select').openDropDownByClick(); - cy.get('#member-role-select [value="view"]').clickDropdownMenuItem({ - force: true, - }); - - cy.get('#member-role-select').should('have.value', MemberRoles.viewer); - }); -}); diff --git a/src/components/Members/MemberRoleSelect.tsx b/src/components/Members/MemberRoleSelect.tsx deleted file mode 100644 index 8536153e..00000000 --- a/src/components/Members/MemberRoleSelect.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { MemberRoles, MemberRolesDetailed } from '../../lib/api/types/shared/members'; -import { FlexBox, Label, Option, Select, SelectDomRef, Ui5CustomEvent } from '@ui5/webcomponents-react'; -import { SelectChangeEventDetail } from '@ui5/webcomponents/dist/Select.js'; -import { useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -interface MemberRoleSelectProps { - value: MemberRoles; - onChange: (value: MemberRoles) => void; -} -export function MemberRoleSelect({ value, onChange: fireOnChangeEventToParent }: MemberRoleSelectProps) { - const ref = useRef(null); - - const handleChange = (event: Ui5CustomEvent) => { - const newValue = event.detail.selectedOption.dataset.value as MemberRoles; - fireOnChangeEventToParent(newValue); - }; - - useEffect(() => { - if (ref.current) { - ref.current.value = value; - return; - } - }, [value]); - const { t } = useTranslation(); - return ( - - - - - ); -} diff --git a/src/components/Members/MemberTable.tsx b/src/components/Members/MemberTable.tsx index be248489..5513408d 100644 --- a/src/components/Members/MemberTable.tsx +++ b/src/components/Members/MemberTable.tsx @@ -1,18 +1,23 @@ -import { AnalyticalTable, Button } from '@ui5/webcomponents-react'; -import { Member, MemberRolesDetailed } from '../../lib/api/types/shared/members'; +import { AnalyticalTable, Button, FlexBox, Icon } from '@ui5/webcomponents-react'; +import { Member, MemberRoles, MemberRolesDetailed } from '../../lib/api/types/shared/members'; import { AnalyticalTableColumnDefinition } from '@ui5/webcomponents-react/wrappers'; import { useTranslation } from 'react-i18next'; import { FC } from 'react'; import { Infobox } from '../Ui/Infobox/Infobox.tsx'; +import { ACCOUNT_TYPES } from './EditMembers.tsx'; type MemberTableRow = { email: string; role: string; + kind: string; + namespace: string; + _member: Member; }; type MemberTableProps = { members: Member[]; onDeleteMember?: (email: string) => void; + onEditMember?: (member: Member) => void; isValidationError?: boolean; requireAtLeastOneMember: boolean; }; @@ -28,6 +33,7 @@ type CellInstance = { export const MemberTable: FC = ({ members, onDeleteMember, + onEditMember, isValidationError = false, requireAtLeastOneMember, }) => { @@ -38,16 +44,53 @@ export const MemberTable: FC = ({ Header: t('MemberTable.columnEmailHeader'), accessor: 'email', }, + + { + Header: t('MemberTable.columnTypeHeader'), + accessor: 'kind', + width: 145, + Cell: (instance: CellInstance) => { + const kind = ACCOUNT_TYPES.find(({ value }) => value === instance.cell.row.original.kind); + return ( + + + {kind?.label} + + ); + }, + }, { Header: t('MemberTable.columnRoleHeader'), accessor: 'role', + width: 105, + }, + { + Header: t('MemberTable.columnNamespaceHeader'), + accessor: 'namespace', }, ]; + if (onEditMember) { + columns.push({ + Header: '', + id: 'edit', + width: 50, + Cell: (instance: CellInstance) => ( +